From ba96b6b6f6529eafecd6fb24012d33b4bd45a520 Mon Sep 17 00:00:00 2001 From: Andrio Celos Date: Thu, 8 Feb 2024 15:49:49 +1100 Subject: [PATCH] Implement custom cards in battles --- TableturfBattleClient/index.html | 4 ++ TableturfBattleClient/src/Config.ts | 1 + TableturfBattleClient/src/GameVariables.ts | 4 +- TableturfBattleClient/src/Pages/GamePage.ts | 2 +- TableturfBattleClient/src/Pages/LobbyPage.ts | 26 ++++++- .../src/Pages/PreGamePage.ts | 3 + TableturfBattleClient/src/ReplayLoader.ts | 3 +- TableturfBattleClient/src/app.ts | 4 +- TableturfBattleServer/DataStructures.cs | 40 +++++++++++ TableturfBattleServer/Game.cs | 5 +- TableturfBattleServer/Player.cs | 2 + TableturfBattleServer/Program.cs | 70 +++++++++++++++++-- 12 files changed, 153 insertions(+), 11 deletions(-) diff --git a/TableturfBattleClient/index.html b/TableturfBattleClient/index.html index 18fa6c4..40b4717 100644 --- a/TableturfBattleClient/index.html +++ b/TableturfBattleClient/index.html @@ -85,6 +85,9 @@ +

Stage

@@ -663,6 +666,7 @@

+

Stage selection:

diff --git a/TableturfBattleClient/src/Config.ts b/TableturfBattleClient/src/Config.ts index 67e7c53..d28be0b 100644 --- a/TableturfBattleClient/src/Config.ts +++ b/TableturfBattleClient/src/Config.ts @@ -27,6 +27,7 @@ interface CustomRoomConfig { turnTimeLimit: number | null; goalWinCount: number | null; allowUpcomingCards: boolean; + allowCustomCards: boolean; stageSelectionMethodFirst: StageSelectionMethod; stageSelectionMethodAfterWin: StageSelectionMethod | null; stageSelectionMethodAfterDraw: StageSelectionMethod | null; diff --git a/TableturfBattleClient/src/GameVariables.ts b/TableturfBattleClient/src/GameVariables.ts index 28b3b7d..3b5dc79 100644 --- a/TableturfBattleClient/src/GameVariables.ts +++ b/TableturfBattleClient/src/GameVariables.ts @@ -13,7 +13,9 @@ interface Game { /** The number of game wins needed to win the set, or null if no goal win count is set. */ goalWinCount: number | null, /** Whether upcoming cards may be used. */ - allowUpcomingCards: boolean + allowUpcomingCards: boolean, + /** Whether custom cards may be used. */ + allowCustomCards: boolean } /** A UUID used to identify the client. */ diff --git a/TableturfBattleClient/src/Pages/GamePage.ts b/TableturfBattleClient/src/Pages/GamePage.ts index 5e176c0..df7e3f6 100644 --- a/TableturfBattleClient/src/Pages/GamePage.ts +++ b/TableturfBattleClient/src/Pages/GamePage.ts @@ -158,7 +158,7 @@ function initTest(stage: Stage) { clear(); testMode = true; gamePage.classList.add('deckTest'); - currentGame = { id: 'test', game: { state: GameState.Ongoing, maxPlayers: 2, players: [ ], turnNumber: 1, turnTimeLimit: null, turnTimeLeft: null, goalWinCount: null, allowUpcomingCards: true }, me: { playerIndex: 0, move: null, deck: null, hand: null, cardsUsed: [ ], stageSelectionPrompt: null }, isHost: false, webSocket: null }; + currentGame = { id: 'test', game: { state: GameState.Ongoing, maxPlayers: 2, players: [ ], turnNumber: 1, turnTimeLimit: null, turnTimeLeft: null, goalWinCount: null, allowUpcomingCards: true, allowCustomCards: true }, me: { playerIndex: 0, move: null, deck: null, hand: null, cardsUsed: [ ], stageSelectionPrompt: null }, isHost: false, webSocket: null }; board.flip = false; board.resize(stage.copyGrid()); const startSpaces = stage.getStartSpaces(2); diff --git a/TableturfBattleClient/src/Pages/LobbyPage.ts b/TableturfBattleClient/src/Pages/LobbyPage.ts index 276d18b..c4c3695 100644 --- a/TableturfBattleClient/src/Pages/LobbyPage.ts +++ b/TableturfBattleClient/src/Pages/LobbyPage.ts @@ -26,6 +26,7 @@ const lobbyDeckSubmitButton = document.getElementById('submitDeckButton') as HTM const lobbyTimeLimitBox = document.getElementById('lobbyTimeLimitBox') as HTMLInputElement; const lobbyAllowUpcomingCardsBox = document.getElementById('lobbyAllowUpcomingCardsBox') as HTMLInputElement; +const lobbyAllowCustomCardsBox = document.getElementById('lobbyAllowCustomCardsBox') as HTMLInputElement; const lobbyTimeLimitUnit = document.getElementById('lobbyTimeLimitUnit')!; const qrCodeDialog = document.getElementById('qrCodeDialog') as HTMLDialogElement; @@ -182,6 +183,7 @@ function lobbyResetSlots() { function lobbyLockSettings(lock: boolean) { lobbyTimeLimitBox.readOnly = lock; lobbyAllowUpcomingCardsBox.disabled = lock; + lobbyAllowCustomCardsBox.disabled = lock; } function clearReady() { @@ -298,7 +300,9 @@ function initDeckSelection() { } }); - if (!deck.isValid || (!currentGame.game.allowUpcomingCards && deck.cards.find(n => cardDatabase.get(n).number < 0))) { + if (!deck.isValid + || (!currentGame.game.allowUpcomingCards && deck.cards.find(n => cardDatabase.get(n).isUpcoming)) + || (!currentGame.game.allowCustomCards && deck.cards.find(n => n <= CUSTOM_CARD_START))) { button.enabled = false; } else if (deck.name == lastDeckName) { selectedDeck = deck; @@ -332,10 +336,28 @@ lobbyAllowUpcomingCardsBox.addEventListener('change', () => { req.send(data.toString()); }); +lobbyAllowCustomCardsBox.addEventListener('change', () => { + let req = new XMLHttpRequest(); + req.open('POST', `${config.apiBaseUrl}/games/${currentGame!.id}/setGameSettings`); + let data = new URLSearchParams(); + data.append('clientToken', clientToken); + data.append('allowCustomCards', lobbyAllowCustomCardsBox.checked.toString()); + req.send(data.toString()); +}); + deckSelectionForm.addEventListener('submit', e => { e.preventDefault(); if (selectedDeck == null) return; + const customCards: {[key: number]: Card} = { }; + let anyCustomCards = false; + for (const number of selectedDeck.cards) { + if (number <= CUSTOM_CARD_START) { + customCards[number] = cardDatabase.get(number); + anyCustomCards = true; + } + } + let req = new XMLHttpRequest(); req.open('POST', `${config.apiBaseUrl}/games/${currentGame!.id}/chooseDeck`); req.addEventListener('load', () => { @@ -351,6 +373,8 @@ deckSelectionForm.addEventListener('submit', e => { data.append('deckName', selectedDeck.name); data.append('deckCards', selectedDeck.cards.join('+')); data.append('deckSleeves', selectedDeck.sleeves.toString()); + if (anyCustomCards) + data.append('customCards', JSON.stringify(customCards, deckExportJsonReplacer)); req.send(data.toString()); localStorage.setItem('lastDeckName', selectedDeck.name); diff --git a/TableturfBattleClient/src/Pages/PreGamePage.ts b/TableturfBattleClient/src/Pages/PreGamePage.ts index 85bd178..89a420d 100644 --- a/TableturfBattleClient/src/Pages/PreGamePage.ts +++ b/TableturfBattleClient/src/Pages/PreGamePage.ts @@ -20,6 +20,7 @@ const maxPlayersBox = document.getElementById('maxPlayersBox') as HTMLSelectElem const turnTimeLimitBox = document.getElementById('turnTimeLimitBox') as HTMLInputElement; const goalWinCountBox = document.getElementById('goalWinCountBox') as HTMLSelectElement; const gameSetupAllowUpcomingCardsBox = document.getElementById('gameSetupAllowUpcomingCardsBox') as HTMLInputElement; +const gameSetupAllowCustomCardsBox = document.getElementById('gameSetupAllowCustomCardsBox') as HTMLInputElement; const stageSelectionRuleFirstBox = document.getElementById('stageSelectionRuleFirstBox') as HTMLSelectElement; const stageSelectionRuleAfterWinBox = document.getElementById('stageSelectionRuleAfterWinBox') as HTMLSelectElement; const stageSelectionRuleAfterDrawBox = document.getElementById('stageSelectionRuleAfterDrawBox') as HTMLSelectElement; @@ -174,6 +175,7 @@ function createRoom(useOptionsForm: boolean) { turnTimeLimit: turnTimeLimitBox.value ? turnTimeLimitBox.valueAsNumber : null, goalWinCount: goalWinCountBox.value ? parseInt(goalWinCountBox.value) : null, allowUpcomingCards: gameSetupAllowUpcomingCardsBox.checked, + allowCustomCards: gameSetupAllowCustomCardsBox.checked, stageSelectionMethodFirst: StageSelectionMethod[stageSelectionRuleFirstBox.value as keyof typeof StageSelectionMethod], stageSelectionMethodAfterWin: stageSelectionRuleAfterWinBox.value == 'Inherit' ? null : StageSelectionMethod[stageSelectionRuleAfterWinBox.value as keyof typeof StageSelectionMethod], stageSelectionMethodAfterDraw: stageSelectionRuleAfterDrawBox.value == 'Inherit' ? null : StageSelectionMethod[stageSelectionRuleAfterDrawBox.value as keyof typeof StageSelectionMethod], @@ -190,6 +192,7 @@ function createRoom(useOptionsForm: boolean) { if (goalWinCountBox.value) data.append('goalWinCount', goalWinCountBox.value); data.append('allowUpcomingCards', settings.allowUpcomingCards.toString()); + data.append('allowCustomCards', settings.allowCustomCards.toString()); const stageSelectionRuleFirst = { method: settings.stageSelectionMethodFirst, diff --git a/TableturfBattleClient/src/ReplayLoader.ts b/TableturfBattleClient/src/ReplayLoader.ts index db011df..d04763e 100644 --- a/TableturfBattleClient/src/ReplayLoader.ts +++ b/TableturfBattleClient/src/ReplayLoader.ts @@ -184,7 +184,8 @@ class ReplayLoader { turnTimeLimit: null, turnTimeLeft: null, goalWinCount: goalWinCount, - allowUpcomingCards: true + allowUpcomingCards: true, + allowCustomCards: true }, me: null, isHost: false, diff --git a/TableturfBattleClient/src/app.ts b/TableturfBattleClient/src/app.ts index 9366cdc..0029739 100644 --- a/TableturfBattleClient/src/app.ts +++ b/TableturfBattleClient/src/app.ts @@ -118,6 +118,7 @@ function onGameSettingsChange() { if (lobbyTimeLimitBox.value != currentGame.game.turnTimeLimit?.toString() ?? '') lobbyTimeLimitBox.value = currentGame.game.turnTimeLimit?.toString() ?? ''; lobbyAllowUpcomingCardsBox.checked = currentGame.game.allowUpcomingCards; + lobbyAllowCustomCardsBox.checked = currentGame.game.allowCustomCards; } function onGameStateChange(game: any, playerData: PlayerData | null) { @@ -281,7 +282,8 @@ function setupWebSocket(gameID: string) { turnTimeLimit: payload.data.turnTimeLimit, turnTimeLeft: payload.data.turnTimeLeft, goalWinCount: payload.data.goalWinCount, - allowUpcomingCards: payload.data.allowUpcomingCards + allowUpcomingCards: payload.data.allowUpcomingCards, + allowCustomCards: payload.data.allowCustomCards }, me: payload.playerData, isHost: payload.isHost, diff --git a/TableturfBattleServer/DataStructures.cs b/TableturfBattleServer/DataStructures.cs index 42091a4..b85cc75 100644 --- a/TableturfBattleServer/DataStructures.cs +++ b/TableturfBattleServer/DataStructures.cs @@ -23,3 +23,43 @@ public class PlayerData(int playerIndex, Card[]? hand, Deck? deck, Move? move, L public PlayerData(int playerIndex, Player player) : this(playerIndex, player.Hand, player.CurrentGameData.Deck, player.Move, player.CardsUsed, player.StageSelectionPrompt) { } } + +public record UserCustomCard(string Name, string? Line1, string? Line2, float TextScale, Colour InkColour1, Colour InkColour2, Rarity Rarity, Space[,] Grid) { + public bool CheckGrid(out bool hasSpecialSpace, out int size) { + size = 0; + hasSpecialSpace = false; + if (this.Grid is null || this.Grid.GetLength(0) != 8 || this.Grid.GetLength(1) != 8) return false; + for (var x = 0; x < 8; x++) { + for (var y = 0; y < 8; y++) { + switch (this.Grid[x, y]) { + case Space.Empty: + break; + case Space.Ink1: + size++; + break; + case Space.SpecialInactive1: + if (hasSpecialSpace) return false; + size++; + hasSpecialSpace = true; + break; + default: + return false; + } + } + } + // TODO: Maybe also check that the ink pattern is fully connected. + return size > 0; + } + + public bool Equals(Card? card) { + if (card is null || card.Name != this.Name || card.Rarity != this.Rarity) return false; + for (var x = 0; x < 8; x++) { + for (var y = 0; y < 8; y++) { + if (this.Grid[x, y] != card.GetSpace(x, y, 0)) return false; + } + } + return true; + } + + public Card ToCard(int number, int altNumber, int? specialCost) => new(number, altNumber, this.Name, this.Rarity, specialCost, this.TextScale, null, this.Grid) { InkColour1 = this.InkColour1, InkColour2 = this.InkColour2 }; +} diff --git a/TableturfBattleServer/Game.cs b/TableturfBattleServer/Game.cs index 4f163de..2892533 100644 --- a/TableturfBattleServer/Game.cs +++ b/TableturfBattleServer/Game.cs @@ -27,6 +27,7 @@ public class Game(int maxPlayers) { internal DateTime abandonedSince = DateTime.UtcNow; public bool AllowUpcomingCards { get; set; } = true; + public bool AllowCustomCards { get; set; } public required StageSelectionRules StageSelectionRuleFirst { get; set; } public required StageSelectionRules StageSelectionRuleAfterWin { get; set; } @@ -38,6 +39,8 @@ public class Game(int maxPlayers) { [JsonIgnore] internal List deckCache = []; [JsonIgnore] + internal List customCards = []; + [JsonIgnore] internal List setStages = []; private static readonly PlayerColours[] Colours = [ @@ -134,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 CardDatabase.GetCard(i)).ToArray(), cardUpgrades.ToArray()); + 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()); this.deckCache.Add(deck); } return deck; diff --git a/TableturfBattleServer/Player.cs b/TableturfBattleServer/Player.cs index d08aa7e..39c1baf 100644 --- a/TableturfBattleServer/Player.cs +++ b/TableturfBattleServer/Player.cs @@ -28,6 +28,8 @@ public class Player(Game game, string name, Guid token) { internal Move? Move; [JsonIgnore] internal Move? ProvisionalMove; + [JsonIgnore] + internal readonly Dictionary customCardMap = new(); public int GamesWon { get; set; } diff --git a/TableturfBattleServer/Program.cs b/TableturfBattleServer/Program.cs index 99f214c..e5bc5ee 100644 --- a/TableturfBattleServer/Program.cs +++ b/TableturfBattleServer/Program.cs @@ -26,6 +26,8 @@ internal partial class Program { 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 string? GetClientRootPath() { var directory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); @@ -161,13 +163,20 @@ internal partial class Program { } else clientToken = Guid.NewGuid(); - var allowUpcomingCards = false; + 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, out stageSelectionRuleFirst) || stageSelectionRuleFirst.Method is StageSelectionMethod.Same or StageSelectionMethod.Counterpick) { @@ -205,7 +214,7 @@ internal partial class Program { } else spectate = false; - var game = new Game(maxPlayers) { HostClientToken = clientToken, GoalWinCount = goalWinCount, TurnTimeLimit = turnTimeLimit, AllowUpcomingCards = allowUpcomingCards, StageSelectionRuleFirst = stageSelectionRuleFirst, StageSelectionRuleAfterWin = stageSelectionRuleAfterWin, StageSelectionRuleAfterDraw = stageSelectionRuleAfterDraw, ForceSameDeckAfterDraw = forceSameDeckAfterDraw }; + 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); @@ -351,6 +360,14 @@ internal partial class Program { 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; @@ -431,6 +448,42 @@ internal partial class Program { 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.")); @@ -451,7 +504,7 @@ internal partial class Program { } var cards = new int[15]; for (var i = 0; i < 15; i++) { - if (!int.TryParse(array[i], out var cardNumber) || !CardDatabase.IsValidCardNumber(cardNumber)) { + 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; } @@ -459,13 +512,20 @@ internal partial class Program { SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Deck cannot have duplicates.")); return; } - if (!game.AllowUpcomingCards && cardNumber < 0 && CardDatabase.GetCard(cardNumber).Number < 0) { + 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; } - cards[i] = cardNumber; + // 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);