Implement custom cards in battles

This commit is contained in:
Andrio Celos 2024-02-08 15:49:49 +11:00
parent 3d742ac443
commit ba96b6b6f6
12 changed files with 153 additions and 11 deletions

View File

@ -85,6 +85,9 @@
<label for="lobbyAllowUpcomingCardsBox">
<input type="checkbox" id="lobbyAllowUpcomingCardsBox"/> Allow upcoming cards
</label>
<label for="lobbyAllowCustomCardsBox">
<input type="checkbox" id="lobbyAllowCustomCardsBox"/> Allow custom cards
</label>
</section>
<section id="lobbySelectedStageSection">
<h3>Stage</h3>
@ -663,6 +666,7 @@
</p>
<p>
<label for="gameSetupAllowUpcomingCardsBox"><input type="checkbox" id="gameSetupAllowUpcomingCardsBox" checked/> Allow upcoming cards</label>
<label for="gameSetupAllowCustomCardsBox"><input type="checkbox" id="gameSetupAllowCustomCardsBox"/> Allow custom cards</label>
</p>
<p>Stage selection:</p>
<table>

View File

@ -27,6 +27,7 @@ interface CustomRoomConfig {
turnTimeLimit: number | null;
goalWinCount: number | null;
allowUpcomingCards: boolean;
allowCustomCards: boolean;
stageSelectionMethodFirst: StageSelectionMethod;
stageSelectionMethodAfterWin: StageSelectionMethod | null;
stageSelectionMethodAfterDraw: StageSelectionMethod | null;

View File

@ -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. */

View File

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

View File

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

View File

@ -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,

View File

@ -184,7 +184,8 @@ class ReplayLoader {
turnTimeLimit: null,
turnTimeLeft: null,
goalWinCount: goalWinCount,
allowUpcomingCards: true
allowUpcomingCards: true,
allowCustomCards: true
},
me: null,
isHost: false,

View File

@ -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,

View File

@ -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 };
}

View File

@ -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<Deck> deckCache = [];
[JsonIgnore]
internal List<Card> customCards = [];
[JsonIgnore]
internal List<int> setStages = [];
private static readonly PlayerColours[] Colours = [
@ -134,7 +137,7 @@ public class Game(int maxPlayers) {
public Deck GetDeck(string name, int sleeves, IEnumerable<int> cardNumbers, IEnumerable<int> 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;

View File

@ -28,6 +28,8 @@ public class Player(Game game, string name, Guid token) {
internal Move? Move;
[JsonIgnore]
internal Move? ProvisionalMove;
[JsonIgnore]
internal readonly Dictionary<int, int> customCardMap = new();
public int GamesWon { get; set; }

View File

@ -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<int, UserCustomCard>? userCustomCards = null;
List<KeyValuePair<int, Card>>? 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<Dictionary<int, UserCustomCard>>(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);