Choose your deck.
@@ -602,6 +609,49 @@
+
Stage selection:
+
+
Stage switch:
+
diff --git a/TableturfBattleClient/src/CheckButtonGroup.ts b/TableturfBattleClient/src/CheckButtonGroup.ts
index 6a6e181..15b6efa 100644
--- a/TableturfBattleClient/src/CheckButtonGroup.ts
+++ b/TableturfBattleClient/src/CheckButtonGroup.ts
@@ -3,6 +3,7 @@ class CheckButtonGroup {
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 {
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;
diff --git a/TableturfBattleClient/src/Pages/GamePage.ts b/TableturfBattleClient/src/Pages/GamePage.ts
index bf14efc..79acfd1 100644
--- a/TableturfBattleClient/src/Pages/GamePage.ts
+++ b/TableturfBattleClient/src/Pages/GamePage.ts
@@ -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;
diff --git a/TableturfBattleClient/src/Pages/LobbyPage.ts b/TableturfBattleClient/src/Pages/LobbyPage.ts
index b45bf37..996f2ca 100644
--- a/TableturfBattleClient/src/Pages/LobbyPage.ts
+++ b/TableturfBattleClient/src/Pages/LobbyPage.ts
@@ -1,17 +1,18 @@
const lobbyWinCounters: WinCounter[] = [ ];
-const stageButtons = new CheckButtonGroup(document.getElementById('stageList')!);
+const stageButtons = new CheckButtonGroup(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'))
+ ( 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'))
+ ( 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'))
+ ( 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());
+});
diff --git a/TableturfBattleClient/src/Pages/PreGamePage.ts b/TableturfBattleClient/src/Pages/PreGamePage.ts
index 470039e..59b34e1 100644
--- a/TableturfBattleClient/src/Pages/PreGamePage.ts
+++ b/TableturfBattleClient/src/Pages/PreGamePage.ts
@@ -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();
+ (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...');
diff --git a/TableturfBattleClient/src/PlayerData.ts b/TableturfBattleClient/src/PlayerData.ts
index d7c535e..1372ac9 100644
--- a/TableturfBattleClient/src/PlayerData.ts
+++ b/TableturfBattleClient/src/PlayerData.ts
@@ -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
}
diff --git a/TableturfBattleClient/src/StageSelectionRule.ts b/TableturfBattleClient/src/StageSelectionRule.ts
new file mode 100644
index 0000000..8cffe1f
--- /dev/null
+++ b/TableturfBattleClient/src/StageSelectionRule.ts
@@ -0,0 +1,13 @@
+enum StageSelectionMethod {
+ Same,
+ Vote,
+ Random,
+ Counterpick,
+ Strike
+}
+
+interface StageSelectionRule {
+ method: StageSelectionMethod;
+ bannedStages: number[];
+ strikeCounts: number[];
+}
diff --git a/TableturfBattleClient/src/app.ts b/TableturfBattleClient/src/app.ts
index c5fa082..a4fff0c 100644
--- a/TableturfBattleClient/src/app.ts
+++ b/TableturfBattleClient/src/app.ts
@@ -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;
}
diff --git a/TableturfBattleClient/tableturf.css b/TableturfBattleClient/tableturf.css
index 5fdda86..c1cb0c8 100644
--- a/TableturfBattleClient/tableturf.css
+++ b/TableturfBattleClient/tableturf.css
@@ -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;
diff --git a/TableturfBattleServer/DataStructures.cs b/TableturfBattleServer/DataStructures.cs
index 3debee5..a78a0b4 100644
--- a/TableturfBattleServer/DataStructures.cs
+++ b/TableturfBattleServer/DataStructures.cs
@@ -26,13 +26,15 @@ public class PlayerData {
public Deck? Deck;
public Move? Move;
public List? CardsUsed;
+ public StageSelectionPrompt? StageSelectionPrompt;
- public PlayerData(int playerIndex, Card[]? hand, Deck? deck, Move? move, List? cardsUsed) {
+ public PlayerData(int playerIndex, Card[]? hand, Deck? deck, Move? move, List? 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) { }
}
diff --git a/TableturfBattleServer/Error.cs b/TableturfBattleServer/Error.cs
index ff3527e..e84284b 100644
--- a/TableturfBattleServer/Error.cs
+++ b/TableturfBattleServer/Error.cs
@@ -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; }
diff --git a/TableturfBattleServer/Game.cs b/TableturfBattleServer/Game.cs
index f968c23..1b0be28 100644
--- a/TableturfBattleServer/Game.cs
+++ b/TableturfBattleServer/Game.cs
@@ -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 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 StruckStages = new();
+
[JsonIgnore]
internal List deckCache = new();
[JsonIgnore]
- internal List setStages = new();
+ internal List 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() },
+ StageSelectionMethod.Strike => new() { PromptType = StageSelectionPromptType.VoteOrder, BannedStages = this.StageSelectionRuleFirst.BannedStages, StruckStages = Array.Empty() },
+ _ => new() { PromptType = StageSelectionPromptType.Wait, BannedStages = this.StageSelectionRuleFirst.BannedStages, StruckStages = Array.Empty() }
+ };
+
error = default;
return true;
}
@@ -82,6 +95,40 @@ public class Game {
return false;
}
+ public bool TryChooseStages(Player player, ICollection 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 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) {
@@ -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() : rule.BannedStages;
+ foreach (var player in this.Players) {
+ player.StageSelectionPrompt = new() { PromptType = StageSelectionPromptType.Vote, BannedStages = bannedStages, StruckStages = Array.Empty() };
+ }
+ 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();
+ 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() };
+ } 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(), 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(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) {
diff --git a/TableturfBattleServer/JsonUtils.cs b/TableturfBattleServer/JsonUtils.cs
index 0df3df9..6acd3ad 100644
--- a/TableturfBattleServer/JsonUtils.cs
+++ b/TableturfBattleServer/JsonUtils.cs
@@ -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(string json) => JsonConvert.DeserializeObject(json, serializerSettings);
}
diff --git a/TableturfBattleServer/Player.cs b/TableturfBattleServer/Player.cs
index 32340e8..c8166cd 100644
--- a/TableturfBattleServer/Player.cs
+++ b/TableturfBattleServer/Player.cs
@@ -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? selectedStages;
+
+ internal static readonly int[] RandomStageSelection = new[] { -1 };
public Player(Game game, string name, Guid token) {
this.game = game ?? throw new ArgumentNullException(nameof(game));
diff --git a/TableturfBattleServer/Program.cs b/TableturfBattleServer/Program.cs
index 28dfc67..0415d4b 100644
--- a/TableturfBattleServer/Program.cs
+++ b/TableturfBattleServer/Program.cs
@@ -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();
+ 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(json);
+ return stageSelectionRule != null;
+ } catch (JsonSerializationException) {
+ stageSelectionRule = null;
+ return false;
+ }
+ }
}
diff --git a/TableturfBattleServer/StageSelectionPrompt.cs b/TableturfBattleServer/StageSelectionPrompt.cs
new file mode 100644
index 0000000..3d44fd6
--- /dev/null
+++ b/TableturfBattleServer/StageSelectionPrompt.cs
@@ -0,0 +1,20 @@
+namespace TableturfBattleServer;
+public struct StageSelectionPrompt {
+ public StageSelectionPromptType PromptType;
+ public int NumberOfStagesToStrike;
+ public ICollection StruckStages;
+ public ICollection BannedStages;
+}
+
+public enum StageSelectionPromptType {
+ /// The player is prompted to vote for a stage.
+ Vote,
+ /// The player is prompted to vote for whether to strike first or second.
+ VoteOrder,
+ /// The player is prompted to choose stages to strike.
+ Strike,
+ /// The player is prompted to choose the stage.
+ Choose,
+ /// It is another player's turn to make a choice.
+ Wait
+}
diff --git a/TableturfBattleServer/StageSelectionRules.cs b/TableturfBattleServer/StageSelectionRules.cs
new file mode 100644
index 0000000..fb8cc3b
--- /dev/null
+++ b/TableturfBattleServer/StageSelectionRules.cs
@@ -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();
+ }
+
+ public static StageSelectionRules Default { get; } = new(StageSelectionMethod.Vote, Array.Empty());
+}
+
+public enum StageSelectionMethod {
+ /// The battle will be on the same stage as the last battle. This cannot be used for the first battle.
+ Same,
+ /// Each player votes for a stage, or random. One of the votes, chosen randomly, decides the stage.
+ Vote,
+ /// The stage is chosen randomly. If only one stage is allowed, all battles will be on that stage.
+ Random,
+ /// The loser of the last battle chooses the stage. This cannot be used for the first battle or after a draw.
+ Counterpick,
+ ///
+ /// 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.
+ ///
+ Strike
+}
diff --git a/TableturfBattleServer/TableturfBattleServer.csproj b/TableturfBattleServer/TableturfBattleServer.csproj
index 06d9b76..9e55a68 100644
--- a/TableturfBattleServer/TableturfBattleServer.csproj
+++ b/TableturfBattleServer/TableturfBattleServer.csproj
@@ -2,12 +2,12 @@
Exe
- net6.0
+ net7.0
enable
enable
https://github.com/AndrioCelos/TableturfBattleApp
https://github.com/AndrioCelos/TableturfBattleApp
- 0.0.0.0
+ 0.0.0.0