Add more options for stage selection. Resolves #13

This commit is contained in:
Andrio Celos
2023-11-18 23:24:10 +11:00
parent 329013797f
commit ffd9d7d807
18 changed files with 635 additions and 102 deletions

View File

@@ -85,12 +85,14 @@
</div>
</section>
<section id="lobbyStageSection" hidden>
<h3>Vote for the stage.</h3>
<form id="stageSelectionForm">
<div class="submitButtonContainer">
<button type="submit" id="submitStageButton">Submit</button>
<div class="loadingContainer" hidden>
<div class="loadingSpinner"></div>
<form id="stageSelectionForm" hidden>
<div id="stageSelectionFormHeader">
<h3 id="stagePrompt">Vote for the stage.</h3>
<div class="submitButtonContainer">
<button type="submit" id="submitStageButton" disabled>Submit</button>
<div class="loadingContainer" hidden>
<div class="loadingSpinner"></div>
</div>
</div>
</div>
<div id="stageList">
@@ -98,6 +100,11 @@
</div>
<div id="stageListLoadingSection"><div class="loadingSpinner"></div> Loading stages...</div>
</form>
<form id="strikeOrderSelectionForm" hidden>
<h3 id="stagePrompt">Do you want to strike first or second?</h3>
<button type="submit" data-strike-index="0">Strike first</button>
<button type="submit" data-strike-index="1">Strike second</button>
</form>
</section>
<div id="lobbyDeckSection" hidden>
<h3>Choose your deck.</h3>
@@ -602,6 +609,49 @@
</select>
</label>
</p>
<p>Stage selection:</p>
<table>
<tr>
<td><label for="stageSelectionRuleFirstBox">First battle:</label></td>
<td>
<select id="stageSelectionRuleFirstBox">
<option value="Vote">Vote</option>
<option value="Random">Random/Fixed</option>
<option value="Strike">Strike</option>
</select>
</td>
</tr>
<tr>
<td><label for="stageSelectionRuleAfterWinBox">After a win:</label></td>
<td>
<select id="stageSelectionRuleAfterWinBox">
<option value="Inherit">Same method as the first battle</option>
<option value="Same">Repeat stage of the last battle</option>
<option value="Vote">Vote</option>
<option value="Random">Random/Fixed</option>
<option value="Counterpick">Counterpick</option>
<option value="Strike">Strike</option>
</select>
</td>
</tr>
<tr>
<td><label for="stageSelectionRuleAfterDrawBox">After a draw:</label></td>
<td>
<select id="stageSelectionRuleAfterDrawBox">
<option value="Inherit">Same method as the first battle</option>
<option value="Same">Repeat stage of the last battle</option>
<option value="Vote">Vote</option>
<option value="Random">Random/Fixed</option>
<option value="Strike">Strike</option>
</select>
</td>
<td>
<label for="gameSetupForceSameDeckAfterDrawBox"><input type="checkbox" id="gameSetupForceSameDeckAfterDrawBox"/> Force same decks</label>
</td>
</tr>
</table>
<p>Stage switch:</p>
<div id="stageSwitch"></div>
<p>
<button type="submit" id="gameSetupSubmitButton">Create room</button>
<button type="submit">Back</button>

View File

@@ -3,6 +3,7 @@ class CheckButtonGroup<TValue> {
entries: Array<{ button: CheckButton, value: TValue }> = [ ];
parentElement: HTMLElement | null;
value: TValue | null = null;
allowMultipleSelections = false;
constructor(parentElement?: HTMLElement | null) {
this.parentElement = parentElement ?? null;
@@ -12,7 +13,10 @@ class CheckButtonGroup<TValue> {
private setupButton(button: CheckButton, value: TValue) {
button.buttonElement.addEventListener('click', () => {
if (button.enabled && !button.checked) {
if (!button.enabled) return;
if (this.allowMultipleSelections)
button.checked = !button.checked;
else if (!button.checked) {
for (const el of this.entries) {
if (el.button == button) {
el.button.checked = true;

View File

@@ -154,7 +154,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 }, me: { playerIndex: 0, move: null, deck: null, hand: null, cardsUsed: [ ] }, webSocket: null };
currentGame = { id: 'test', game: { state: GameState.Ongoing, maxPlayers: 2, players: [ ], turnNumber: 1, turnTimeLimit: null, turnTimeLeft: null, goalWinCount: null }, me: { playerIndex: 0, move: null, deck: null, hand: null, cardsUsed: [ ], stageSelectionPrompt: null }, webSocket: null };
board.resize(stage.copyGrid());
const startSpaces = stage.getStartSpaces(2);
board.startSpaces = startSpaces;

View File

@@ -1,17 +1,18 @@
const lobbyWinCounters: WinCounter[] = [ ];
const stageButtons = new CheckButtonGroup<Stage>(document.getElementById('stageList')!);
const stageButtons = new CheckButtonGroup<number>(document.getElementById('stageList')!);
const shareLinkButton = document.getElementById('shareLinkButton') as HTMLButtonElement;
const showQrCodeButton = document.getElementById('showQrCodeButton') as HTMLButtonElement;
const stageSelectionForm = document.getElementById('stageSelectionForm') as HTMLFormElement;
const stageSelectionFormLoadingSection = stageSelectionForm.getElementsByClassName('loadingContainer')[0] as HTMLElement;
const stageSelectionFormSubmitButton = document.getElementById('submitStageButton') as HTMLButtonElement;
const stageRandomButton = CheckButton.fromId('stageRandomButton');
const strikeOrderSelectionForm = document.getElementById('strikeOrderSelectionForm') as HTMLFormElement;
const deckSelectionForm = document.getElementById('deckSelectionForm') as HTMLFormElement;
const deckSelectionFormLoadingSection = deckSelectionForm.getElementsByClassName('loadingContainer')[0] as HTMLElement;
const lobbySelectedStageSection = document.getElementById('lobbySelectedStageSection')!;
const lobbyStageSection = document.getElementById('lobbyStageSection')!;
const stagePrompt = document.getElementById('stagePrompt')!;
const lobbyStageSubmitButton = document.getElementById('submitStageButton') as HTMLButtonElement;
const lobbyDeckSection = document.getElementById('lobbyDeckSection')!;
const lobbyDeckList = document.getElementById('lobbyDeckList')!;
@@ -26,13 +27,17 @@ let qrCode: QRCode | null;
let lobbyShareData: ShareData | null;
let selectedStageIndicator = null as StageButton | null;
let stageSelectionPrompt = null as StageSelectionPrompt | null;
function lobbyInitStageDatabase(stages: Stage[]) {
stageButtons.add(stageRandomButton, -1);
let i = 0;
for (const stage of stages) {
const button = new StageButton(stage);
stageButtons.add(button, stage);
stageButtons.add(button, i++);
button.buttonElement.addEventListener('click', () => {
stageRandomButton.checked = false;
lobbyStageSubmitButton.disabled = !stageSelectionPrompt || stageButtons.buttons.filter(b => b.checked).length != (stageSelectionPrompt.promptType == StageSelectionPromptType.Strike ? stageSelectionPrompt.numberOfStagesToStrike : 1);
});
button.setStartSpaces(2);
}
@@ -49,12 +54,72 @@ function initLobbyPage(url: string) {
}
}
function showStageSelectionForm() {
function showStageSelectionForm(prompt: StageSelectionPrompt | null, isReady: boolean) {
stageSelectionPrompt = prompt;
if (!prompt) return;
lobbyStageSection.hidden = false;
stageSelectionFormLoadingSection.hidden = true;
stageRandomButton.checked = true;
stageButtons.deselect();
lobbyStageSubmitButton.disabled = false;
lobbyStageSubmitButton.disabled = true;
let i = -1;
for (const button of stageButtons.buttons) {
const originalClass = i < 0 ? 'stageRandom' : 'stage';
if (prompt.bannedStages?.includes(i)) {
button.buttonElement.className = `${originalClass} banned`;
button.enabled = false;
} else if (prompt.struckStages?.includes(i)) {
button.buttonElement.className = `${originalClass} struck`;
button.enabled = false;
} else {
button.buttonElement.className = originalClass;
button.enabled = prompt.promptType != StageSelectionPromptType.Wait && !isReady;
}
i++;
}
switch (prompt.promptType) {
case StageSelectionPromptType.Vote:
stageSelectionForm.hidden = false;
stageRandomButton.buttonElement.hidden = false;
strikeOrderSelectionForm.hidden = true;
stagePrompt.innerText = isReady ? 'Opponent is choosing...' : 'Vote for the stage.';
stageButtons.allowMultipleSelections = false;
stageButtons.parentElement!.classList.remove('striking');
break;
case StageSelectionPromptType.VoteOrder:
stageSelectionForm.hidden = true;
strikeOrderSelectionForm.hidden = false;
for (const button of strikeOrderSelectionForm.getElementsByTagName('button'))
(<HTMLButtonElement> button).disabled = isReady;
break;
case StageSelectionPromptType.Strike:
stageSelectionForm.hidden = false;
stageRandomButton.buttonElement.hidden = true;
strikeOrderSelectionForm.hidden = true;
stagePrompt.innerText = prompt.numberOfStagesToStrike == 1 ? 'Choose a stage to strike.' : `Choose ${prompt.numberOfStagesToStrike} stages to strike.`;
stageButtons.allowMultipleSelections = prompt.numberOfStagesToStrike != 1;
stageButtons.parentElement!.classList.add('striking');
break;
case StageSelectionPromptType.Choose:
stageSelectionForm.hidden = false;
stageRandomButton.buttonElement.hidden = true;
strikeOrderSelectionForm.hidden = true;
stagePrompt.innerText = 'Choose the stage for the next battle.';
stageButtons.allowMultipleSelections = false;
stageButtons.parentElement!.classList.remove('striking');
break;
case StageSelectionPromptType.Wait:
stageSelectionForm.hidden = false;
stageRandomButton.buttonElement.hidden = true;
strikeOrderSelectionForm.hidden = true;
stagePrompt.innerText = currentGame?.game.state == GameState.ChoosingStage ? 'Opponent is choosing...' : 'Possible stages:';
stageButtons.allowMultipleSelections = false;
stageButtons.parentElement!.classList.remove('striking');
break;
}
}
shareLinkButton.addEventListener('click', () => {
@@ -111,7 +176,7 @@ function lobbyLockSettings(lock: boolean) {
function clearReady() {
if (!currentGame) throw new Error('No current game');
stageSelectionFormSubmitButton.disabled = false;
lobbyStageSubmitButton.disabled = false;
stageSelectionFormLoadingSection.hidden = true;
for (var i = 0; i < currentGame.game.players.length; i++) {
currentGame.game.players[i].isReady = false;
@@ -171,6 +236,7 @@ function initDeckSelection() {
}
lobbyDeckSubmitButton.disabled = selectedDeck == null;
deckSelectionFormLoadingSection.hidden = true;
lobbyStageSection.hidden = true;
lobbyDeckSection.hidden = false;
} else {
lobbyDeckSection.hidden = true;
@@ -215,6 +281,7 @@ deckSelectionForm.addEventListener('submit', e => {
stageRandomButton.buttonElement.addEventListener('click', () => {
stageRandomButton.checked = true;
stageButtons.deselect();
lobbyStageSubmitButton.disabled = false;
});
stageSelectionForm.addEventListener('submit', e => {
@@ -222,20 +289,41 @@ stageSelectionForm.addEventListener('submit', e => {
let req = new XMLHttpRequest();
req.open('POST', `${config.apiBaseUrl}/games/${currentGame!.id}/chooseStage`);
req.addEventListener('load', () => {
stageSelectionFormLoadingSection.hidden = true;
if (req.status != 204) {
stageSelectionFormLoadingSection.hidden = true;
alert(req.responseText);
lobbyStageSubmitButton.disabled = false;
}
});
req.addEventListener('error', () => communicationError());
let data = new URLSearchParams();
const stageName = stageRandomButton.checked ? 'random' : stageButtons.value!.name;
data.append('clientToken', clientToken);
data.append('stage', stageName);
data.append('stages', stageButtons.entries.filter(e => e.button.checked).map(e => e.value).join(','));
req.send(data.toString());
localStorage.setItem('lastStage', stageName);
stageSelectionFormLoadingSection.hidden = false;
lobbyStageSubmitButton.disabled = true;
});
strikeOrderSelectionForm.addEventListener('submit', e => {
e.preventDefault();
for (const button of strikeOrderSelectionForm.getElementsByTagName('button'))
(<HTMLButtonElement> button).disabled = true;
let req = new XMLHttpRequest();
req.open('POST', `${config.apiBaseUrl}/games/${currentGame!.id}/chooseStage`);
req.addEventListener('load', () => {
if (req.status != 204) {
stageSelectionFormLoadingSection.hidden = true;
alert(req.responseText);
lobbyStageSubmitButton.disabled = false;
for (const button of strikeOrderSelectionForm.getElementsByTagName('button'))
(<HTMLButtonElement> button).disabled = false;
}
});
req.addEventListener('error', () => communicationError());
let data = new URLSearchParams();
const number = e.submitter!.dataset.strikeIndex!;
data.append('clientToken', clientToken);
data.append('stages', number);
req.send(data.toString());
});

View File

@@ -18,6 +18,13 @@ const gameSetupForm = document.getElementById('gameSetupForm') as HTMLFormElemen
const maxPlayersBox = document.getElementById('maxPlayersBox') as HTMLSelectElement;
const turnTimeLimitBox = document.getElementById('turnTimeLimitBox') as HTMLInputElement;
const goalWinCountBox = document.getElementById('goalWinCountBox') as HTMLSelectElement;
const stageSelectionRuleFirstBox = document.getElementById('stageSelectionRuleFirstBox') as HTMLSelectElement;
const stageSelectionRuleAfterWinBox = document.getElementById('stageSelectionRuleAfterWinBox') as HTMLSelectElement;
const stageSelectionRuleAfterDrawBox = document.getElementById('stageSelectionRuleAfterDrawBox') as HTMLSelectElement;
const stageSwitch = document.getElementById('stageSwitch')!;
const stageSwitchButtons: HTMLButtonElement[] = [ ];
const gameSetupForceSameDeckAfterDrawBox = document.getElementById('gameSetupForceSameDeckAfterDrawBox') as HTMLInputElement;
const gameSetupSubmitButton = document.getElementById('gameSetupSubmitButton') as HTMLButtonElement;
const optionsColourLock = document.getElementById('optionsColourLock') as HTMLInputElement;
const optionsTurnNumberStyle = document.getElementById('optionsTurnNumberStyle') as HTMLSelectElement;
@@ -35,6 +42,37 @@ function setLoadingMessage(message: string | null) {
}
}
function preGameInitStageDatabase(stages: Stage[]) {
for (const stage of stages) {
const button = document.createElement('button');
const div1 = document.createElement('div');
div1.className = 'stageName';
div1.innerText = stage.name;
button.appendChild(div1);
const div2 = document.createElement('div');
div2.className = 'stageStatus';
div2.innerText = 'Allowed';
button.appendChild(div2);
button.type = 'button';
button.dataset.index = stageSwitchButtons.length.toString();
button.dataset.status = '0';
stageSwitchButtons.push(button);
button.addEventListener('click', stageSwitchButton_click);
stageSwitch.appendChild(button);
}
}
function stageSwitchButton_click(e: Event) {
const button = e.currentTarget as HTMLButtonElement;
let status = button.dataset.status == '0' ? 1 : button.dataset.status == '1' ? 2 : 0;
button.dataset.status = status.toString();
(<HTMLElement>button.getElementsByClassName('stageStatus')[0]).innerText = [ 'Allowed', 'Counterpick only', 'Banned' ][status];
gameSetupSubmitButton.disabled = stageSwitchButtons.every(b => b.dataset.status != '0');
}
maxPlayersBox.addEventListener('change', () => {
if (!shownMaxPlayersWarning && maxPlayersBox.value != '2') {
if (confirm('Tableturf Battle is designed for two players and may not be well-balanced for more. Do you want to continue?'))
@@ -116,6 +154,27 @@ function createRoom(useOptionsForm: boolean) {
data.append('turnTimeLimit', turnTimeLimitBox.value);
if (goalWinCountBox.value)
data.append('goalWinCount', goalWinCountBox.value);
const stageSelectionRuleFirst = {
method: StageSelectionMethod[stageSelectionRuleFirstBox.value as keyof typeof StageSelectionMethod],
bannedStages: stageSwitchButtons.map((_, i) => i).filter(i => stageSwitchButtons[i].dataset.status != '0'),
strikeCounts: [ 1, 2 ],
};
const stageSelectionRuleAfterWin = {
method: stageSelectionRuleAfterWinBox.value == 'Inherit' ? stageSelectionRuleFirst.method : StageSelectionMethod[stageSelectionRuleAfterWinBox.value as keyof typeof StageSelectionMethod],
bannedStages: stageSwitchButtons.map((_, i) => i).filter(i => stageSwitchButtons[i].dataset.status == '2'),
strikeCounts: [ 2 ],
};
const stageSelectionRuleAfterDraw = {
method: stageSelectionRuleAfterWinBox.value == 'Inherit' ? stageSelectionRuleFirst.method : StageSelectionMethod[stageSelectionRuleAfterDrawBox.value as keyof typeof StageSelectionMethod],
bannedStages: stageSwitchButtons.map((_, i) => i).filter(i => stageSwitchButtons[i].dataset.status == '2'),
strikeCounts: [ 1, 2 ],
};
data.append('stageSelectionRuleFirst', JSON.stringify(stageSelectionRuleFirst));
data.append('stageSelectionRuleAfterWin', JSON.stringify(stageSelectionRuleAfterWin));
data.append('stageSelectionRuleAfterDraw', JSON.stringify(stageSelectionRuleAfterDraw));
data.append('ForceSameDeckAfterDrawBox', gameSetupForceSameDeckAfterDrawBox.checked.toString());
}
request.send(data.toString());
setLoadingMessage('Creating a room...');

View File

@@ -4,4 +4,20 @@ interface PlayerData {
deck: Deck | null;
cardsUsed: number[];
move: Move | null;
stageSelectionPrompt: StageSelectionPrompt | null;
}
interface StageSelectionPrompt {
promptType: StageSelectionPromptType;
numberOfStagesToStrike: number;
struckStages: number[] | null;
bannedStages: number[] | null;
}
enum StageSelectionPromptType {
Vote,
VoteOrder,
Strike,
Choose,
Wait
}

View File

@@ -0,0 +1,13 @@
enum StageSelectionMethod {
Same,
Vote,
Random,
Counterpick,
Strike
}
interface StageSelectionRule {
method: StageSelectionMethod;
bannedStages: number[];
strikeCounts: number[];
}

View File

@@ -51,6 +51,7 @@ function initCardDatabase(cards: Card[]) {
deckEditInitCardDatabase(cards);
}
function initStageDatabase(stages: Stage[]) {
preGameInitStageDatabase(stages);
lobbyInitStageDatabase(stages);
deckEditInitStageDatabase(stages);
}
@@ -140,15 +141,15 @@ function onGameStateChange(game: any, playerData: PlayerData | null) {
initLobbyPage(window.location.toString());
showPage('lobby');
clearConfirmLeavingGame();
showStageSelectionForm(playerData?.stageSelectionPrompt ?? null, playerData && game.players[playerData.playerIndex]?.isReady);
lobbySelectedStageSection.hidden = true;
lobbyStageSection.hidden = !playerData || game.players[playerData.playerIndex]?.isReady;
break;
case GameState.ChoosingDeck:
showPage('lobby');
if (currentGame.me) setConfirmLeavingGame();
if (selectedStageIndicator)
lobbySelectedStageSection.removeChild(selectedStageIndicator.buttonElement);
selectedStageIndicator = new StageButton(stageDatabase.stages?.find(s => s.name == game.stage)!);
selectedStageIndicator = new StageButton(stageDatabase.stages![game.stage]);
selectedStageIndicator.buttonElement.id = 'selectedStageButton';
selectedStageIndicator.buttonElement.disabled = true;
selectedStageIndicator.setStartSpaces(game.players.length);
@@ -280,8 +281,10 @@ function setupWebSocket(gameID: string) {
playerBars[i].visible = i < currentGame.game.maxPlayers;
}
for (const button of stageButtons.buttons)
for (const button of stageButtons.buttons) {
if (!(button instanceof StageButton)) continue;
(button as StageButton).setStartSpaces(currentGame.game.maxPlayers);
}
onGameStateChange(payload.data, payload.playerData);
@@ -336,7 +339,6 @@ function setupWebSocket(gameID: string) {
lobbySetReady(payload.data.playerIndex);
if (payload.data.playerIndex == currentGame.me?.playerIndex) {
lobbyStageSection.hidden = true;
lobbyDeckSection.hidden = true;
}

View File

@@ -115,6 +115,14 @@ footer {
text-align: right;
}
#stageSwitch button { display: block; }
#stageSwitch button[data-status='0'] { color: lime; }
#stageSwitch button[data-status='1'] { color: yellow; }
#stageSwitch button[data-status='2'] { color: red; }
#stageSwitch button * { display: inline-block; }
#stageSwitch button .stageName { width: 12em; text-align: start; }
#stageSwitch button .stageStatus { width: 8em; text-align: end; }
/* Lobby page */
#lobbyPage:not([hidden]) {
@@ -286,7 +294,7 @@ dialog::backdrop {
flex-wrap: wrap;
}
.stage, .stageRandom {
.stage, .stageRandom:not([hidden]) {
font: inherit;
color: currentColor; /* Override disabled colour */
background: black;
@@ -299,6 +307,8 @@ dialog::backdrop {
flex-flow: column;
margin: 5px;
}
.stage.banned { display: none; }
.stage.struck { opacity: 0.25; }
.stageBody {
flex-grow: 1;

View File

@@ -26,13 +26,15 @@ public class PlayerData {
public Deck? Deck;
public Move? Move;
public List<int>? CardsUsed;
public StageSelectionPrompt? StageSelectionPrompt;
public PlayerData(int playerIndex, Card[]? hand, Deck? deck, Move? move, List<int>? cardsUsed) {
public PlayerData(int playerIndex, Card[]? hand, Deck? deck, Move? move, List<int>? cardsUsed, StageSelectionPrompt? stageSelectionPrompt) {
this.PlayerIndex = playerIndex;
this.Hand = hand;
this.Deck = deck;
this.Move = move;
this.CardsUsed = cardsUsed;
this.StageSelectionPrompt = stageSelectionPrompt;
}
public PlayerData(int playerIndex, Player player) : this(playerIndex, player.Hand, player.CurrentGameData.Deck, player.Move, player.CardsUsed) { }
public PlayerData(int playerIndex, Player player) : this(playerIndex, player.Hand, player.CurrentGameData.Deck, player.Move, player.CardsUsed, player.StageSelectionPrompt) { }
}

View File

@@ -1,9 +1,8 @@
using System.Net;
using Newtonsoft.Json;
namespace TableturfBattleServer;
public struct Error {
public readonly struct Error {
[JsonIgnore]
public HttpStatusCode HttpStatusCode { get; }
public string Code { get; }

View File

@@ -1,7 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Text;
using Newtonsoft.Json;
namespace TableturfBattleServer;
@@ -14,7 +13,7 @@ public class Game {
public List<Player> Players { get; } = new(4);
public int MaxPlayers { get; set; }
[JsonProperty("stage")]
public string? StageName { get; private set; }
public int? StageIndex { get; private set; }
public Space[,]? Board { get; private set; }
public Point[]? StartSpaces;
@@ -25,10 +24,17 @@ public class Game {
[JsonIgnore]
internal DateTime abandonedSince = DateTime.UtcNow;
public required StageSelectionRules StageSelectionRuleFirst { get; set; }
public required StageSelectionRules StageSelectionRuleAfterWin { get; set; }
public required StageSelectionRules StageSelectionRuleAfterDraw { get; set; }
public bool ForceSameDeckAfterDraw { get; set; }
public List<int> StruckStages = new();
[JsonIgnore]
internal List<Deck> deckCache = new();
[JsonIgnore]
internal List<string> setStages = new();
internal List<int> setStages = new();
public Game(int maxPlayers) => this.MaxPlayers = maxPlayers;
@@ -63,6 +69,13 @@ public class Game {
}
playerIndex = this.Players.Count;
this.Players.Add(player);
player.StageSelectionPrompt = this.StageSelectionRuleFirst.Method switch {
StageSelectionMethod.Vote => new() { PromptType = StageSelectionPromptType.Vote, BannedStages = this.StageSelectionRuleFirst.BannedStages, StruckStages = Array.Empty<int>() },
StageSelectionMethod.Strike => new() { PromptType = StageSelectionPromptType.VoteOrder, BannedStages = this.StageSelectionRuleFirst.BannedStages, StruckStages = Array.Empty<int>() },
_ => new() { PromptType = StageSelectionPromptType.Wait, BannedStages = this.StageSelectionRuleFirst.BannedStages, StruckStages = Array.Empty<int>() }
};
error = default;
return true;
}
@@ -82,6 +95,40 @@ public class Game {
return false;
}
public bool TryChooseStages(Player player, ICollection<int> stages, out Error error) {
if (player.StageSelectionPrompt == null || player.StageSelectionPrompt.Value.PromptType == StageSelectionPromptType.Wait) {
error = new(HttpStatusCode.Conflict, "CannotChooseStage", "You cannot choose stages now.");
return false;
}
if (player.selectedStages != null) {
error = new(HttpStatusCode.Conflict, "StageAlreadyChosen", "You've already chosen a stage.");
return false;
}
if (player.StageSelectionPrompt.Value.PromptType == StageSelectionPromptType.VoteOrder) {
if (stages.Count != 1) {
error = new(HttpStatusCode.BadRequest, "InvalidStage", "Invalid stage selection.");
return false;
}
} else {
var rule = this.GetCurrentStageSelectionRule();
if (stages.Intersect(rule.BannedStages).Any()) {
error = new(HttpStatusCode.UnprocessableEntity, "IllegalStage", "A selected stage is banned.");
return false;
}
if (player.StageSelectionPrompt.Value.StruckStages != null && stages.Intersect(player.StageSelectionPrompt.Value.StruckStages).Any()) { // Includes stages previously won on when counterpicking.
error = new(HttpStatusCode.UnprocessableEntity, "IllegalStage", "A selected stage was struck.");
return false;
}
}
if (stages.Any(i => i >= StageDatabase.Stages.Count)) {
error = new(HttpStatusCode.UnprocessableEntity, "StageNotFound", "No such stage is known.");
return false;
}
player.selectedStages = stages;
error = default;
return true;
}
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) {
@@ -143,11 +190,18 @@ public class Game {
return isAnchored;
}
private StageSelectionRules GetCurrentStageSelectionRule() {
return this.Players.Count == 0 || this.Players[0].Games.Count <= 1
? this.StageSelectionRuleFirst
: this.Players.Any(p => p.Games[^2].won) ? this.StageSelectionRuleAfterWin : this.StageSelectionRuleAfterDraw;
}
internal void Tick() {
if (this.State is GameState.WaitingForPlayers or GameState.ChoosingStage && this.Players.Count >= 2 && this.Players.All(p => p.selectedStageIndex != null)) {
if (this.State is GameState.WaitingForPlayers or GameState.ChoosingStage && this.Players.Count >= 2 && this.Players.All(p => p.StageSelectionPrompt == null || p.StageSelectionPrompt.Value.PromptType == StageSelectionPromptType.Wait || p.selectedStages != null)) {
// Choose colours.
var random = new Random();
if (this.State == GameState.WaitingForPlayers) {
this.State = GameState.ChoosingStage;
var index = random.Next(Colours.Length);
var increment = this.Players.Count switch {
2 => random.Next(3, 7),
@@ -165,29 +219,88 @@ public class Game {
}
// Choose the stage.
var stageIndex = this.Players[random.Next(this.Players.Count)].selectedStageIndex!.Value;
if (stageIndex < 0) stageIndex = random.Next(StageDatabase.Stages.Count);
var stage = StageDatabase.Stages[stageIndex];
this.StageName = stage.Name;
this.setStages.Add(stage.Name);
this.Board = (Space[,]) stage.grid.Clone();
var rule = this.GetCurrentStageSelectionRule();
switch (rule.Method) {
case StageSelectionMethod.Vote: {
var stageIndex = this.Players[random.Next(this.Players.Count)].selectedStages!.First();
if (stageIndex < 0) stageIndex = random.Next(StageDatabase.Stages.Count);
this.LockInStage(stageIndex);
this.SendEvent("stateChange", this, true);
break;
}
case StageSelectionMethod.Random: {
var legalStages = Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).ToList();
var stageIndex = legalStages[random.Next(legalStages.Count)];
this.LockInStage(stageIndex);
this.SendEvent("stateChange", this, true);
break;
}
case StageSelectionMethod.Counterpick: {
var player = this.Players.FirstOrDefault(p => p.StageSelectionPrompt != null && p.StageSelectionPrompt?.PromptType != StageSelectionPromptType.Wait);
if (player == null) {
var legalStages = Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).ToList();
this.LockInStage(legalStages[random.Next(legalStages.Count)]);
} else {
if (player.selectedStages!.First() >= 0)
this.LockInStage(player.selectedStages.First());
else {
var legalStages = Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).Except(player.StageSelectionPrompt!.Value.StruckStages).ToList();
this.LockInStage(legalStages[random.Next(legalStages.Count)]);
}
}
this.SendEvent("stateChange", this, true);
break;
}
case StageSelectionMethod.Strike: {
var player = this.Players.FirstOrDefault(p => p.StageSelectionPrompt != null && p.StageSelectionPrompt?.PromptType != StageSelectionPromptType.Wait);
switch (player.StageSelectionPrompt!.Value.PromptType) {
case StageSelectionPromptType.VoteOrder:
// Choose who will strike first.
Player? firstPlayer = null;
foreach (var player2 in this.Players) {
if (player2.selectedStages!.First() == 0) {
if (firstPlayer == null || random.Next(2) == 0)
firstPlayer = player2;
}
}
firstPlayer ??= this.Players[random.Next(this.Players.Count)];
// Present new prompts.
foreach (var player2 in this.Players) {
player2.StageSelectionPrompt = new() { PromptType = player2 == firstPlayer ? StageSelectionPromptType.Strike : StageSelectionPromptType.Wait, BannedStages = rule.BannedStages, NumberOfStagesToStrike = 1 };
player2.selectedStages = null;
}
break;
case StageSelectionPromptType.Strike:
this.StruckStages.AddRange(player.selectedStages!);
var index = this.Players.IndexOf(player);
index = (index + 1) % this.Players.Count;
// Place starting positions.
var list = stage.startSpaces.Where(s => s.Length >= this.Players.Count).MinBy(s => s.Length) ?? throw new InvalidOperationException("Couldn't find start spaces");
this.StartSpaces = list;
for (int i = 0; i < this.Players.Count; i++)
this.Board[list[i].X, list[i].Y] = Space.SpecialInactive1 | (Space) i;
this.State = GameState.ChoosingDeck;
this.SendEvent("stateChange", this, true);
// Present new prompts.
for (var i = 0; i < this.Players.Count; i++) {
this.Players[i].StageSelectionPrompt = i == index
? this.StruckStages.Count == 2 || Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).Except(this.StruckStages).Count() <= 3
? new() { PromptType = StageSelectionPromptType.Choose, BannedStages = rule.BannedStages, StruckStages = this.StruckStages }
: new() { PromptType = StageSelectionPromptType.Strike, BannedStages = rule.BannedStages, StruckStages = this.StruckStages, NumberOfStagesToStrike = 2 }
: new() { PromptType = StageSelectionPromptType.Wait, BannedStages = rule.BannedStages, StruckStages = this.StruckStages };
}
break;
case StageSelectionPromptType.Choose:
if (player.selectedStages!.First() >= 0)
this.LockInStage(player.selectedStages.First());
else {
var legalStages = Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).Except(player.StageSelectionPrompt!.Value.StruckStages).ToList();
this.LockInStage(legalStages[random.Next(legalStages.Count)]);
}
break;
}
this.SendEvent("stateChange", this, true);
foreach (var player2 in this.Players)
player2.selectedStages = null;
break;
}
}
} else if (this.State == GameState.ChoosingDeck && this.Players.All(p => p.CurrentGameData.Deck != null)) {
// Draw cards.
var random = new Random();
foreach (var player in this.Players)
player.Shuffle(random);
this.State = GameState.Redraw;
this.TurnTimeLeft = this.TurnTimeLimit;
this.StartGame();
this.SendEvent("stateChange", this, true);
} else if (this.State == GameState.Redraw && this.Players.All(p => p.Move != null)) {
var random = new Random();
@@ -368,19 +481,117 @@ public class Game {
}
}
} else if (this.State is GameState.GameEnded or GameState.SetEnded && this.Players.All(p => p.Move != null)) {
foreach (var player in this.Players) {
player.selectedStageIndex = null;
player.Hand = null;
player.CardsUsed.Clear();
player.Games.Add(new());
player.ClearMoves();
}
this.State = GameState.ChoosingStage;
this.TurnTimeLeft = this.TurnTimeLimit;
this.SendEvent("stateChange", this, true);
SetupNextGame();
}
}
private void LockInStage(int stageIndex) {
var stage = StageDatabase.Stages[stageIndex];
this.StageIndex = stageIndex;
this.setStages.Add(stageIndex);
this.Board = (Space[,]) stage.grid.Clone();
// Place starting positions.
var list = stage.startSpaces.Where(s => s.Length >= this.Players.Count).MinBy(s => s.Length) ?? throw new InvalidOperationException("Couldn't find start spaces");
this.StartSpaces = list;
for (int i = 0; i < this.Players.Count; i++)
this.Board[list[i].X, list[i].Y] = Space.SpecialInactive1 | (Space) i;
foreach (var player in this.Players) {
player.StageSelectionPrompt = null;
player.selectedStages = null;
}
if (this.ForceSameDeckAfterDraw && this.Players[0].Games.Count > 1 && !this.Players.Any(p => p.WonLastGame)) {
foreach (var player in this.Players)
player.CurrentGameData.Deck = player.Games[^2].Deck;
this.StartGame();
} else
this.State = GameState.ChoosingDeck;
}
private void StartGame() {
// Draw cards.
var random = new Random();
foreach (var player in this.Players)
player.Shuffle(random);
this.State = GameState.Redraw;
this.TurnTimeLeft = this.TurnTimeLimit;
}
private void SetupNextGame() {
this.State = GameState.ChoosingStage;
this.StruckStages.Clear();
this.TurnTimeLeft = this.TurnTimeLimit;
var winner = this.Players.FirstOrDefault(p => p.CurrentGameData.won);
foreach (var player in this.Players) {
player.selectedStages = null;
player.Hand = null;
player.CardsUsed.Clear();
player.ClearMoves();
player.Games.Add(new());
}
var rule = this.GetCurrentStageSelectionRule();
var legalStages = Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).ToList();
switch (rule.Method) {
case StageSelectionMethod.Same:
this.LockInStage(this.setStages[^1]);
break;
case StageSelectionMethod.Vote: {
if (legalStages.Count == 1) {
this.LockInStage(legalStages[0]);
break;
}
var bannedStages = legalStages.Count == 0 ? Array.Empty<int>() : rule.BannedStages;
foreach (var player in this.Players) {
player.StageSelectionPrompt = new() { PromptType = StageSelectionPromptType.Vote, BannedStages = bannedStages, StruckStages = Array.Empty<int>() };
}
break;
}
case StageSelectionMethod.Random: {
var random = new Random();
var stage = legalStages.Count switch {
0 => random.Next(StageDatabase.Stages.Count),
1 => legalStages[0],
_ => legalStages[random.Next(legalStages.Count)]
};
this.LockInStage(stage);
break;
}
case StageSelectionMethod.Counterpick: {
var playerPicking = this.Players.FirstOrDefault(p => p != winner) ?? this.Players[0]; // Should never reach the latter case.
// Prevent picking stages that the player has previously won on if that would leave any legal stages.
var struckStages = new HashSet<int>();
for (var i = 0; i < playerPicking.Games.Count; i++) {
if (playerPicking.Games[i].won && !rule.BannedStages.Contains(this.setStages[i]))
struckStages.Add(this.setStages[i]);
}
foreach (var player in this.Players)
player.StageSelectionPrompt = new() { PromptType = player == playerPicking ? StageSelectionPromptType.Choose : StageSelectionPromptType.Wait, BannedStages = rule.BannedStages, StruckStages = struckStages };
break;
}
case StageSelectionMethod.Strike: {
if (winner == null) {
foreach (var player in this.Players)
player.StageSelectionPrompt = new() { PromptType = StageSelectionPromptType.VoteOrder, BannedStages = rule.BannedStages, StruckStages = Array.Empty<int>() };
} else {
// After a win, the winner strikes first.
foreach (var player in this.Players)
player.StageSelectionPrompt = new() { PromptType = player == winner ? StageSelectionPromptType.Strike : StageSelectionPromptType.Wait, BannedStages = rule.BannedStages, StruckStages = Array.Empty<int>(), NumberOfStagesToStrike = 2 };
}
break;
}
}
this.SendEvent("stateChange", this, true);
}
internal void SendPlayerReadyEvent(int playerIndex, bool isTimeout) => this.SendEvent("playerReady", new { playerIndex, isTimeout }, false);
internal void SendEvent<T>(string eventType, T data, bool includePlayerData) {
@@ -446,7 +657,7 @@ public class Game {
// Games
for (int i = 0; i < this.Players[0].Games.Count; i++) {
var stageNumber = Enumerable.Range(0, StageDatabase.Stages.Count).First(j => this.setStages[i] == StageDatabase.Stages[j].Name);
var stageNumber = this.setStages[i];
writer.Write((byte) stageNumber);
foreach (var player in this.Players) {

View File

@@ -6,4 +6,5 @@ internal class JsonUtils {
private static readonly JsonSerializerSettings serializerSettings = new() { ContractResolver = new CamelCasePropertyNamesContractResolver() };
internal static string Serialise(object? o) => JsonConvert.SerializeObject(o, serializerSettings);
internal static T? Deserialise<T>(string json) => JsonConvert.DeserializeObject<T>(json, serializerSettings);
}

View File

@@ -10,6 +10,8 @@ public class Player {
public Colour SpecialAccentColour { get; set; }
public bool UIBaseColourIsSpecialColour { get; set; }
public StageSelectionPrompt? StageSelectionPrompt { get; set; }
[JsonIgnore]
private readonly Game game;
[JsonIgnore]
@@ -28,6 +30,8 @@ public class Player {
[JsonIgnore]
public SingleGameData CurrentGameData => this.Games[^1];
[JsonIgnore]
public bool WonLastGame => this.Games.Count > 1 && this.Games[^2].won;
public int SpecialPoints => this.CurrentGameData.SpecialPoints;
@@ -36,13 +40,15 @@ public class Player {
public int? Sleeves => this.CurrentGameData.Deck?.Sleeves;
public bool IsReady => this.game.State switch {
GameState.WaitingForPlayers or GameState.ChoosingStage => this.selectedStageIndex != null,
GameState.WaitingForPlayers or GameState.ChoosingStage => this.selectedStages != null,
GameState.ChoosingDeck => this.CurrentGameData.Deck != null,
_ => this.Move != null
};
[JsonIgnore]
internal int? selectedStageIndex;
internal ICollection<int>? selectedStages;
internal static readonly int[] RandomStageSelection = new[] { -1 };
public Player(Game game, string name, Guid token) {
this.game = game ?? throw new ArgumentNullException(nameof(game));

View File

@@ -159,8 +159,37 @@ internal class Program {
}
} else
clientToken = Guid.NewGuid();
var game = new Game(maxPlayers) { GoalWinCount = goalWinCount, TurnTimeLimit = turnTimeLimit };
game.Players.Add(new(game, name, clientToken));
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) {
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, 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, out stageSelectionRuleAfterDraw) || stageSelectionRuleAfterDraw.Method == StageSelectionMethod.Counterpick) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "stageSelectionRuleAfterDraw was invalid."));
return;
}
} else
stageSelectionRuleAfterDraw = stageSelectionRuleFirst;
if (d.TryGetValue("forceSameDeckAfterDraw", out var forceSameDeckAfterDrawString) && !bool.TryParse(forceSameDeckAfterDrawString, out var forceSameDeckAfterDraw))
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "forceSameDeckAfterDraw was invalid."));
else
forceSameDeckAfterDraw = false;
var game = new Game(maxPlayers) { GoalWinCount = goalWinCount, TurnTimeLimit = turnTimeLimit, StageSelectionRuleFirst = stageSelectionRuleFirst, StageSelectionRuleAfterWin = stageSelectionRuleAfterWin, StageSelectionRuleAfterDraw = stageSelectionRuleAfterDraw, ForceSameDeckAfterDraw = forceSameDeckAfterDraw };
game.TryAddPlayer(new(game, name, clientToken), out _, out _);
games.Add(game.ID, game);
timer.Start();
@@ -318,35 +347,28 @@ internal class Program {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "NotInGame", "You're not in the game."));
return;
}
if (player.selectedStageIndex != null) {
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "StageAlreadyChosen", "You've already chosen a stage."));
if (!d.TryGetValue("stages", out var stagesString)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidStage", "Missing stages."));
return;
}
if (!d.TryGetValue("stage", out var stageName)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidStage", "Missing stage name."));
return;
}
if (stageName == "random") {
player.selectedStageIndex = -1;
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
game.SendPlayerReadyEvent(playerIndex, false);
timer.Start();
return;
} else {
for (var i = 0; i < StageDatabase.Stages.Count; i++) {
var stage = StageDatabase.Stages[i];
if (stageName == stage.Name) {
player.selectedStageIndex = i;
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
game.SendPlayerReadyEvent(playerIndex, false);
timer.Start();
return;
}
var stages = new HashSet<int>();
foreach (var field in stagesString.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries)) {
if (!int.TryParse(field, out var i)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidStage", "Invalid stages."));
return;
}
stages.Add(i);
}
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "StageNotFound", "No such stage is known."));
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;
}
@@ -416,15 +438,6 @@ internal class Program {
cards[i] = cardNumber;
}
if (d.TryGetValue("stageIndex", out var stageIndexString) && stageIndexString is not ("" or "null" or "undefined")) {
if (int.TryParse(stageIndexString, out var stageIndex) && stageIndex >= 0 && stageIndex < StageDatabase.Stages.Count)
player.selectedStageIndex = stageIndex;
else {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidStage", "Invalid stage index."));
return;
}
}
player.CurrentGameData.Deck = game.GetDeck(deckName, deckSleeves, cards, upgrades ?? Enumerable.Repeat(0, 15));
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
game.SendPlayerReadyEvent(playerIndex, false);
@@ -664,4 +677,14 @@ internal class Program {
}
return false;
}
private static bool TryParseStageSelectionRule(string json, [MaybeNullWhen(false)] out StageSelectionRules stageSelectionRule) {
try {
stageSelectionRule = JsonUtils.Deserialise<StageSelectionRules>(json);
return stageSelectionRule != null;
} catch (JsonSerializationException) {
stageSelectionRule = null;
return false;
}
}
}

View File

@@ -0,0 +1,20 @@
namespace TableturfBattleServer;
public struct StageSelectionPrompt {
public StageSelectionPromptType PromptType;
public int NumberOfStagesToStrike;
public ICollection<int> StruckStages;
public ICollection<int> BannedStages;
}
public enum StageSelectionPromptType {
/// <summary>The player is prompted to vote for a stage.</summary>
Vote,
/// <summary>The player is prompted to vote for whether to strike first or second.</summary>
VoteOrder,
/// <summary>The player is prompted to choose stages to strike.</summary>
Strike,
/// <summary>The player is prompted to choose the stage.</summary>
Choose,
/// <summary>It is another player's turn to make a choice.</summary>
Wait
}

View File

@@ -0,0 +1,29 @@
namespace TableturfBattleServer;
public class StageSelectionRules {
public StageSelectionMethod Method { get; set; }
public int[] BannedStages { get; set; }
public StageSelectionRules(StageSelectionMethod method, int[]? bannedStages) {
this.Method = method;
this.BannedStages = bannedStages ?? Array.Empty<int>();
}
public static StageSelectionRules Default { get; } = new(StageSelectionMethod.Vote, Array.Empty<int>());
}
public enum StageSelectionMethod {
/// <summary>The battle will be on the same stage as the last battle. This cannot be used for the first battle.</summary>
Same,
/// <summary>Each player votes for a stage, or random. One of the votes, chosen randomly, decides the stage.</summary>
Vote,
/// <summary>The stage is chosen randomly. If only one stage is allowed, all battles will be on that stage.</summary>
Random,
/// <summary>The loser of the last battle chooses the stage. This cannot be used for the first battle or after a draw.</summary>
Counterpick,
/// <summary>
/// Players take turns to ban stages for the next match, until the final player chooses the stage from among the remaining ones.
/// For the first battle or after a draw, players vote on who shall strike first. For subsequent battles, the winner of the last battle strikes first.
/// </summary>
Strike
}

View File

@@ -2,12 +2,12 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackageProjectUrl>https://github.com/AndrioCelos/TableturfBattleApp</PackageProjectUrl>
<RepositoryUrl>https://github.com/AndrioCelos/TableturfBattleApp</RepositoryUrl>
<Version>0.0.0.0</Version>
<Version>0.0.0.0</Version>
</PropertyGroup>
<ItemGroup>