Add upcoming cards

Upcoming cards are identified by a negative number. The pre-release number will be preserved as an alt number after release to avoid breaking saved deck and replay data that references it.
This commit is contained in:
Andrio Celos
2023-08-18 21:36:19 +10:00
parent 235d3ac25a
commit d366d955af
14 changed files with 229 additions and 38 deletions

View File

@@ -287,6 +287,7 @@ class Board {
clearChildren(this.table);
this.cells.splice(0);
this.highlightedCells.splice(0);
this.animatedCells.splice(0);
this.specialAnimatedCells.splice(0);
const boardWidth = this.grid.length;

View File

@@ -1,5 +1,6 @@
class Card {
number: number;
altNumber: number | null;
name: string;
rarity: Rarity;
specialCost: number;
@@ -11,8 +12,9 @@ class Card {
private maxX: number;
private maxY: number;
constructor(number: number, name: string, rarity: Rarity, specialCost: number, grid: Space[][]) {
constructor(number: number, altNumber: number | null, name: string, rarity: Rarity, specialCost: number, grid: Space[][]) {
this.number = number;
this.altNumber = altNumber;
this.name = name;
this.rarity = rarity;
this.specialCost = specialCost;
@@ -38,9 +40,11 @@ class Card {
}
static fromJson(obj: any) {
return new Card(obj.number, obj.name, obj.rarity, obj.specialCost, obj.grid);
return new Card(obj.number, obj.altNumber ?? null, obj.name, obj.rarity, obj.specialCost, obj.grid);
}
get isUpcoming() { return this.number < 0; }
getSpace(x: number, y: number, rotation: number) {
switch (rotation & 3) {
case 0: return this.grid[x][y];

View File

@@ -10,6 +10,7 @@ class CardButton extends CheckButton {
button.type = 'button';
button.classList.add('card');
button.classList.add([ 'common', 'rare', 'fresh' ][card.rarity]);
if (card.number < 0) button.classList.add('upcoming');
button.dataset.cardNumber = card.number.toString();
super(button);
@@ -40,7 +41,7 @@ class CardButton extends CheckButton {
let el2 = document.createElement('div');
el2.classList.add('cardNumber');
el2.innerText = card.number.toString();
el2.innerText = card.number >= 0 ? `No. ${card.number}` : 'Upcoming';
row.appendChild(el2);
el2 = document.createElement('div');

View File

@@ -1,9 +1,23 @@
const cardDatabase = {
cards: null as Card[] | null,
lastOfficialCardNumber: 0,
_byAltNumber: [ ] as Card[],
// Upcoming cards are identified with a negative number, as their actual numbers aren't known until their release.
// The placeholder numbers will be kept as alternate numbers then, to avoid breaking replay and saved deck data that uses them.
get(number: number) {
if (cardDatabase.cards == null) throw new Error('Card database not loaded');
if (number <= 0 || number > cardDatabase.cards.length) throw new RangeError(`No card with number ${number}`);
return cardDatabase.cards[number - 1];
if (number > 0) {
number--;
if (number < cardDatabase.lastOfficialCardNumber) return cardDatabase.cards[number];
} else if (number < 0) {
const card = cardDatabase._byAltNumber[-number];
if (card) return card;
}
throw new RangeError(`No card with number ${number}`);
},
isValidCardNumber(number: number) {
return number > 0 ? number <= cardDatabase.lastOfficialCardNumber : cardDatabase._byAltNumber[-number] != undefined;
},
loadAsync() {
return new Promise<Card[]>((resolve, reject) => {
@@ -19,7 +33,11 @@ const cardDatabase = {
const s = cardListRequest.responseText;
const response = JSON.parse(s) as object[];
for (const o of response) {
cards.push(Card.fromJson(o));
const card = Card.fromJson(o);
cards.push(card);
cardDatabase.lastOfficialCardNumber = Math.max(cardDatabase.lastOfficialCardNumber, card.number);
if (card.number < 0) cardDatabase._byAltNumber[-card.number] = card;
else if (card.altNumber != null && card.altNumber < 0) cardDatabase._byAltNumber[-card.altNumber] = card;
}
cardDatabase.cards = cards;
resolve(cards);

View File

@@ -4,11 +4,11 @@ class CardList {
readonly filterBox: HTMLInputElement;
readonly cardButtons: CardButton[] = [ ];
static readonly cardSortOrders: { [key: string]: ((a: Card, b: Card) => number) | undefined } = {
'number': (a, b) => a.number - b.number,
static readonly cardSortOrders: { [key: string]: (a: Card, b: Card) => number } = {
'number': (a, b) => compareCardNumbers(a, b),
'name': (a, b) => a.name.localeCompare(b.name),
'size': (a, b) => a.size != b.size ? a.size - b.size : a.number - b.number,
'rarity': (a, b) => a.rarity != b.rarity ? a.rarity - b.rarity : a.number - b.number,
'size': (a, b) => a.size != b.size ? a.size - b.size : compareCardNumbers(a, b),
'rarity': (a, b) => a.rarity != b.rarity ? a.rarity - b.rarity : compareCardNumbers(a, b),
}
constructor(listElement: HTMLElement, sortBox: HTMLSelectElement, filterBox: HTMLInputElement) {
@@ -55,3 +55,7 @@ class CardList {
button.buttonElement.hidden = false;
}
}
function compareCardNumbers(a: Card, b: Card) {
// Sort upcoming cards after released cards.
return a.number >= 0 ? (b.number >= 0 ? a.number - b.number : -1) : (b.number >= 0 ? 1 : b.number - a.number);
}

View File

@@ -13,7 +13,7 @@ class Deck {
if (!cardDatabase.cards) throw new Error('Card database must be loaded to validate decks.');
if (this.cards.length != 15) return false;
for (let i = 0; i < 15; i++) {
if (this.cards[i] <= 0 || this.cards[i] > cardDatabase.cards.length) return false;
if (!cardDatabase.isValidCardNumber(this.cards[i])) return false;
if (this.cards.indexOf(this.cards[i], i + 1) >= 0) return false; // Duplicate cards
}
return true;

View File

@@ -68,9 +68,8 @@ function deckEditInitStageDatabase(stages: Stage[]) {
}
} else if (selectedDeck) {
for (const cardNumber of selectedDeck.cards) {
if (cardNumber > 0 && cardNumber <= cardDatabase.cards!.length) {
if (cardDatabase.isValidCardNumber(cardNumber))
addTestDeckCard(cardDatabase.get(cardNumber));
}
}
}
@@ -105,12 +104,12 @@ function editDeck() {
showPage('deckEdit');
}
function createDeckEditCardButton(index: number, card: number) {
const button = new CardButton(cardDatabase.cards![card - 1]);
function createDeckEditCardButton(index: number, cardNumber: number) {
const button = new CardButton(cardDatabase.get(cardNumber));
button.buttonElement.addEventListener('click', () => {
selectedDeckCardIndex = index;
for (const button2 of cardList.cardButtons) {
button2.checked = button2.card.number == card;
button2.checked = button2.card.number == cardNumber;
}
cardList.listElement.parentElement!.classList.add('selecting');
});

View File

@@ -158,7 +158,7 @@ deckImportForm.addEventListener('submit', e => {
const data = JSON.parse(deckImportTextBox.value);
const decks = (data instanceof Array ? data : [ data ]) as Deck[];
for (const deck of decks) {
if (typeof(deck) != 'object' || !Array.isArray(deck.cards) || deck.cards.length != 15 || deck.cards.find(i => i < 0 || i > cardDatabase.cards!.length))
if (typeof(deck) != 'object' || !Array.isArray(deck.cards) || deck.cards.length != 15 || deck.cards.find(i => !cardDatabase.isValidCardNumber(i)))
throw new SyntaxError('Invalid JSON deck');
}
importDecks(decks);
@@ -187,7 +187,7 @@ function selectDeck() {
deckNameLabel.innerText = selectedDeck.name;
for (const cardNumber of selectedDeck.cards) {
if (cardNumber) {
const card = cardDatabase.cards![cardNumber - 1];
const card = cardDatabase.get(cardNumber);
size += card.size;
const button = new CardButton(card);

View File

@@ -38,7 +38,7 @@ function loadReplay(base64: string) {
const initialDrawOrder = [ ];
const drawOrder = [ ];
for (let j = 0; j < 15; j++) {
deck.push(cardDatabase.get(dataView.getUint8(pos + 9 + j)));
deck.push(loadCardFromReplay(dataView, pos + 9 + j));
}
for (let j = 0; j < 2; j++) {
initialDrawOrder.push(dataView.getUint8(pos + 24 + j) & 0xF);
@@ -59,14 +59,14 @@ function loadReplay(base64: string) {
for (let i = 0; i < 12; i++) {
const turn = [ ];
for (let j = 0; j < numPlayers; j++) {
const cardNumber = dataView.getUint8(pos);
const card = loadCardFromReplay(dataView, pos);
const b = dataView.getUint8(pos + 1);
const x = dataView.getInt8(pos + 2);
const y = dataView.getInt8(pos + 3);
if (b & 0x80)
turn.push({ card: cardDatabase.get(cardNumber), isPass: true, isTimeout: (b & 0x20) != 0 });
turn.push({ card, isPass: true, isTimeout: (b & 0x20) != 0 });
else {
const move: PlayMove = { card: cardDatabase.get(cardNumber), isPass: false, isTimeout: (b & 0x20) != 0, x, y, rotation: b & 0x03, isSpecialAttack: (b & 0x40) != 0 };
const move: PlayMove = { card, isPass: false, isTimeout: (b & 0x20) != 0, x, y, rotation: b & 0x03, isSpecialAttack: (b & 0x40) != 0 };
turn.push(move);
}
pos += 4;
@@ -113,7 +113,7 @@ function loadReplay(base64: string) {
const drawOrder = [ ];
let won = false;
for (let j = 0; j < 15; j++) {
deck.push(cardDatabase.get(dataView.getUint8(pos + j)));
deck.push(loadCardFromReplay(dataView, pos + j));
}
for (let j = 0; j < 2; j++) {
initialDrawOrder.push(dataView.getUint8(pos + 15 + j) & 0xF);
@@ -162,14 +162,14 @@ function replayLoadTurns(dataView: DataView, numPlayers: number, pos: number) {
for (let i = 0; i < 12; i++) {
const turn = [ ];
for (let j = 0; j < numPlayers; j++) {
const cardNumber = dataView.getUint8(pos);
const card = loadCardFromReplay(dataView, pos);
const b = dataView.getUint8(pos + 1);
const x = dataView.getInt8(pos + 2);
const y = dataView.getInt8(pos + 3);
if (b & 0x80)
turn.push({ card: cardDatabase.get(cardNumber), isPass: true, isTimeout: (b & 0x20) != 0 });
turn.push({ card, isPass: true, isTimeout: (b & 0x20) != 0 });
else {
const move: PlayMove = { card: cardDatabase.get(cardNumber), isPass: false, isTimeout: (b & 0x20) != 0, x, y, rotation: b & 0x03, isSpecialAttack: (b & 0x40) != 0 };
const move: PlayMove = { card, isPass: false, isTimeout: (b & 0x20) != 0, x, y, rotation: b & 0x03, isSpecialAttack: (b & 0x40) != 0 };
turn.push(move);
}
pos += 4;
@@ -178,3 +178,5 @@ function replayLoadTurns(dataView: DataView, numPlayers: number, pos: number) {
}
return turns;
}
function loadCardFromReplay(dataView: DataView, index: number) { return cardDatabase.get(dataView.getUint8(index) > cardDatabase.lastOfficialCardNumber ? dataView.getInt8(index) : dataView.getUint8(index)); }

View File

@@ -347,6 +347,7 @@ function setupWebSocket(gameID: string) {
const move = payload.data.moves[i];
const button = new CardButton(move.card);
button.buttonElement.disabled = true;
if (move.isSpecialAttack) {
anySpecialAttacks = true;
button.buttonElement.classList.add('specialAttack');

View File

@@ -306,6 +306,8 @@ dialog::backdrop {
display: inline-block;
}
:is(#deckCardListView, #deckCardListEdit, .cardListGrid) .card.upcoming { background: midnightblue; }
.card.common { --colour: rgb(89, 49, 255); }
.card.rare { --colour: rgb(231, 180, 39); }
.card.fresh { --colour: white; }
@@ -323,12 +325,25 @@ dialog::backdrop {
display: none;
}
.cardListGrid .card:hover .cardNumber {
display: block;
position: absolute;
background: grey;
border: 1px solid black;
top: -1.5em;
left: -1em;
padding: 0 1em;
transform: rotate(-10deg);
z-index: 1;
}
.cardName {
text-align: center;
line-height: 1.25em;
flex-grow: 1;
}
.card:is([data-card-number="163"], [data-card-number="166"], [data-card-number="196"], [data-card-number="197"], [data-card-number="199"], [data-card-number="202"]) .cardName {
.card:is([data-card-number="163"], [data-card-number="166"], [data-card-number="196"], [data-card-number="197"], [data-card-number="199"],
[data-card-number="202"], [data-card-number="-3"], [data-card-number="-5"], [data-card-number="-12"]) .cardName {
position: absolute;
left: -1em;
right: -1em;
@@ -415,10 +430,14 @@ dialog::backdrop {
top: 0;
right: 0;
bottom: 0;
border-radius: 0.5em;
opacity: 0.25;
z-index: 0;
}
.card:is(:hover, :focus-within):not(.checked, .disabled)::before {
.card:is(.checked, :hover, :focus-within):not([disabled]) {
transform: rotate(-3deg);
}
.card:is(:hover, :focus-within):not(.checked, .disabled, [disabled])::before {
content: '';
background: grey;
}
@@ -1540,7 +1559,7 @@ dialog::backdrop {
.playerBar[data-index="1"] { grid-column: 1 / span 4; grid-row: 1; }
.playerBar[data-index="2"] { grid-column: 1 / span 4; grid-row: 3; }
.playerBar[data-index="3"] { grid-column: 1 / span 4; grid-row: 1; }
.wins { margin-right: 8em; }
#gamePage.boardFlipped .playerBar[data-index="3"] { grid-column: 1 / span 4; grid-row: 3; }

View File

@@ -4,6 +4,8 @@ namespace TableturfBattleServer;
public class Card {
[JsonProperty("number")]
public int Number { get; }
[JsonProperty("altNumber")]
public int? AltNumber { get; }
[JsonProperty("name")]
public string Name { get; }
[JsonProperty("rarity")]
@@ -16,9 +18,12 @@ public class Card {
[JsonProperty("grid")]
private readonly Space[,] grid;
internal Card(int number, string name, Rarity rarity, Space[,] grid) : this(number, name, rarity, null, grid) { }
internal Card(int number, string name, Rarity rarity, int? specialCost, Space[,] grid) {
internal Card(int number, string name, Rarity rarity, Space[,] grid) : this(number, null, name, rarity, null, grid) { }
internal Card(int number, int? altNumber, string name, Rarity rarity, Space[,] grid) : this(number, altNumber, name, rarity, null, grid) { }
internal Card(int number, string name, Rarity rarity, int? specialCost, Space[,] grid) : this(number, null, name, rarity, specialCost, grid) { }
internal Card(int number, int? altNumber, string name, Rarity rarity, int? specialCost, Space[,] grid) {
this.Number = number;
this.AltNumber = altNumber;
this.Name = name ?? throw new ArgumentNullException(nameof(name));
this.Rarity = rarity;
this.grid = grid ?? throw new ArgumentNullException(nameof(grid));

View File

@@ -2097,21 +2097,158 @@ public static class CardDatabase {
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 }
}),
// Upcoming Drizzle Season 2023 cards
new(-1, "Custom Goo Tuber", Rarity.Common, new Space[,] {
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, I, 0, 0, 0, 0, 0 },
{ 0, 0, I, I, 0, 0, 0, 0 },
{ 0, 0, I, 0, I, 0, 0, 0 },
{ 0, 0, I, S, 0, 0, 0, 0 },
{ 0, 0, I, I, 0, 0, 0, 0 },
{ 0, 0, I, I, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 }
}),
new(-2, "Sloshing Machine Neo", Rarity.Common, new Space[,] {
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, I, I, I, 0, 0, 0 },
{ 0, 0, S, I, I, 0, 0, 0 },
{ 0, 0, I, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 }
}),
new(-3, "Heavy Edit Splatling", Rarity.Common, new Space[,] {
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, I, I, 0, 0 },
{ 0, 0, 0, I, I, I, 0, 0 },
{ 0, 0, 0, I, I, S, 0, 0 },
{ 0, 0, 0, I, 0, I, 0, 0 },
{ 0, 0, 0, I, 0, 0, 0, 0 },
{ 0, 0, 0, I, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 }
}),
new(-4, "Dread Wringer", Rarity.Common, new Space[,] {
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, I, 0, I, 0, 0 },
{ 0, 0, I, I, I, I, 0, 0 },
{ 0, 0, I, I, I, I, 0, 0 },
{ 0, 0, 0, 0, S, I, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 }
}),
new(-5, "Octobrush Nouveau", Rarity.Common, new Space[,] {
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, I, 0, 0, 0, 0, 0 },
{ 0, I, I, I, 0, 0, 0, 0 },
{ 0, 0, S, I, 0, 0, 0, 0 },
{ 0, 0, 0, 0, I, 0, 0, 0 },
{ 0, 0, 0, 0, 0, I, 0, 0 },
{ 0, 0, 0, 0, 0, 0, I, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 }
}),
new(-6, "Gold Dynamo Roller", Rarity.Common, new Space[,] {
{ 0, 0, I, I, 0, 0, 0, 0 },
{ 0, 0, I, I, 0, 0, 0, 0 },
{ 0, 0, I, I, 0, 0, 0, 0 },
{ 0, 0, I, S, I, I, 0, 0 },
{ 0, 0, I, 0, 0, 0, 0, 0 },
{ 0, 0, I, 0, 0, 0, 0, 0 },
{ 0, 0, I, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 }
}),
new(-7, "Tri-Stringer Collab", Rarity.Common, new Space[,] {
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, I, 0, 0, 0, 0, 0, 0 },
{ 0, I, 0, 0, 0, 0, 0, 0 },
{ 0, I, I, 0, 0, 0, 0, 0 },
{ 0, S, 0, I, 0, 0, 0, 0 },
{ 0, I, I, I, I, I, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 }
}),
new(-8, "Bloblobber Deco", Rarity.Common, new Space[,] {
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, I, 0, 0, 0, 0 },
{ 0, 0, 0, I, I, 0, 0, 0 },
{ 0, 0, 0, I, I, 0, 0, 0 },
{ 0, 0, 0, I, I, 0, 0, 0 },
{ 0, 0, I, S, I, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 }
}),
new(-9, "Horrorboros", Rarity.Fresh, new Space[,] {
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, S, 0, 0, 0, 0, 0 },
{ 0, I, 0, I, 0, 0, 0, 0 },
{ 0, I, I, 0, I, I, I, 0 },
{ I, 0, I, 0, I, 0, I, 0 },
{ 0, 0, I, I, I, 0, I, 0 },
{ 0, 0, 0, 0, 0, I, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 }
}),
new(-10, "Cohozuna", Rarity.Fresh, new Space[,] {
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, I, 0, I, I, 0, 0, 0 },
{ 0, I, I, I, I, I, 0, 0 },
{ 0, 0, I, I, I, I, 0, 0 },
{ 0, 0, 0, I, I, I, 0, 0 },
{ 0, 0, 0, 0, 0, I, 0, 0 },
{ 0, 0, 0, 0, S, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 }
}),
new(-11, "Sorella Brella", Rarity.Common, new Space[,] {
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, I, 0, 0, 0, 0 },
{ 0, 0, I, I, I, 0, 0, 0 },
{ 0, I, I, S, I, I, 0, 0 },
{ 0, 0, 0, I, 0, 0, 0, 0 },
{ 0, 0, 0, 0, I, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 }
}),
new(-12, "Ballpoint Splatling Nouveau", Rarity.Common, new Space[,] {
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, I, 0, 0, 0 },
{ 0, 0, 0, I, S, 0, 0, 0 },
{ 0, 0, 0, I, I, 0, 0, 0 },
{ 0, 0, I, I, I, 0, 0, 0 },
{ 0, 0, 0, I, I, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0 }
})
};
public static Version Version { get; } = new(4, 1, 0, 0);
public static DateTime LastModified { get; } = new(2023, 8, 12, 9, 0, 0, DateTimeKind.Utc);
private static readonly Dictionary<int, Card> byAltNumber;
public static int LastOfficialCardNumber { get; }
public static Version Version { get; } = new(5, 0, 0, 0);
public static DateTime LastModified { get; } = new(2023, 8, 18, 1, 0, 0, DateTimeKind.Utc);
public static string JSON { get; }
public static ReadOnlyCollection<Card> Cards { get; }
static CardDatabase() {
Cards = Array.AsReadOnly(cards);
JSON = JsonConvert.SerializeObject(cards);
LastOfficialCardNumber = cards.Last(c => c.Number > 0).Number;
byAltNumber = new(from c in cards where c.Number < 0 || c.AltNumber is not null select new KeyValuePair<int, Card>(c.Number < 0 ? c.Number : c.AltNumber ?? 0, c));
}
public static Card GetCard(int number) {
number--;
return number >= 0 && number < cards.Length ? cards[number] : throw new ArgumentOutOfRangeException(nameof(number));
}
// Upcoming cards are identified with a negative number, as their actual numbers aren't known until their release.
// The placeholder numbers will be kept as alternate numbers then, to avoid breaking replay and saved deck data that uses them.
public static Card GetCard(int number) => number switch {
> 0 => --number < LastOfficialCardNumber ? cards[number] : throw new ArgumentOutOfRangeException(nameof(number)),
< 0 => byAltNumber.TryGetValue(number, out var card) ? card : throw new ArgumentOutOfRangeException(nameof(number)),
_ => throw new ArgumentOutOfRangeException(nameof(number))
};
public static bool IsValidCardNumber(int number) => number switch {
> 0 => number <= LastOfficialCardNumber,
< 0 => byAltNumber.ContainsKey(number),
_ => false
};
}

View File

@@ -349,7 +349,7 @@ internal class Program {
}
var cards = new int[15];
for (int i = 0; i < 15; i++) {
if (!int.TryParse(array[i], out var cardNumber) || cardNumber < 0 || cardNumber > CardDatabase.Cards.Count) {
if (!int.TryParse(array[i], out var cardNumber) || !CardDatabase.IsValidCardNumber(cardNumber)) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Invalid deck list."));
return;
}