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);