#5 Basic replay implementation

This commit is contained in:
Andrio Celos 2022-12-25 12:49:30 +11:00
parent 4113b150c4
commit bb75c238a8
15 changed files with 516 additions and 24 deletions

14
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,14 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "typescript",
"tsconfig": "TableturfBattleClient/tsconfig.json",
"problemMatcher": [
"$tsc"
],
"group": "build",
"label": "tsc: build - TableturfBattleClient/tsconfig.json"
}
]
}

View File

@ -4,7 +4,8 @@
<meta charset="UTF-8"/>
<base href="https://tableturf.andriocelos.net/tableturf/"/>
<script>
document.getElementsByTagName('base')[0].href = window.location.toString().replace(/\/(?:index.html|deckeditor|game\/[^/]*)$/, '/');
baseUrl = window.location.toString().replace(/\/(?:index.html|deckeditor|game\/[^/]*|replay\/[^/]*)$/, '/');
document.getElementsByTagName('base')[0].href = baseUrl;
</script>
<title>Tableturf Battle</title>
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png">
@ -49,7 +50,8 @@
<a id="preGameBackButton" href="../..">Create or join a different room</a>
</div>
<p>
<a id="preGameDeckEditorButton" href="deckeditor">Edit decks</a>
<a id="preGameDeckEditorButton" href="deckeditor">Edit decks</a> |
<a id="preGameReplayButton" href="replay">Replay</a>
</p>
<div id="preGameLoadingSection" class="loadingContainer" hidden>
<div class="lds-ripple"><div></div><div></div></div> <span id="preGameLoadingLabel"></span>
@ -148,8 +150,14 @@
</label>
</div>
<div id="resultContainer" hidden>
<button id="shareReplayLinkButton">Copy replay link</button>
<br/>
<a id="resultLeaveButton" href="#">Leave game</a>
</div>
<div id="replayControls" hidden>
<button id="replayNextButton">Next turn</button>
<a id="replayLeaveButton" href="#">Exit replay</a>
</div>
<div class="playerBar" data-index="0">
<div class="result"></div>

View File

@ -0,0 +1,90 @@
// Adapted from https://github.com/mdn/content/blob/main/files/en-us/glossary/base64/index.md
class Base64 {
// Array of bytes to Base64 string decoding
private static b64ToUint6(nChr: number) {
return nChr > 64 && nChr < 91
? nChr - 65
: nChr > 96 && nChr < 123
? nChr - 71
: nChr > 47 && nChr < 58
? nChr + 4
: nChr === 43
? 62
: nChr === 47
? 63
: 0;
}
static base64DecToArr(sBase64: string, nBlocksSize?: number) {
const sB64Enc = sBase64.replace(/[^A-Za-z0-9+/]/g, "");
const nInLen = sB64Enc.length;
const nOutLen = nBlocksSize
? Math.ceil(((nInLen * 3 + 1) >> 2) / nBlocksSize) * nBlocksSize
: (nInLen * 3 + 1) >> 2;
const taBytes = new Uint8Array(nOutLen);
let nMod3;
let nMod4;
let nUint24 = 0;
let nOutIdx = 0;
for (let nInIdx = 0; nInIdx < nInLen; nInIdx++) {
nMod4 = nInIdx & 3;
nUint24 |= Base64.b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << (6 * (3 - nMod4));
if (nMod4 === 3 || nInLen - nInIdx === 1) {
nMod3 = 0;
while (nMod3 < 3 && nOutIdx < nOutLen) {
taBytes[nOutIdx] = (nUint24 >>> ((16 >>> nMod3) & 24)) & 255;
nMod3++;
nOutIdx++;
}
nUint24 = 0;
}
}
return taBytes;
}
/* Base64 string to array encoding */
private static uint6ToB64(nUint6: number) {
return nUint6 < 26
? nUint6 + 65
: nUint6 < 52
? nUint6 + 71
: nUint6 < 62
? nUint6 - 4
: nUint6 === 62
? 43
: nUint6 === 63
? 47
: 65;
}
static base64EncArr(aBytes: Uint8Array) {
let nMod3 = 2;
let sB64Enc = "";
const nLen = aBytes.length;
let nUint24 = 0;
for (let nIdx = 0; nIdx < nLen; nIdx++) {
nMod3 = nIdx % 3;
if (nIdx > 0 && ((nIdx * 4) / 3) % 76 === 0) {
sB64Enc += "\r\n";
}
nUint24 |= aBytes[nIdx] << ((16 >>> nMod3) & 24);
if (nMod3 === 2 || aBytes.length - nIdx === 1) {
sB64Enc += String.fromCodePoint(
Base64.uint6ToB64((nUint24 >>> 18) & 63),
Base64.uint6ToB64((nUint24 >>> 12) & 63),
Base64.uint6ToB64((nUint24 >>> 6) & 63),
Base64.uint6ToB64(nUint24 & 63)
);
nUint24 = 0;
}
}
return (
sB64Enc.substring(0, sB64Enc.length - 2 + nMod3) +
(nMod3 === 2 ? "" : nMod3 === 1 ? "=" : "==")
);
}
}

View File

@ -274,6 +274,10 @@ class Board {
}
}
setDisplayedSpace(x: number, y: number, newState: Space) {
this.cells[x][y].setAttribute('class', Space[newState]);
}
getScore(playerIndex: number) {
let count = 0;
for (let x = 0; x < this.grid.length; x++) {
@ -300,4 +304,94 @@ class Board {
}
return false;
}
/**
* Calculates and makes moves resulting from players playing the specified cards, for replays and test placements.
*/
makePlacements(moves: Move[]) {
var playerIndices = Array.from({ length: moves.length }, (_, i) => i);
playerIndices.sort((a, b) => moves[b].card.size - moves[a].card.size);
const placements = [ ];
let placementData: { placement: Placement, cardSize: number } | null = null;
for (const i of playerIndices) {
if (!moves[i] || moves[i].isPass) continue;
const move = moves[i] as PlayMove;
if (placementData == null) {
placementData = { placement: { players: [ ], spacesAffected: [ ] }, cardSize: move.card.size };
} else if (move.card.size != placementData.cardSize) {
placements.push(placementData.placement);
placementData = { placement: { players: [ ], spacesAffected: [ ] }, cardSize: move.card.size };
}
const placement = placementData.placement;
placement.players.push(i);
for (let dy = 0; dy < 8; dy++) {
const y = move.y + dy;
for (let dx = 0; dx < 8; dx++) {
const x = move.x + dx;
const point = { x, y }
switch (move.card.getSpace(dx, dy, move.rotation)) {
case Space.Ink1: {
const e = placement.spacesAffected.find(s => s.space.x == point.x && s.space.y == point.y)
if (e) {
if (e.newState < Space.SpecialInactive1) {
// Two ink spaces overlapped; create a wall there.
e.newState = this.grid[x][y] = Space.Wall;
}
} else {
if (this.grid[x][y] < Space.SpecialInactive1) // Ink spaces can't overlap special spaces from larger cards.
placement.spacesAffected.push({ space: point, newState: this.grid[x][y] = (Space.Ink1 | i) });
}
break;
}
case Space.SpecialInactive1: {
const e = placement.spacesAffected.find(s => s.space.x == point.x && s.space.y == point.y)
if (e) {
if (e.newState >= Space.SpecialInactive1)
// Two special spaces overlapped; create a wall there.
e.newState = this.grid[x][y] = Space.Wall;
else
// If a special space overlaps an ink space, overwrite it.
e.newState = this.grid[x][y] = (Space.SpecialInactive1 | i);
} else
placement.spacesAffected.push({ space: point, newState: this.grid[x][y] = (Space.SpecialInactive1 | i) });
break;
}
}
}
}
}
if (placementData != null)
placements.push(placementData.placement);
// Activate special spaces.
const specialSpacesActivated: Point[] = [ ];
for (let x = 0; x < this.grid.length; x++) {
for (let y = 0; y < this.grid[x].length; y++) {
const space = this.grid[x][y];
if ((space & Space.SpecialActive1) == Space.SpecialInactive1) {
let anyEmptySpace = false;
for (let dy = -1; !anyEmptySpace && dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const x2 = x + dx;
const y2 = y + dy;
if (x2 >= 0 && x2 < this.grid.length && y2 >= 0 && y2 < this.grid[x2].length
&& this.grid[x2][y2] == Space.Empty) {
anyEmptySpace = true;
break;
}
}
}
if (!anyEmptySpace) {
this.grid[x][y] |= Space.SpecialActive1;
specialSpacesActivated.push({ x, y });
}
}
}
}
return { placements, specialSpacesActivated };
}
}

View File

@ -9,11 +9,17 @@ let currentGame: {
maxPlayers: number,
/** The user's player data, or null if they are spectating. */
me: PlayerData | null,
turnNumber: number,
/** The WebSocket used for receiving game events, or null if not yet connected. */
webSocket: WebSocket
} | null;
webSocket: WebSocket | null
} | null = null;
let enterGameTimeout: number | null = null;
let currentReplay: {
turns: Move[][],
initialDrawOrder: number[][],
drawOrder: number[][]
} | null = null;
const playerList = document.getElementById('playerList')!;
const playerListItems: HTMLElement[] = [ ];

View File

@ -4,7 +4,7 @@ interface Move {
}
interface PlayMove extends Move {
isPass: true;
isPass: false;
x: number;
y: number;
rotation: number;

View File

@ -18,6 +18,11 @@ const redrawModal = document.getElementById('redrawModal')!;
const playControls = document.getElementById('playControls')!;
const resultContainer = document.getElementById('resultContainer')!;
const resultElement = document.getElementById('result')!;
const replayControls = document.getElementById('replayControls')!;
const replayNextButton = document.getElementById('replayNextButton')!;
const shareReplayLinkButton = document.getElementById('shareReplayLinkButton') as HTMLButtonElement;
let canShareReplay = false;
const playerBars = Array.from(document.getElementsByClassName('playerBar'), el => new PlayerBar(el as HTMLDivElement));
playerBars.sort((a, b) => a.playerIndex - b.playerIndex);
@ -46,6 +51,68 @@ cols[4][21] = Space.SpecialInactive1;
cols[4][4] = Space.SpecialInactive2;
board.resize(cols);
function initReplay() {
playControls.hidden = true;
resultContainer.hidden = true;
replayControls.hidden = false;
gameButtonsContainer.hidden = true;
canPlay = false;
showPage('game');
turnNumberLabel.setTurnNumber(1);
}
replayNextButton.addEventListener('click', _ => {
if (currentGame == null || currentReplay == null) return;
const moves = currentReplay.turns[currentGame.turnNumber - 1];
const result = board.makePlacements(moves);
let anySpecialAttacks = false;
// Show the cards that were played.
clearPlayContainers();
for (let i = 0; i < currentGame.players.length; i++) {
const player = currentGame.players[i];
const move = moves[i];
const button = new CardButton('checkbox', move.card);
if ((move as PlayMove).isSpecialAttack) {
anySpecialAttacks = true;
button.element.classList.add('specialAttack');
} else if (move.isPass) {
player.passes++;
player.specialPoints++;
player.totalSpecialPoints++;
const el = document.createElement('div');
el.className = 'passLabel';
el.innerText = 'Pass';
button.element.appendChild(el);
}
button.inputElement.hidden = true;
playContainers[i].append(button.element);
}
for (const p of result.specialSpacesActivated) {
const space = board.grid[p.x][p.y];
const player2 = currentGame.players[space & 3];
player2.specialPoints++;
player2.totalSpecialPoints++;
}
currentGame.turnNumber++;
(async () => {
await playInkAnimations({ game: { state: GameState.Ongoing, board: null, turnNumber: currentGame.turnNumber, players: currentGame.players }, placements: result.placements, specialSpacesActivated: result.specialSpacesActivated }, anySpecialAttacks);
turnNumberLabel.setTurnNumber(currentGame.turnNumber);
clearPlayContainers();
if (currentGame.turnNumber > 12) {
gameButtonsContainer.hidden = true;
passButton.enabled = false;
specialButton.enabled = false;
gamePage.classList.add('gameEnded');
showResult();
}
})();
});
function loadPlayers(players: Player[]) {
gamePage.dataset.players = players.length.toString();
for (let i = 0; i < players.length; i++) {
@ -59,6 +126,9 @@ function loadPlayers(players: Player[]) {
document.body.style.setProperty(`--special-accent-colour-${i + 1}`, `rgb(${player.specialAccentColour.r}, ${player.specialAccentColour.g}, ${player.specialAccentColour.b})`);
}
}
for (let i = 0; i < playerBars.length; i++) {
playerBars[i].visible = i < players.length;
}
}
function updateStats(playerIndex: number) {
@ -112,11 +182,9 @@ function setupControlsForPlay() {
async function playInkAnimations(data: {
game: { state: GameState, board: Space[][] | null, turnNumber: number, players: Player[] },
placements: { cards: { playerIndex: number, card: Card }[], spacesAffected: { space: { x: number, y: number }, newState: Space }[] }[],
specialSpacesActivated: { x: number, y: number }[]
placements: Placement[],
specialSpacesActivated: Point[]
}, anySpecialAttacks: boolean) {
if (!data.game.board) throw new Error("Board is null during game");
const inkPlaced = new Set<number>();
const placements = data.placements;
board.clearHighlight();
@ -133,14 +201,14 @@ async function playInkAnimations(data: {
for (const p of placement.spacesAffected) {
inkPlaced.add(p.space.y * 37 + p.space.x);
board.grid[p.space.x][p.space.y] = p.newState;
board.setDisplayedSpace(p.space.x, p.space.y, p.newState);
}
board.refresh();
}
await delay(1000);
// Show special spaces.
board.grid = data.game.board;
if (data.game.board)
board.grid = data.game.board;
board.refresh();
if (data.specialSpacesActivated.length > 0)
await delay(1000); // Delay if we expect that this changed the board.
@ -157,8 +225,6 @@ async function playInkAnimations(data: {
function showResult() {
if (currentGame == null) return;
playControls.hidden = true;
resultContainer.hidden = false;
turnNumberLabel.setTurnNumber(null);
let winners = [ 0 ]; let maxPoints = playerBars[0].points;
@ -192,6 +258,13 @@ function showResult() {
el.innerText = 'Defeat';
}
}
if (!currentReplay) {
playControls.hidden = true;
resultContainer.hidden = false;
canShareReplay = navigator.canShare && navigator.canShare({ url: window.location.href, title: 'Tableturf Battle Replay' });
shareReplayLinkButton.innerText = canShareReplay ? 'Share replay link' : 'Copy replay link';
}
}
function clearShowDeck() {
@ -357,14 +430,8 @@ board.onclick = (x, y) => {
return;
}
if (testMode) {
for (let dy = 0; dy < 8; dy++) {
for (let dx = 0; dx < 8; dx++) {
let space = board.cardPlaying.getSpace(dx, dy, board.cardRotation);
if (space != Space.Empty) {
board.grid[x + dx][y + dy] = space;
}
}
}
const move: PlayMove = { card: board.cardPlaying, isPass: false, x, y, rotation: board.cardRotation, isSpecialAttack: false };
const r = board.makePlacements([ move ]);
board.refresh();
} else if (canPlay) {
canPlay = false;
@ -468,9 +535,41 @@ document.addEventListener('keydown', e => {
}
});
shareReplayLinkButton.addEventListener('click', _ => {
shareReplayLinkButton.disabled = true;
let req = new XMLHttpRequest();
req.responseType = "arraybuffer";
req.open('GET', `${config.apiBaseUrl}/games/${currentGame!.id}/replay`);
req.addEventListener('load', _ => {
if (req.status == 200) {
const array = new Uint8Array(req.response as ArrayBuffer);
let base64 = Base64.base64EncArr(array);
base64 = base64.replaceAll('+', '-');
base64 = base64.replaceAll('/', '_');
const url = new URL(`${canPushState ? '' : '#'}replay/${base64}`, baseUrl);
shareReplayLinkButton.disabled = false;
if (canShareReplay) {
navigator.share({ url: url.href, title: 'Tableturf Battle Replay' });
} else {
navigator.clipboard.writeText(window.location.toString()).then(() => shareLinkButton.innerText = 'Copied');
}
}
});
req.send();
});
document.getElementById('resultLeaveButton')!.addEventListener('click', e => {
e.preventDefault();
clearPreGameForm(true);
showPage('preGame');
newGameButton.focus();
});
document.getElementById('replayLeaveButton')!.addEventListener('click', e => {
e.preventDefault();
clearPreGameForm(true);
showPage('preGame');
newGameButton.focus();
currentReplay = null;
});

View File

@ -7,6 +7,7 @@ const maxPlayersBox = document.getElementById('maxPlayersBox') as HTMLSelectElem
const preGameDeckEditorButton = document.getElementById('preGameDeckEditorButton') as HTMLLinkElement;
const preGameLoadingSection = document.getElementById('preGameLoadingSection')!;
const preGameLoadingLabel = document.getElementById('preGameLoadingLabel')!;
const preGameReplayButton = document.getElementById('preGameReplayButton') as HTMLLinkElement;
let shownMaxPlayersWarning = false;
@ -159,6 +160,103 @@ preGameDeckEditorButton.addEventListener('click', e => {
setUrl('deckeditor');
});
preGameReplayButton.addEventListener('click', e => {
e.preventDefault();
if (stageDatabase.stages == null)
throw new Error('Game data not loaded');
const s = prompt('Enter a replay link or code.');
if (!s) return;
const m = /(?:^|replay\/)([A-Za-z0-9+/=\-_]+)$/i.exec(s);
if (!m) {
alert('Not a valid replay code');
return;
}
let base64 = m[1];
base64 = base64.replaceAll('-', '+');
base64 = base64.replaceAll('_', '/');
const bytes = Base64.base64DecToArr(base64);
const dataView = new DataView(bytes.buffer);
if (dataView.getUint8(0) != 1) {
alert('Unknown replay data version');
return;
}
const n = dataView.getUint8(1);
const stage = stageDatabase.stages[n & 0x1F];
const numPlayers = n >> 5;
let pos = 2;
const players = [ ];
currentReplay = { turns: [ ], drawOrder: [ ], initialDrawOrder: [ ] };
for (let i = 0; i < numPlayers; i++) {
const len = dataView.getUint8(pos + 34);
const player: Player = {
name: new TextDecoder().decode(new DataView(bytes.buffer, pos + 35, len)),
specialPoints: 0,
isReady: false,
colour: { r: dataView.getUint8(pos + 0), g: dataView.getUint8(pos + 1), b: dataView.getUint8(pos + 2) },
specialColour: { r: dataView.getUint8(pos + 3), g: dataView.getUint8(pos + 4), b: dataView.getUint8(pos + 5) },
specialAccentColour: { r: dataView.getUint8(pos + 6), g: dataView.getUint8(pos + 7), b: dataView.getUint8(pos + 8) },
totalSpecialPoints: 0,
passes: 0
};
players.push(player);
const initialDrawOrder = [ ];
const drawOrder = [ ];
for (let j = 0; j < 2; j++) {
initialDrawOrder.push(dataView.getUint8(pos + 24 + j) & 0xF);
initialDrawOrder.push(dataView.getUint8(pos + 24 + j) >> 4 & 0xF);
}
for (let j = 0; j < 8; j++) {
drawOrder.push(dataView.getUint8(pos + 26 + j) & 0xF);
drawOrder.push(dataView.getUint8(pos + 26 + j) >> 4 & 0xF);
}
currentReplay.initialDrawOrder.push(initialDrawOrder);
currentReplay.drawOrder.push(drawOrder);
pos += 35 + len;
}
for (let i = 0; i < 12; i++) {
const turn = [ ]
for (let j = 0; j < numPlayers; j++) {
const cardNumber = dataView.getUint8(pos);
const b = dataView.getUint8(pos + 1);
const x = dataView.getInt8(pos + 2);
const y = dataView.getInt8(pos + 3);
if (b & 0x80)
turn.push({ card: cardDatabase.get(cardNumber), isPass: true });
else {
const move: PlayMove = { card: cardDatabase.get(cardNumber), isPass: false, x, y, rotation: b & 0x03, isSpecialAttack: (b & 0x40) != 0 };
turn.push(move);
}
pos += 4;
}
currentReplay.turns.push(turn);
}
currentGame = {
id: 'replay',
me: null,
players: players,
maxPlayers: numPlayers,
turnNumber: 1,
webSocket: null
};
board.resize(stage.grid);
const startSpaces = stage.getStartSpaces(numPlayers);
for (let i = 0; i < numPlayers; i++)
board.grid[startSpaces[i].x][startSpaces[i].y] = Space.SpecialInactive1 | i;
board.refresh();
loadPlayers(players);
initReplay();
});
let playerName = localStorage.getItem('name');
(document.getElementById('nameBox') as HTMLInputElement).value = playerName || '';

View File

@ -0,0 +1,4 @@
interface Placement {
players: number[],
spacesAffected: { space: Point, newState: Space }[]
}

View File

@ -1,5 +1,7 @@
/// <reference path="Pages/PreGamePage.ts"/>
declare var baseUrl: string;
const errorDialog = document.getElementById('errorDialog') as HTMLDialogElement;
const errorMessage = document.getElementById('errorMessage')!;
const errorDialogForm = document.getElementById('errorDialogForm') as HTMLFormElement;
@ -190,6 +192,7 @@ function setupWebSocket(gameID: string, myPlayerIndex: number | null) {
me: payload.playerData,
players: payload.data.players,
maxPlayers: payload.data.maxPlayers,
turnNumber: payload.data.turnNumber,
webSocket: webSocket
};

View File

@ -468,7 +468,7 @@ dialog::backdrop {
align-items: center;
}
#playControls, #resultContainer {
#playControls, #resultContainer, #replayControls {
grid-column: 1 / span 2;
grid-row: hand-row;
align-self: center;
@ -1054,7 +1054,7 @@ dialog::backdrop {
grid-template-rows: [button-row] auto [hand-row] auto;
}
#playControls, #resultContainer {
#playControls, #resultContainer, #replayControls {
grid-column: 1 / -1;
grid-row: 5;
width: initial;

View File

@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Text;
using Newtonsoft.Json;
@ -180,6 +181,11 @@ public class Game {
if (this.Board == null) throw new InvalidOperationException("No board?!");
foreach (var player in this.Players) {
var move = player.Move!;
player.turns.Add(new() { CardNumber = move.Card.Number, X = move.X, Y = move.Y, Rotation = move.Rotation, IsPass = move.IsPass, IsSpecialAttack = move.IsSpecialAttack });
}
// Place the ink.
(Placement placement, int cardSize)? placementData = null;
foreach (var i in Enumerable.Range(0, this.Players.Count).Where(i => this.Players[i] != null).OrderByDescending(i => this.Players[i]!.Move!.Card.Size)) {
@ -306,4 +312,44 @@ public class Game {
}
}
}
public void WriteReplayData(Stream stream) {
const int VERSION = 1;
if (this.State < GameState.Ended)
throw new InvalidOperationException("Can't save a replay until the game has ended.");
using var writer = new BinaryWriter(stream, Encoding.UTF8, true);
writer.Write((byte) VERSION);
var stageNumber = Enumerable.Range(0, StageDatabase.Stages.Count).First(i => this.StageName == StageDatabase.Stages[i].Name);
writer.Write((byte) (stageNumber | (this.Players.Count << 5)));
foreach (var player in this.Players) {
writer.Write((byte) player.Colour.R);
writer.Write((byte) player.Colour.G);
writer.Write((byte) player.Colour.B);
writer.Write((byte) player.SpecialColour.R);
writer.Write((byte) player.SpecialColour.G);
writer.Write((byte) player.SpecialColour.B);
writer.Write((byte) player.SpecialAccentColour.R);
writer.Write((byte) player.SpecialAccentColour.G);
writer.Write((byte) player.SpecialAccentColour.B);
foreach (var card in player.Deck!)
writer.Write((byte) card.Number);
for (int i = 0; i < 4; i += 2)
writer.Write((byte) (player.initialDrawOrder![i] | player.initialDrawOrder[i + 1]));
for (int i = 0; i < 15; i += 2)
writer.Write((byte) (player.drawOrder![i] | (i < 14 ? player.drawOrder[i + 1] << 4 : 0)));
writer.Write(player.Name);
}
for (int i = 0; i < 12; i++) {
foreach (var player in this.Players) {
var move = player.turns[i];
writer.Write((byte) move.CardNumber);
writer.Write((byte) (move.Rotation | (move.IsPass ? 0x80 : 0) | (move.IsSpecialAttack ? 0x40 : 0)));
writer.Write((sbyte) move.X);
writer.Write((sbyte) move.Y);
}
}
}
}

View File

@ -31,6 +31,9 @@ public class Player {
_ => this.Move != null
};
[JsonIgnore]
internal List<ReplayTurn> turns = new(12);
[JsonIgnore]
private readonly Game game;
[JsonIgnore]
@ -42,6 +45,8 @@ public class Player {
[JsonIgnore]
internal Move? Move;
[JsonIgnore]
internal int[]? initialDrawOrder;
[JsonIgnore]
internal int[]? drawOrder;
[JsonIgnore]
internal int? selectedStageIndex;
@ -55,6 +60,7 @@ public class Player {
[MemberNotNull(nameof(drawOrder))]
internal void Shuffle(Random random) {
this.drawOrder = new int[15];
this.initialDrawOrder ??= this.drawOrder;
for (int i = 0; i < 15; i++) this.drawOrder[i] = i;
for (int i = 14; i > 0; i--) {
var j = random.Next(i);

View File

@ -426,6 +426,21 @@ internal class Program {
}
break;
}
case "replay": {
if (e.Request.HttpMethod != "GET") {
e.Response.AddHeader("Allow", "GET");
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
} else {
if (game.State != GameState.Ended) {
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameInProgress", "You can't see the replay until the game has ended."));
return;
}
var ms = new MemoryStream();
game.WriteReplayData(ms);
SetResponse(e.Response, HttpStatusCode.OK, "application/octet-stream", ms.ToArray());
}
break;
}
default:
SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found."));
break;

View File

@ -0,0 +1,9 @@
namespace TableturfBattleServer;
public struct ReplayTurn {
public int CardNumber;
public int X;
public int Y;
public int Rotation;
public bool IsPass;
public bool IsSpecialAttack;
}