Compare commits

..

23 Commits

Author SHA1 Message Date
Andrio Celos
a02809ef25
Fix Show Deck showing the deck from a previous battle 2025-08-20 22:40:35 +10:00
Andrio Celos
260b1425bd
Enable non-default costs for custom cards 2025-03-29 18:52:12 +11:00
Andrio Celos
c531b92884
Fix issues with lobby game settings UI 2025-03-29 18:48:02 +11:00
Andrio Celos
3e77d2afde
Remove some validation from deckSleeves API field
This fixes a bug where certain actual sleeve IDs were rejected. Thanks to a Discord user who reported this.
2025-03-27 18:09:55 +11:00
Andrio Celos
74747b7f16 Fix incorrectly-positioned ink pattern on Heavy Edit Splatling 2024-06-10 12:45:25 +10:00
Andrio Celos
825c02db58 Add the ability to export snapshots of cards 2024-06-10 12:01:15 +10:00
Andrio Celos
87c8280b03 Preload the font before loading the card database
Fixes card names not being scaled correctly.
2024-06-10 12:00:35 +10:00
Andrio Celos
686a648dcd Add more metadata for new cards 2024-06-02 19:55:46 +10:00
Andrio Celos
83e4c9932c Add Sizzle Season 2024 cards 2024-06-02 19:16:46 +10:00
Andrio Celos
ef8f5a700d Fix infinite loop of 'game not found' errors in a certain situation 2024-03-12 13:27:30 +11:00
Andrio Celos
0b57a48de3 Fix sort ordering of new weapon variants 2024-03-08 14:57:41 +11:00
Andrio Celos
0891a53da1 Reorganise assets and convert images to WebP 2024-03-08 12:45:03 +11:00
Andrio Celos
ca287ea8eb Rework card rendering to use the raw CardInk_00 asset 2024-03-08 12:34:57 +11:00
Andrio Celos
bd1bdd9966 Adjust card rendering 2024-03-08 11:00:41 +11:00
Andrio Celos
6409983705 Correct card name gradient on longer names 2024-03-07 10:51:35 +11:00
Andrio Celos
9d9d560b7a Adjust ink colours for 2-star cards 2024-03-07 10:41:31 +11:00
Andrio Celos
282b998d17 Fix card name gradients not showing when filtering the gallery 2024-03-04 10:07:14 +11:00
Andrio Celos
6372e79204 Finalise Fresh Season 2024 cards and sleeves 2024-03-01 18:35:21 +11:00
Andrio Celos
d627db31fb Add a remove card button to the deck editor 2024-03-01 11:20:06 +11:00
Andrio Celos
5296e19e86 Fix an error exporting custom cards without an image 2024-03-01 11:17:49 +11:00
Andrio Celos
242ff60a9d Reorganise server code; move API endpoints into separate methods 2024-02-29 11:39:04 +11:00
Andrio Celos
4878d7d2c0 Update copyright notice 2024-02-26 09:54:07 +11:00
Andrio Celos
2792089465 Fix layout of stages Two-Lane Splattop and Pedal to the Metal 2024-02-24 21:08:54 +11:00
26 changed files with 1621 additions and 1154 deletions

View File

@ -2,14 +2,14 @@
"name": "Tableturf Battle",
"icons": [
{
"src": "external/android-chrome-192x192.png",
"src": "external/android-chrome-192x192.webp",
"sizes": "192x192",
"type": "image/png"
"type": "image/webp"
},
{
"src": "external/android-chrome-512x512.png",
"src": "external/android-chrome-512x512.webp",
"sizes": "512x512",
"type": "image/png"
"type": "image/webp"
}
],
"theme_color": "#0c92f2",

View File

@ -9,8 +9,8 @@
</script>
<title>Tableturf Battle</title>
<link rel="apple-touch-icon" sizes="180x180" href="assets/external/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="assets/external/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="assets/external/favicon-16x16.png">
<link rel="icon" type="image/webp" sizes="32x32" href="assets/external/favicon-32x32.webp">
<link rel="icon" type="image/webp" sizes="16x16" href="assets/external/favicon-16x16.webp">
<link rel="manifest" href="assets/site.webmanifest">
<link rel="stylesheet" href="tableturf.css"/>
<script src="config/config.js"></script>
@ -30,8 +30,27 @@
</head>
<body>
<div id="noJSPage">This application requires JavaScript.</div>
<svg id="cardDisplayAssets">
<defs>
<clipPath id="cardBorder">
<rect x="19" y="20" width="404" height="576" rx="18" ry="18"/>
</clipPath>
<linearGradient id='rareGradient' gradientUnits="userSpaceOnUse" x1="29.2%" y1='21.5%' x2="55.5%" y2="15.7%" spreadMethod='reflect'>
<stop offset='0%' stop-color='#FBFFCC'/>
<stop offset='100%' stop-color='#E0AE12'/>
</linearGradient>
<linearGradient id='freshGradient' gradientUnits="userSpaceOnUse" x1="17.5%" y1="-2.5%" x2="83.5%" y2="32%">
<stop offset='0%' stop-color='#FF93DD'/>
<stop offset='20%' stop-color='#FEF499'/>
<stop offset='50%' stop-color='#C9448A'/>
<stop offset='75%' stop-color='#1EFBC3'/>
<stop offset='95%' stop-color='#FD97DB'/>
<stop offset='100%' stop-color='#FFBAC2'/>
</linearGradient>
</defs>
</svg>
<div id="preGamePage" hidden>
<div id="logoBanner"><img title="Tableturf Battle" alt="Tableturf Battle logo" id="logo" src="assets/external/logo.png"></div>
<div id="logoBanner"><img title="Tableturf Battle" alt="Tableturf Battle logo" id="logo" src="assets/external/logo.webp"></div>
<h1>Tableturf Battle</h1>
<form id="preGameForm">
<p><label for="nameBox">Choose a nickname: <input type="text" id="nameBox" required minlength="1" maxlength="20"/></label></p>
@ -488,6 +507,12 @@
<input type="radio" name="deckSleeves" id="deckSleeve22" value="22"/><label for="deckSleeve22"></label>
<input type="radio" name="deckSleeves" id="deckSleeve23" value="23"/><label for="deckSleeve23"></label>
<input type="radio" name="deckSleeves" id="deckSleeve24" value="24"/><label for="deckSleeve24"></label>
<input type="radio" name="deckSleeves" id="deckSleeve25" value="25"/><label for="deckSleeve25"></label>
<input type="radio" name="deckSleeves" id="deckSleeve26" value="26"/><label for="deckSleeve26"></label>
<input type="radio" name="deckSleeves" id="deckSleeve27" value="27"/><label for="deckSleeve27"></label>
<input type="radio" name="deckSleeves" id="deckSleeve28" value="28"/><label for="deckSleeve28"></label>
<input type="radio" name="deckSleeves" id="deckSleeve29" value="29"/><label for="deckSleeve29"></label>
<input type="radio" name="deckSleeves" id="deckSleeve30" value="30"/><label for="deckSleeve30"></label>
</section>
<section id="deckSleevesFormButtons">
<button type="submit" id="deckSleevesOkButton">Select</button>
@ -585,6 +610,7 @@
<input type="text" id="cardListFilterBox" placeholder="Filter"/>
</div>
<div id="cardList" class="cardListGrid"></div>
<button id="deckEditorRemoveButton">Cut from deck</button>
</section>
<div id="deckEditorCardListBackdrop"></div>
</div>
@ -606,29 +632,30 @@
<div id="galleryCardEditor" hidden>
<textarea type="text" id="galleryCardEditorName" placeholder="Card name"></textarea>
<div id="galleryCardEditorGrid"></div>
<div id="galleryCardEditorSpecialCost">
<label for="galleryCardEditorSpecialCostDefaultBox"><input type="checkbox" id="galleryCardEditorSpecialCostDefaultBox" checked/> Default</label>
</div>
<div id="galleryCardEditorImageToolbar" class="galleryCardEditorToolbar">
<input type="file" id="galleryCardEditorImageFile" accept="image/*" autocomplete="off"/>
<button id="galleryCardEditorImageSelectButton">Choose image</button>
<button id="galleryCardEditorImageClearButton">Clear</button>
<footer>(The image will not be seen by other players.)</footer>
</div>
<div id="galleryCardEditorImageToolbar2" class="galleryCardEditorToolbar">
<div id="galleryCardEditorColoursToolbar" class="galleryCardEditorToolbar">
Colours:
<input type="color" id="galleryCardEditorColour2"/>
<input type="color" id="galleryCardEditorColour1"/>
<input type="color" id="galleryCardEditorColour2"/>
<select id="galleryCardEditorColourPresetBox"></select>
</div>
<div id="galleryCardEditorImageToolbar3" class="galleryCardEditorToolbar">
<div id="galleryCardEditorRarityToolbar" class="galleryCardEditorToolbar">
<label for="galleryCardEditorRarityBox">Rarity: <select id="galleryCardEditorRarityBox"></select></label>
</div>
<div id="galleryCardEditorSpecialCost">
<label for="galleryCardEditorSpecialCostDefaultBox"><input type="checkbox" id="galleryCardEditorSpecialCostDefaultBox" checked/>Default</label>
</div>
</div>
<form method="dialog">
<button type="button" id="galleryCardEditorEditButton">Edit</button>
<button type="button" id="galleryCardEditorSubmitButton">Save</button>
<button type="button" id="galleryCardEditorDeleteButton">Delete</button>
<button type="button" id="galleryCardEditorSnapshotButton">Snapshot</button>
<button type="submit" id="galleryCardEditorCancelButton">Cancel</button>
</form>
<dialog id="galleryCardDeleteDialog">

View File

@ -20,8 +20,8 @@ class Card {
private maxX: number;
private maxY: number;
static DEFAULT_INK_COLOUR_1: Colour = { r: 116, g: 96, b: 240 };
static DEFAULT_INK_COLOUR_2: Colour = { r: 224, g: 242, b: 104 };
static DEFAULT_INK_COLOUR_1: Colour = { r: 224, g: 242, b: 104 };
static DEFAULT_INK_COLOUR_2: Colour = { r: 116, g: 96, b: 240 };
private static textScaleCalculationContext: OffscreenCanvasRenderingContext2D | null = null;
private static getTextScaleCalculationContext() {

View File

@ -41,65 +41,78 @@ const cardDatabase = {
},
loadAsync() {
return new Promise<Card[]>((resolve, reject) => {
if (cardDatabase.cards != null) {
resolve(cardDatabase.cards);
return;
}
const cardListRequest = new XMLHttpRequest();
cardListRequest.open('GET', `${config.apiBaseUrl}/cards`);
cardListRequest.addEventListener('load', e => {
const cards: Card[] = [ ];
if (cardListRequest.status == 200) {
const s = cardListRequest.responseText;
const response = JSON.parse(s) as object[];
for (const o of response) {
const card = Card.fromJson(o);
cards.push(card);
cardDatabase.lastOfficialCardNumber = Math.max(cardDatabase.lastOfficialCardNumber, card.number);
if (card.number < 0) cardDatabase._byAltNumber[-card.number] = card;
else if (card.altNumber != null && card.altNumber < 0) cardDatabase._byAltNumber[-card.altNumber] = card;
}
cardDatabase.cards = cards;
if (window.location.protocol == 'file:') {
// If debugging locally, just read the files from the assets directory.
for (const card of cardDatabase.cards!) {
card.imageUrl = `assets/external/card/${card.artFileName}.webp`
}
resolve(cards);
return;
}
// Otherwise, download and extract card images from a .tar package.
const imagesRequest = new XMLHttpRequest();
imagesRequest.responseType = 'arraybuffer';
imagesRequest.open('GET', 'assets/external/card.tar');
imagesRequest.addEventListener('load', () => {
if (imagesRequest.status == 200 && imagesRequest.response) {
const buffer = imagesRequest.response as ArrayBuffer;
untar(buffer).then(files => {
for (const tarFile of files) {
const card = cardDatabase.cards!.find(c => tarFile.name == `${c.artFileName}.webp`);
if (!card) continue;
card.imageUrl = tarFile.getBlobUrl();
}
resolve(cards);
});
} else {
reject(new Error(`Error downloading card images: response was ${imagesRequest.status}`));
}
});
imagesRequest.addEventListener('error', () => {
reject(new Error('Error downloading card images: no further information.'))
});
imagesRequest.send();
} else {
reject(new Error(`Error downloading card database: response was ${cardListRequest.status}`));
function afterFontLoaded() {
if (cardDatabase.cards != null) {
resolve(cardDatabase.cards);
return;
}
});
cardListRequest.addEventListener('error', e => {
reject(new Error('Error downloading card database: no further information.'))
});
cardListRequest.send();
const cardListRequest = new XMLHttpRequest();
cardListRequest.open('GET', `${config.apiBaseUrl}/cards`);
cardListRequest.addEventListener('load', e => {
const cards: Card[] = [ ];
if (cardListRequest.status == 200) {
const s = cardListRequest.responseText;
const response = JSON.parse(s) as object[];
for (const o of response) {
const card = Card.fromJson(o);
cards.push(card);
cardDatabase.lastOfficialCardNumber = Math.max(cardDatabase.lastOfficialCardNumber, card.number);
if (card.number < 0) cardDatabase._byAltNumber[-card.number] = card;
else if (card.altNumber != null && card.altNumber < 0) cardDatabase._byAltNumber[-card.altNumber] = card;
}
cardDatabase.cards = cards;
if (window.location.protocol == 'file:') {
// If debugging locally, just read the files from the assets directory.
for (const card of cardDatabase.cards!) {
card.imageUrl = `assets/external/card/${card.artFileName}.webp`
}
resolve(cards);
return;
}
// Otherwise, download and extract card images from a .tar package.
const imagesRequest = new XMLHttpRequest();
imagesRequest.responseType = 'arraybuffer';
imagesRequest.open('GET', 'assets/external/card.tar');
imagesRequest.addEventListener('load', () => {
if (imagesRequest.status == 200 && imagesRequest.response) {
const buffer = imagesRequest.response as ArrayBuffer;
untar(buffer).then(files => {
for (const tarFile of files) {
const card = cardDatabase.cards!.find(c => tarFile.name == `${c.artFileName}.webp`);
if (!card) continue;
card.imageUrl = tarFile.getBlobUrl();
}
resolve(cards);
});
} else {
reject(new Error(`Error downloading card images: response was ${imagesRequest.status}`));
}
});
imagesRequest.addEventListener('error', () => {
reject(new Error('Error downloading card images: no further information.'))
});
imagesRequest.send();
} else {
reject(new Error(`Error downloading card database: response was ${cardListRequest.status}`));
}
});
cardListRequest.addEventListener('error', e => {
reject(new Error('Error downloading card database: no further information.'))
});
cardListRequest.send();
}
// Preload the font first; calculating text scale depends on this.
const link = document.createElement('link');
link.rel = 'preload';
link.href = 'assets/external/splatoon1.woff2';
link.as = 'font';
link.type = 'font/woff';
link.crossOrigin = '';
link.addEventListener('load', afterFontLoaded);
link.addEventListener('error', afterFontLoaded);
document.head.appendChild(link);
});
}
}

View File

@ -9,6 +9,25 @@ class CardDisplay implements ICardElement {
private static nextIdNumber = 0;
private static getGradientId(baseId: string, scale: number) {
if (scale >= 20) return baseId;
const roundedScale = Math.round(scale * 20);
if (roundedScale >= 20) return baseId;
const id = `${baseId}${roundedScale}`;
if (document.getElementById(id)) return id;
const baseElement = document.getElementById(baseId);
if (!baseElement)
throw new Error(`Base gradient element '${baseId}' not found.`);
const el = <Element> baseElement!.cloneNode(true);
baseElement.insertAdjacentElement('afterend', el);
el.setAttribute('id', id);
el.setAttribute('gradientTransform', `translate(${-6350 / roundedScale + 317.5} 0) scale(${20 / roundedScale} 1)`);
return id;
}
constructor(card: Card, level: number, elementType: string = 'div') {
this.idNumber = CardDisplay.nextIdNumber++;
this.card = card;
@ -20,7 +39,7 @@ class CardDisplay implements ICardElement {
this.element = element;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 635 885');
svg.setAttribute('viewBox', '0 0 442 616');
svg.setAttribute('alt', card.name);
this.svg = svg;
element.appendChild(svg);
@ -38,14 +57,17 @@ class CardDisplay implements ICardElement {
image.setAttribute('height', '100%');
svg.appendChild(image);
if (level == 0) {
svg.insertAdjacentHTML('beforeend', `<image href="assets/external/CardInk.webp" width="635" height="885" clip-path="url(#myClip)"/>`);
} else {
if (level > 0) {
const r1 = card.inkColour1.r / 255;
const g1 = card.inkColour1.g / 255;
const b1 = card.inkColour1.b / 255;
const dr = (card.inkColour2.r - card.inkColour1.r) / 255;
const dg = (card.inkColour2.g - card.inkColour1.g) / 255;
const db = (card.inkColour2.b - card.inkColour1.b) / 255;
svg.insertAdjacentHTML('beforeend', `
<filter id="ink1-${this.idNumber}" class="inkFilter" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="${card.inkColour1.r / 255} 0 0 0 0 0 ${card.inkColour1.g / 255} 0 0 0 0 0 ${card.inkColour1.b / 255} 0 0 0 0 0 0.88 0"/></filter>
<image href="assets/external/CardInk-1.webp" width="635" height="885" clip-path="url(#myClip)" filter="url(#ink1-${this.idNumber})"/>
<filter id="ink2-${this.idNumber}" class="inkFilter" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="${card.inkColour2.r / 255} 0 0 0 0 0 ${card.inkColour2.g / 255} 0 0 0 0 0 ${card.inkColour2.b / 255} 0 0 0 0 0 0.88 0"/></filter>
<image href="assets/external/CardInk-2.webp" width="635" height="885" clip-path="url(#myClip)" filter="url(#ink2-${this.idNumber})"/>
<filter id="ink-${this.idNumber}" class="inkFilter" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="${dr} 0 0 0 ${r1} 0 ${dg} 0 0 ${g1} 0 0 ${db} 0 ${b1} 0 0 0 0.88 0"/></filter>
<image href="assets/external/CardInk_00.webp" width="100%" height="100%" clip-path="url(#cardBorder)" filter="url(#ink-${this.idNumber})"/>
<image href="assets/external/CardFrame_01.webp" width="100%" height="100%" clip-path="url(#cardBorder)"/>
`);
}
@ -62,7 +84,7 @@ class CardDisplay implements ICardElement {
// Grid
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('class', 'cardGrid');
g.setAttribute('transform', 'translate(380 604) rotate(6.5) scale(0.283)');
g.setAttribute('transform', 'translate(264 420) rotate(6.5) scale(0.197)');
svg.appendChild(g);
CardDisplay.CreateSvgCardGrid(card, g);
@ -71,12 +93,12 @@ class CardDisplay implements ICardElement {
const text1 = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text1.setAttribute('class', 'cardDisplayName');
text1.setAttribute('x', '50%');
text1.setAttribute('y', '168');
text1.setAttribute('y', '19%');
text1.setAttribute('text-anchor', 'middle');
text1.setAttribute('font-size', '76');
text1.setAttribute('font-size', '53');
text1.setAttribute('font-weight', 'bold');
text1.setAttribute('stroke', 'black');
text1.setAttribute('stroke-width', '15');
text1.setAttribute('stroke-width', '10.5');
text1.setAttribute('stroke-linejoin', 'round');
text1.setAttribute('paint-order', 'stroke');
text1.setAttribute('word-spacing', '-10');
@ -87,31 +109,15 @@ class CardDisplay implements ICardElement {
text1.setAttribute('fill', '#6038FF');
break;
case Rarity.Rare:
svg.insertAdjacentHTML('beforeend', `
<linearGradient id='rareGradient' y1='25%' spreadMethod='reflect'>
<stop offset='0%' stop-color='#FEF9C6'/>
<stop offset='50%' stop-color='#DFAF17'/>
<stop offset='100%' stop-color='#FEF9C6'/>
</linearGradient>
`);
text1.setAttribute('fill', 'url("#rareGradient")');
text1.setAttribute('fill', `url("#${CardDisplay.getGradientId('rareGradient', card.textScale)}")`);
break;
case Rarity.Fresh:
svg.insertAdjacentHTML('beforeend', `
<linearGradient id='freshGradient' y2='25%'>
<stop offset='0%' stop-color='#FF8EDD'/>
<stop offset='25%' stop-color='#FFEC9F'/>
<stop offset='50%' stop-color='#B84386'/>
<stop offset='75%' stop-color='#2BEFC8'/>
<stop offset='100%' stop-color='#FF8EDD'/>
</linearGradient>
`);
text1.setAttribute('fill', 'url("#freshGradient")');
text1.setAttribute('fill', `url("#${CardDisplay.getGradientId('freshGradient', card.textScale)}")`);
break;
}
if (card.line1 != null && card.line2 != null) {
const tspan1 = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
tspan1.setAttribute('y', '122');
tspan1.setAttribute('y', '13.8%');
tspan1.appendChild(document.createTextNode(card.line1));
text1.appendChild(tspan1);
@ -119,14 +125,14 @@ class CardDisplay implements ICardElement {
// Add a space in the middle, to be included when copying the card name.
const tspanBr = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
tspanBr.setAttribute('x', '50%');
tspanBr.setAttribute('y', '169');
tspanBr.setAttribute('y', '19%');
tspanBr.appendChild(document.createTextNode(' '));
text1.appendChild(tspanBr);
}
const tspan2 = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
tspan2.setAttribute('x', '50%');
tspan2.setAttribute('y', '216');
tspan2.setAttribute('y', '24.4%');
tspan2.appendChild(document.createTextNode(card.line2));
text1.appendChild(tspan2);
} else
@ -135,15 +141,15 @@ class CardDisplay implements ICardElement {
svg.appendChild(text1);
// Size
svg.insertAdjacentHTML('beforeend', `<image class='cardSizeBackground' href='assets/external/Game Assets/CardCost_0${card.rarity}.png' width='80' height='80' transform='translate(12 798) rotate(-45) scale(1.33)'/>`);
svg.insertAdjacentHTML('beforeend', `<text fill='white' stroke='${card.rarity == Rarity.Common ? '#482BB4' : card.rarity == Rarity.Rare ? '#8B7E25' : '#481EF9'}' paint-order='stroke' stroke-width='5' font-size='48' y='816' x='87' text-anchor='middle'>${card.size}</text>`);
svg.insertAdjacentHTML('beforeend', `<image class='cardSizeBackground' href='assets/external/CardCost_0${card.rarity}.webp' width='74.1' height='74.1' transform='translate(8.4 555) rotate(-45)'/>`);
svg.insertAdjacentHTML('beforeend', `<text fill='white' stroke='${card.rarity == Rarity.Common ? '#482BB4' : card.rarity == Rarity.Rare ? '#8B7E25' : '#481EF9'}' paint-order='stroke' stroke-width='5' font-size='33.4' x='13.7%' y='92.2%' text-anchor='middle'>${card.size}</text>`);
this.sizeElement = svg.lastElementChild as SVGTextElement;
// Special cost
const g2 = document.createElementNS('http://www.w3.org/2000/svg', 'g');
this.specialCostGroup = g2;
g2.setAttribute('class', 'specialCost');
g2.setAttribute('transform', 'translate(170 806) scale(0.32)');
g2.setAttribute('transform', 'translate(118 561) scale(0.222)');
svg.appendChild(g2);
this.setSpecialCost(card.specialCost);
@ -165,7 +171,7 @@ class CardDisplay implements ICardElement {
rect.classList.add(card.grid[x][y] == Space.SpecialInactive1 ? 'special' : 'ink');
const elements: Element[] = [rect];
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
image.setAttribute('href', card.grid[x][y] == Space.SpecialInactive1 ? 'assets/SpecialOverlay.png' : 'assets/InkOverlay.png');
image.setAttribute('href', card.grid[x][y] == Space.SpecialInactive1 ? 'assets/SpecialOverlay.webp' : 'assets/InkOverlay.webp');
elements.push(image);
for (const el of elements) {
@ -185,7 +191,7 @@ class CardDisplay implements ICardElement {
for (let i = 0; i < value; i++) {
let rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
image.setAttribute('href', 'assets/SpecialOverlay.png');
image.setAttribute('href', 'assets/SpecialOverlay.webp');
for (const el of [ rect, image ]) {
el.setAttribute('x', (110 * (i % 5)).toString());
el.setAttribute('y', (-125 * Math.floor(i / 5)).toString());

View File

@ -13,6 +13,7 @@ const deckTestButton = document.getElementById('deckTestButton') as HTMLButtonEl
const deckSaveButton = document.getElementById('deckSaveButton') as HTMLButtonElement;
const deckCancelButton = document.getElementById('deckCancelButton') as HTMLButtonElement;
const deckCardListBackButton = document.getElementById('deckCardListBackButton') as HTMLLinkElement;
const deckEditorRemoveButton = document.getElementById('deckEditorRemoveButton') as HTMLButtonElement;
const cardListFilterBox = document.getElementById('cardListFilterBox') as HTMLSelectElement;
const testStageSelectionList = document.getElementById('testStageSelectionList')!;
const testStageButtons = new CheckButtonGroup<Stage>(testStageSelectionList);
@ -67,6 +68,28 @@ function addCardToDeckEditor(card: Card) {
});
}
deckEditorRemoveButton.addEventListener('click', () => {
const index = deckEditCardButtons.entries.findIndex(el => el.button.checked);
if (index < 0) return;
const oldEntry = deckEditCardButtons.entries[index];
const oldCardNumber = oldEntry.value;
if (oldCardNumber != 0)
cardListButtonGroup.entries.find(e => e.value.number == oldCardNumber || e.value.altNumber == oldCardNumber)!.button.enabled = true;
const button3 = createDeckEditEmptySlotButton();
button3.checked = true;
deckEditCardButtons.replace(index, button3, 0);
deckEditUpdateSize();
cardList.listElement.parentElement!.classList.remove('selecting');
if (!deckModified) {
deckModified = true;
window.addEventListener('beforeunload', onBeforeUnload_deckEditor);
}
});
function deckEditInitStageDatabase(stages: Stage[]) {
for (const stage of stages) {
const button = new StageButton(stage);
@ -156,6 +179,7 @@ function createDeckEditCardButton(cardNumber: number) {
button2.checked = button2.card.number == cardNumber;
}
cardList.listElement.parentElement!.classList.add('selecting');
deckEditorRemoveButton.hidden = false;
});
button.buttonElement.addEventListener('dragstart', e => {
if (e.dataTransfer == null) return;
@ -235,6 +259,7 @@ function createDeckEditEmptySlotButton() {
for (const button2 of cardList.cardButtons)
button2.checked = false;
cardList.listElement.parentElement!.classList.add('selecting');
deckEditorRemoveButton.hidden = true;
});
buttonElement.addEventListener('dragenter', e => e.preventDefault());
buttonElement.addEventListener('dragover', deckEditCardButton_dragover);

View File

@ -426,7 +426,6 @@ function deckExportJsonReplacer(key: string, value: any) {
case 'number':
case 'altNumber':
case 'artFileName':
case 'specialCost':
case 'size':
case 'textScale':
case 'isVariantOf':
@ -440,7 +439,7 @@ function deckExportJsonReplacer(key: string, value: any) {
return value ?? undefined; // Omit null values.
case 'imageUrl':
// Custom cards store image data here, so include it if it is a data URI.
return (<string> value).startsWith('data:') ? value : undefined;
return value && (<string> value).startsWith('data:') ? value : undefined;
default:
return value;
}

View File

@ -25,13 +25,14 @@ const galleryCardEditorSpecialCostDefaultBox = document.getElementById('galleryC
const galleryCardEditorEditButton = document.getElementById('galleryCardEditorEditButton') as HTMLButtonElement;
const galleryCardEditorSubmitButton = document.getElementById('galleryCardEditorSubmitButton') as HTMLButtonElement;
const galleryCardEditorDeleteButton = document.getElementById('galleryCardEditorDeleteButton') as HTMLButtonElement;
const galleryCardEditorSnapshotButton = document.getElementById('galleryCardEditorSnapshotButton') as HTMLButtonElement;
const galleryCardEditorCancelButton = document.getElementById('galleryCardEditorCancelButton') as HTMLButtonElement;
const galleryCardEditorDeleteYesButton = document.getElementById('galleryCardEditorDeleteYesButton') as HTMLButtonElement;
const colourPresets: {[key: string]: [ Colour, Colour ]} = {
"Default": [ Card.DEFAULT_INK_COLOUR_1, Card.DEFAULT_INK_COLOUR_2 ],
"Octarian": [ { r: 166, g: 105, b: 169 }, { r: 121, g: 111, b: 174 } ],
"Salmonid": [ { r: 84, g: 142, b: 122 }, { r: 193, g: 111, b: 98 } ],
"Octarian": [ { r: 121, g: 111, b: 174 }, { r: 166, g: 105, b: 169 } ],
"Salmonid": [ { r: 193, g: 111, b: 98 }, { r: 84, g: 142, b: 122 } ],
};
const ownedCards: {[key: number]: number} = { 6: 0, 34: 0, 159: 0, 13: 0, 45: 0, 137: 0, 22: 0, 52: 0, 141: 0, 28: 0, 55: 0, 103: 0, 40: 0, 56: 0, 92: 0 };
@ -113,11 +114,43 @@ function openGalleryCardView(card: Card) {
updateSelectedPreset([card.inkColour1, card.inkColour2]);
galleryCardEditorName.value = card.line2 == null ? card.name : `${card.line1}\n${card.line2}`;
let size = 0; let hasSpecialSpace = false;
for (let y = 0; y < 8; y++) {
for (let x = 0; x < 8; x++) {
galleryCardEditorGridButtons[y][x].dataset.state = card.grid[y][x].toString();
switch (card.grid[y][x]) {
case Space.Ink1:
size++;
break;
case Space.SpecialInactive1:
size++;
hasSpecialSpace = true;
break;
}
}
}
let defaultSpecialCost =
size <= 3 ? 1
: size <= 5 ? 2
: size <= 8 ? 3
: size <= 11 ? 4
: size <= 15 ? 5
: 6;
if (!hasSpecialSpace && defaultSpecialCost > 3)
defaultSpecialCost = 3;
galleryCardEditorSpecialCostDefaultBox.checked = card.specialCost == defaultSpecialCost;
customCardSpecialCost = card.specialCost;
for (let i = 0; i < galleryCardEditorSpecialCostButtons.length; i++) {
const button = galleryCardEditorSpecialCostButtons[i];
if (parseInt(button.dataset.value!) <= card.specialCost)
button.classList.add('active');
else
button.classList.remove('active');
}
updateCustomCardSize();
}
@ -385,7 +418,7 @@ galleryCardEditorRarityBox.addEventListener('change', () => {
display.element.classList.add(Rarity[parseInt(galleryCardEditorRarityBox.value)].toLowerCase());
const sizeImage = <SVGImageElement> display.svg.getElementsByClassName('cardSizeBackground')[0];
sizeImage.setAttribute('href', `assets/external/Game Assets/CardCost_0${galleryCardEditorRarityBox.value}.png`);
sizeImage.setAttribute('href', `assets/external/CardCost_0${galleryCardEditorRarityBox.value}.webp`);
const backgroundImage = <SVGImageElement> display.svg.getElementsByClassName('cardDisplayBackground')[0];
backgroundImage.setAttribute('href', `assets/external/CardBackground-custom-${galleryCardEditorRarityBox.value}-1.webp`);
@ -396,15 +429,23 @@ galleryCardEditorColour2.addEventListener('change', galleryCardEditorColour_chan
function galleryCardEditorColour_change() {
const display = galleryCardDisplay!;
const filters = display.svg.getElementsByClassName('inkFilter');
const filter = display.svg.getElementsByClassName('inkFilter')[0];
const selectedColours = [];
for (let i = 0; i < 2; i++) {
const value = [galleryCardEditorColour1, galleryCardEditorColour2][i].value;
const colour = { r: parseInt(value.substring(1, 3), 16), g: parseInt(value.substring(3, 5), 16), b: parseInt(value.substring(5, 7), 16) };
selectedColours.push(colour);
filters[i].getElementsByTagName('feColorMatrix')[0].setAttribute('values', `${colour.r / 255} 0 0 0 0 0 ${colour.g / 255} 0 0 0 0 0 ${colour.b / 255} 0 0 0 0 0 0.88 0`)
}
const r1 = selectedColours[0].r / 255;
const g1 = selectedColours[0].g / 255;
const b1 = selectedColours[0].b / 255;
const dr = (selectedColours[1].r - selectedColours[0].r) / 255;
const dg = (selectedColours[1].g - selectedColours[0].g) / 255;
const db = (selectedColours[1].b - selectedColours[0].b) / 255;
filter.getElementsByTagName('feColorMatrix')[0].setAttribute('values', `${dr} 0 0 0 ${r1} 0 ${dg} 0 0 ${g1} 0 0 ${db} 0 ${b1} 0 0 0 0.88 0`);
updateSelectedPreset(selectedColours);
}
@ -485,3 +526,133 @@ galleryCardEditorDeleteYesButton.addEventListener('click', () => {
saveCustomCards();
saveDecks();
});
galleryCardEditorSnapshotButton.addEventListener('click', async () => {
//const canvas = new OffscreenCanvas(635, 885);
const card = galleryCardDisplay!.card;
const canvas = document.createElement('canvas');
canvas.id = 'cardSnapshotCanvas';
canvas.width = 442; canvas.height = 616; //canvas.setAttribute('style', 'position: absolute; left: 0; top: 0; width: 100%;');
//galleryCardDisplay!.svg.parentElement!.appendChild(canvas);
const ctx = canvas.getContext('2d')!;
function toPx(length: SVGLength, referenceLength: number) {
switch (length.unitType) {
case SVGLength.SVG_LENGTHTYPE_NUMBER:
case SVGLength.SVG_LENGTHTYPE_PX:
return length.valueInSpecifiedUnits;
case SVGLength.SVG_LENGTHTYPE_PERCENTAGE:
return length.valueInSpecifiedUnits * referenceLength / 100;
default:
throw new Error(`Unknown unit type: ${length.unitType}`);
}
}
function drawElements(parent: SVGElement) {
for (const node of parent.childNodes) {
if (!(node instanceof SVGGraphicsElement)) continue;
const el = <SVGGraphicsElement> node;
if (el.transform.baseVal.length > 0) {
const transformAboutCentre = el.getAttribute('transform-origin') == 'center';
if (transformAboutCentre) ctx.translate(canvas.width / 2, canvas.height / 2);
for (const transformElement of el.transform.baseVal) {
const matrix = transformElement.matrix;
ctx.transform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f);
}
if (transformAboutCentre) ctx.translate(-canvas.width / 2, -canvas.height / 2);
}
if (el instanceof SVGGElement) {
drawElements(el);
} else if (el instanceof SVGImageElement) {
ctx.save();
ctx.filter = el.getAttribute('filter') ?? 'none';
if (el.hasAttribute('clip-path')) {
ctx.beginPath();
ctx.roundRect(canvas.width * 19 / 442, canvas.height * 20 / 616, canvas.width * 404 / 442, canvas.height * 576 / 616, canvas.width * 18 / 442);
ctx.clip();
}
ctx.drawImage(<SVGImageElement> el, el.x.baseVal.value, el.y.baseVal.value, toPx(el.width.baseVal, canvas.width), toPx(el.height.baseVal, canvas.height));
ctx.filter = 'none';
ctx.restore();
} else if (el instanceof SVGRectElement) {
if (el.classList[0] == 'empty') {
ctx.fillStyle = '#00000080';
ctx.strokeStyle = '#60606080';
ctx.lineWidth = 6;
ctx.strokeRect(toPx(el.x.baseVal, canvas.width), toPx(el.y.baseVal, canvas.height), toPx(el.width.baseVal, canvas.width), toPx(el.height.baseVal, canvas.height));
} else if (el.classList[0] == 'ink') {
ctx.fillStyle = document.body.style.getPropertyValue('--primary-colour-1');
} else {
ctx.fillStyle = document.body.style.getPropertyValue('--special-colour-1');
}
ctx.fillRect(toPx(el.x.baseVal, canvas.width), toPx(el.y.baseVal, canvas.height), toPx(el.width.baseVal, canvas.width), toPx(el.height.baseVal, canvas.height));
} else if (el instanceof SVGTextElement) {
const text = <SVGTextElement> el;
ctx.textAlign = 'center';
switch (text.classList.contains('cardDisplayName') ? card.rarity : Rarity.Common) {
case Rarity.Rare:
const rareGradient = ctx.createLinearGradient(canvas.width * 0.029, canvas.height * 0.273, canvas.width * 1.081, canvas.height * 0.041);
rareGradient.addColorStop(0, '#E0AE12');
rareGradient.addColorStop(0.25, '#FBFFCC');
rareGradient.addColorStop(0.5, '#E0AE12');
rareGradient.addColorStop(0.75, '#FBFFCC');
rareGradient.addColorStop(1, '#E0AE12');
ctx.fillStyle = rareGradient;
break;
case Rarity.Fresh:
const freshGradient = ctx.createLinearGradient(canvas.width * (0.5 - 0.325 / card.textScale), canvas.height * -0.025, canvas.width * (0.5 + 0.335 / card.textScale), canvas.height * 0.32);
freshGradient.addColorStop(0, '#FF93DD');
freshGradient.addColorStop(0.2, '#FEF499');
freshGradient.addColorStop(0.5, '#C9448A');
freshGradient.addColorStop(0.75, '#1EFBC3');
freshGradient.addColorStop(0.95, '#FD97DB');
freshGradient.addColorStop(1, '#FFBAC2');
ctx.fillStyle = freshGradient;
break;
default:
ctx.fillStyle = text.getAttribute('fill')!;
break;
}
ctx.strokeStyle = text.getAttribute('stroke')!;
ctx.lineWidth = parseFloat(text.getAttribute('stroke-width')!);
ctx.font = `bold ${text.getAttribute('font-size')!}px 'Splatoon 1'`;
(<any> ctx).wordSpacing = `${text.getAttribute('word-spacing') ?? '0'}px`;
for (const node2 of text.childNodes) {
const textContent = node2.textContent!;
if (node2 instanceof CharacterData) {
ctx.strokeText(textContent, toPx(text.x.baseVal[0], canvas.width), toPx(text.y.baseVal[0], canvas.height));
ctx.fillText(textContent, toPx(text.x.baseVal[0], canvas.width), toPx(text.y.baseVal[0], canvas.height));
} else if (node2 instanceof SVGTSpanElement) {
const tspan = <SVGTSpanElement> node2;
ctx.strokeText(textContent, toPx(text.x.baseVal[0], canvas.width), toPx(tspan.y.baseVal[0], canvas.height));
ctx.fillText(textContent, toPx(text.x.baseVal[0], canvas.width), toPx(tspan.y.baseVal[0], canvas.height));
}
}
}
if (el.transform.baseVal.length > 0) ctx.resetTransform();
}
}
drawElements(galleryCardDisplay!.svg);
canvas.toBlob(blob => {
if (blob == null) {
alert('Could not create the image.');
return;
}
const url = URL.createObjectURL(blob);
try {
const link = document.createElement('a');
link.href = url;
link.download = 'card.png';
link.click();
} finally {
URL.revokeObjectURL(url);
}
});
});

View File

@ -276,6 +276,7 @@ function joinGameError(message: string, fromInitialLoad: boolean) {
if (fromInitialLoad)
clearPreGameForm(true);
else {
showPage('preGame');
gameIDBox.focus();
gameIDBox.setSelectionRange(0, gameIDBox.value.length);
}

View File

@ -114,7 +114,7 @@ class ReplayLoader {
}
break;
}
case 3: case 4: {
case 3: case 4: case 5: {
const n = this.readUint8();
const numPlayers = n & 0x0F;
goalWinCount = n >> 4;
@ -131,8 +131,7 @@ class ReplayLoader {
const line2 = this.readString();
const name = line2 != '' ? `${line1} ${line2}` : line1;
const b = this.readUint8();
const rarity = <Rarity> b & 0x7F;
const wordWrap = (b & 0x80) != 0;
const rarity = <Rarity> b;
const specialCost = this.readUint8();
const inkColour1 = this.readColour();
const inkColour2 = this.readColour();
@ -293,6 +292,15 @@ class ReplayLoader {
turn.push({ card, isPass: true, isTimeout: (b & 0x20) != 0 });
else {
const move: PlayMove = { card, isPass: false, isTimeout: (b & 0x20) != 0, x, y, rotation: b & 0x03, isSpecialAttack: (b & 0x40) != 0 };
if (version < 5 && card.number == 217) {
// Heavy Edit Splatling: originally had the ink pattern transposed down one space
switch (move.rotation) {
case 0: move.y++; break;
case 1: move.x--; break;
case 2: move.y--; break;
default: move.x++; break;
}
}
turn.push(move);
}
}

View File

@ -37,7 +37,7 @@ class StageButton extends CheckButton {
if (stage.grid[x][y] & Space.SpecialInactive1) {
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
image.setAttribute('href', 'assets/SpecialOverlay.png');
image.setAttribute('href', 'assets/SpecialOverlay.webp');
image.setAttribute('x', rect.getAttribute('x')!);
image.setAttribute('y', rect.getAttribute('y')!);
image.setAttribute('width', rect.getAttribute('width')!);
@ -45,7 +45,7 @@ class StageButton extends CheckButton {
gridSvg.appendChild(image);
} else if (stage.grid[x][y] & Space.Ink1) {
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
image.setAttribute('href', 'assets/InkOverlay.png');
image.setAttribute('href', 'assets/InkOverlay.webp');
image.setAttribute('x', rect.getAttribute('x')!);
image.setAttribute('y', rect.getAttribute('y')!);
image.setAttribute('width', rect.getAttribute('width')!);
@ -82,7 +82,7 @@ class StageButton extends CheckButton {
cell.setAttribute('class', `SpecialInactive${i + 1}`);
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
image.setAttribute('href', 'assets/SpecialOverlay.png');
image.setAttribute('href', 'assets/SpecialOverlay.webp');
image.setAttribute('x', cell.getAttribute('x')!);
image.setAttribute('y', cell.getAttribute('y')!);
image.setAttribute('width', cell.getAttribute('width')!);

View File

@ -115,7 +115,7 @@ function clearUrlFromGame() {
function onGameSettingsChange() {
if (currentGame == null) return;
if (lobbyTimeLimitBox.value != currentGame.game.turnTimeLimit?.toString() ?? '')
if (lobbyTimeLimitBox.value != (currentGame.game.turnTimeLimit?.toString() ?? ''))
lobbyTimeLimitBox.value = currentGame.game.turnTimeLimit?.toString() ?? '';
lobbyAllowUpcomingCardsBox.checked = currentGame.game.allowUpcomingCards;
lobbyAllowCustomCardsBox.checked = currentGame.game.allowCustomCards;
@ -155,12 +155,14 @@ function onGameStateChange(game: any, playerData: PlayerData | null) {
case GameState.ChoosingStage:
initLobbyPage(window.location.toString());
showPage('lobby');
clearShowDeck();
clearConfirmLeavingGame();
showStageSelectionForm(playerData?.stageSelectionPrompt ?? null, playerData && game.players[playerData.playerIndex]?.isReady);
lobbySelectedStageSection.hidden = true;
break;
case GameState.ChoosingDeck:
showPage('lobby');
clearShowDeck();
if (currentGame.me) setConfirmLeavingGame();
if (selectedStageIndicator)
lobbySelectedStageSection.removeChild(selectedStageIndicator.buttonElement);
@ -269,8 +271,10 @@ function setupWebSocket(gameID: string) {
}
setLoadingMessage(null);
if (!payload.data) {
joinGameError('The game was not found.', false);
currentGame = null;
webSocket.removeEventListener('close', webSocket_close);
webSocket.close();
alert('The game was not found.');
} else {
currentGame = {
id: gameID,
@ -345,6 +349,7 @@ function setupWebSocket(gameID: string) {
case 'settingsChange':
currentGame.game.turnTimeLimit = payload.data.turnTimeLimit;
currentGame.game.allowUpcomingCards = payload.data.allowUpcomingCards;
currentGame.game.allowCustomCards = payload.data.allowCustomCards;
onGameSettingsChange();
break;
case 'join':

View File

@ -33,7 +33,7 @@ body {
--player-special-accent-colour: var(--special-accent-colour-1);
--theme-colour: #0c92f2;
color: white;
background: url('assets/external/BannerBackground.png') black;
background: url('assets/external/BannerBackground.webp') black;
background-position: 50% -72px;
color-scheme: dark;
}
@ -334,6 +334,12 @@ dialog::backdrop {
.deckButton[data-sleeves="22"] { background-position: 85.7% 63%; }
.deckButton[data-sleeves="23"] { background-position: 100% 63%; }
.deckButton[data-sleeves="24"] { background-position: 0 89%; }
.deckButton[data-sleeves="25"] { background-position: 14.3% 89%; }
.deckButton[data-sleeves="26"] { background-position: 28.6% 89%; }
.deckButton[data-sleeves="27"] { background-position: 42.9% 89%; }
.deckButton[data-sleeves="28"] { background-position: 57.1% 89%; }
.deckButton[data-sleeves="29"] { background-position: 71.4% 89%; }
.deckButton[data-sleeves="30"] { background-position: 85.7% 89%; }
.deckButton:is(:active, .checked) {
outline-color: lightgrey;
@ -450,7 +456,13 @@ dialog::backdrop {
.cardBack[data-sleeves="21"] { background-position: 71.4% 66.7% }
.cardBack[data-sleeves="22"] { background-position: 85.7% 66.7% }
.cardBack[data-sleeves="23"] { background-position: 100% 66.7% }
.cardBack[data-sleeves="24"] { background-position: 0% 100%; }
.cardBack[data-sleeves="24"] { background-position: 0% 66.7% }
.cardBack[data-sleeves="25"] { background-position: 14.3% 100% }
.cardBack[data-sleeves="26"] { background-position: 28.6% 100% }
.cardBack[data-sleeves="27"] { background-position: 42.9% 100% }
.cardBack[data-sleeves="28"] { background-position: 57.1% 100% }
.cardBack[data-sleeves="29"] { background-position: 71.4% 100% }
.cardBack[data-sleeves="30"] { background-position: 85.7% 100% }
@keyframes cardBackFadeIn {
from {
@ -581,7 +593,7 @@ dialog::backdrop {
.cardSpecialPoint, .playHintSpecial {
display: inline-block;
color: transparent;
background: url('assets/SpecialOverlay.png') center/cover, var(--player-special-colour);
background: url('assets/SpecialOverlay.webp') center/cover, var(--player-special-colour);
width: 1ch;
height: 1ch;
vertical-align: middle;
@ -593,10 +605,10 @@ dialog::backdrop {
width: 1.5ch;
height: 1.5ch;
}
.playHintSpecial:nth-of-type(1):not(:last-of-type) { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-1); }
.playHintSpecial:nth-of-type(2) { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-2); }
.playHintSpecial:nth-of-type(3) { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-3); }
.playHintSpecial:nth-of-type(4) { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-4); }
.playHintSpecial:nth-of-type(1):not(:last-of-type) { background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-1); }
.playHintSpecial:nth-of-type(2) { background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-2); }
.playHintSpecial:nth-of-type(3) { background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-3); }
.playHintSpecial:nth-of-type(4) { background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-4); }
.card {
position: relative;
@ -636,6 +648,12 @@ svg.card text.cardDisplayName {
transform: scaleX(var(--scale));
}
#cardDisplayAssets {
position: absolute;
width: 0;
height: 0;
}
rect.Empty, rect.empty {
fill: #00000080;
stroke: #60606080;
@ -694,19 +712,19 @@ rect.special, g.specialCost rect {
}
#gameBoard td.Empty { background: #000000C0; outline: 1px solid #80808080; outline-offset: -1px; }
#gameBoard td.Wall { background: url('assets/Wall.png') center/cover, grey; }
#gameBoard td.Ink1 { background: url('assets/InkOverlay.png') center/cover, var(--primary-colour-1); }
#gameBoard td.Ink2 { background: url('assets/InkOverlay.png') center/cover, var(--primary-colour-2); }
#gameBoard td.Ink3 { background: url('assets/InkOverlay.png') center/cover, var(--primary-colour-3); }
#gameBoard td.Ink4 { background: url('assets/InkOverlay.png') center/cover, var(--primary-colour-4); }
#gameBoard td.SpecialInactive1 { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-1); }
#gameBoard td.SpecialInactive2 { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-2); }
#gameBoard td.SpecialInactive3 { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-3); }
#gameBoard td.SpecialInactive4 { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-4); }
#gameBoard td.SpecialActive1 { background: url('assets/SpecialOverlay.png') center/cover, radial-gradient(circle, var(--special-accent-colour-1) 25%, var(--special-colour-1) 75%); }
#gameBoard td.SpecialActive2 { background: url('assets/SpecialOverlay.png') center/cover, radial-gradient(circle, var(--special-accent-colour-2) 25%, var(--special-colour-2) 75%); }
#gameBoard td.SpecialActive3 { background: url('assets/SpecialOverlay.png') center/cover, radial-gradient(circle, var(--special-accent-colour-3) 25%, var(--special-colour-3) 75%); }
#gameBoard td.SpecialActive4 { background: url('assets/SpecialOverlay.png') center/cover, radial-gradient(circle, var(--special-accent-colour-4) 25%, var(--special-colour-4) 75%); }
#gameBoard td.Wall { background: url('assets/Wall.webp') center/cover, grey; }
#gameBoard td.Ink1 { background: url('assets/InkOverlay.webp') center/cover, var(--primary-colour-1); }
#gameBoard td.Ink2 { background: url('assets/InkOverlay.webp') center/cover, var(--primary-colour-2); }
#gameBoard td.Ink3 { background: url('assets/InkOverlay.webp') center/cover, var(--primary-colour-3); }
#gameBoard td.Ink4 { background: url('assets/InkOverlay.webp') center/cover, var(--primary-colour-4); }
#gameBoard td.SpecialInactive1 { background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-1); }
#gameBoard td.SpecialInactive2 { background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-2); }
#gameBoard td.SpecialInactive3 { background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-3); }
#gameBoard td.SpecialInactive4 { background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-4); }
#gameBoard td.SpecialActive1 { background: url('assets/SpecialOverlay.webp') center/cover, radial-gradient(circle, var(--special-accent-colour-1) 25%, var(--special-colour-1) 75%); }
#gameBoard td.SpecialActive2 { background: url('assets/SpecialOverlay.webp') center/cover, radial-gradient(circle, var(--special-accent-colour-2) 25%, var(--special-colour-2) 75%); }
#gameBoard td.SpecialActive3 { background: url('assets/SpecialOverlay.webp') center/cover, radial-gradient(circle, var(--special-accent-colour-3) 25%, var(--special-colour-3) 75%); }
#gameBoard td.SpecialActive4 { background: url('assets/SpecialOverlay.webp') center/cover, radial-gradient(circle, var(--special-accent-colour-4) 25%, var(--special-colour-4) 75%); }
#gameBoard.specialAttackVisual td:is(.Ink1, .Ink2, .Ink3, .Ink4) {
opacity: 0.5;
@ -806,6 +824,7 @@ rect.special, g.specialCost rect {
#cardList {
grid-row: 2;
grid-column: 1 / -1;
padding-bottom: 4rem;
}
.cardListControl {
display: grid;
@ -819,6 +838,14 @@ rect.special, g.specialCost rect {
grid-auto-rows: max-content;
}
#deckEditorRemoveButton {
position: absolute;
right: 1em;
bottom: 1em;
font-size: 100%;
padding: 0.5em;
}
/* Game page */
#gamePage:not([hidden]) {
@ -1273,10 +1300,10 @@ rect.special, g.specialCost rect {
.playerBar .specialPoint {
position: relative;
color: transparent;
background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour);
background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour);
}
.playerBar .specialPoint.specialAnimation {
background: url('assets/SpecialOverlay.png') center/cover, radial-gradient(circle, var(--special-accent-colour) 25%, var(--special-colour) 75%);
background: url('assets/SpecialOverlay.webp') center/cover, radial-gradient(circle, var(--special-accent-colour) 25%, var(--special-colour) 75%);
}
.playerBar .specialPoint.specialAnimation > div {
background: color-mix(in srgb, var(--special-colour), var(--special-accent-colour) 75%);
@ -1766,7 +1793,7 @@ button.dragging {
display: flex;
flex-flow: row wrap;
justify-content: center;
max-width: 80em;
max-width: 88em;
gap: 1em;
}
@ -1815,6 +1842,12 @@ button.dragging {
#deckSleevesList label:nth-of-type(23) { background-position: -600% -200%; }
#deckSleevesList label:nth-of-type(24) { background-position: -700% -200%; }
#deckSleevesList label:nth-of-type(25) { background-position: 0% -300%; }
#deckSleevesList label:nth-of-type(26) { background-position: -100% -300%; }
#deckSleevesList label:nth-of-type(27) { background-position: -200% -300%; }
#deckSleevesList label:nth-of-type(28) { background-position: -300% -300%; }
#deckSleevesList label:nth-of-type(29) { background-position: -400% -300%; }
#deckSleevesList label:nth-of-type(30) { background-position: -500% -300%; }
#deckSleevesList label:nth-of-type(31) { background-position: -600% -300%; }
/* Card list */
@ -1935,11 +1968,11 @@ button.dragging {
}
#galleryCardEditorGrid button[data-state="4"] {
border: 1px solid grey;
background: url('assets/InkOverlay.png') center/cover, var(--primary-colour-1);
background: url('assets/InkOverlay.webp') center/cover, var(--primary-colour-1);
}
#galleryCardEditorGrid button[data-state="8"] {
border: 1px solid grey;
background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-1);
background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-1);
}
.galleryCardEditorToolbar {
@ -1954,13 +1987,13 @@ button.dragging {
font-size: 60%;
}
.galleryCardEditorToolbar:nth-last-child(3) { bottom: 18%; }
.galleryCardEditorToolbar:nth-last-child(2) { bottom: 12%; }
.galleryCardEditorToolbar:nth-last-child(1) { bottom: 6%; }
#galleryCardEditorImageToolbar { bottom: 18%; }
#galleryCardEditorColoursToolbar { bottom: 12%; }
#galleryCardEditorRarityToolbar { bottom: 6%; }
#galleryCardEditorImageFile { display: none; }
#galleryCardEditorImageToolbar2 input {
#galleryCardEditorColoursToolbar input {
width: 3em;
}
@ -1969,23 +2002,23 @@ button.dragging {
}
#galleryCardEditorSpecialCost {
display: none;
display: grid;
position: absolute;
grid-template-columns: repeat(5, 1fr);
left: 26.8%;
top: 86.8%;
gap: 0.072em;
top: 86.2%;
gap: 0.15em 0.075em;
}
#galleryCardEditorSpecialCost button {
width: 1.7em;
height: 1.7em;
width: 2.5vh;
aspect-ratio: 1;
border: none;
background: #00000080;
position: relative;
}
#galleryCardEditorSpecialCost button.active {
background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-1);
background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-1);
}
#galleryCardEditorSpecialCost label {

View File

@ -10,7 +10,7 @@
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/bin/Debug/net6.0/TableturfBattleServer.dll",
"program": "${workspaceFolder}/bin/Debug/net8.0/TableturfBattleServer.dll",
"args": [],
"cwd": "${workspaceFolder}",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console

View File

@ -0,0 +1,14 @@
namespace TableturfBattleServer;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
internal class ApiEndpointAttribute(ApiEndpointNamespace endpointNamespace, string path, string allowedMethod) : Attribute {
public ApiEndpointNamespace Namespace { get; } = endpointNamespace;
public string Path { get; } = path;
public string AllowedMethod { get; } = allowedMethod;
public ApiEndpointAttribute(string path, string allowedMethod) : this(ApiEndpointNamespace.ApiRoot, path, allowedMethod) { }
}
internal enum ApiEndpointNamespace {
ApiRoot,
Game
}

View File

@ -0,0 +1,529 @@
#pragma warning disable IDE0060 // Remove unused parameter
using System.Net;
using TableturfBattleServer.DTO;
using HttpListenerRequest = WebSocketSharp.Net.HttpListenerRequest;
using HttpListenerResponse = WebSocketSharp.Net.HttpListenerResponse;
namespace TableturfBattleServer;
internal static class ApiEndpoints {
internal static readonly char[] DELIMITERS = [',', ' '];
internal const int CUSTOM_CARD_START = -10000;
internal const int RECEIVED_CUSTOM_CARD_START = -20000;
[ApiEndpoint("/games/new", "POST")]
public static void ApiGamesNew(HttpListenerRequest request, HttpListenerResponse response) {
if (request.HttpMethod != "POST") {
response.AddHeader("Allow", "POST");
response.SetErrorResponse(new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
} else if (Server.Instance.Lockdown) {
response.SetErrorResponse(new(HttpStatusCode.ServiceUnavailable, "ServerLocked", "The server is temporarily locked for an update. Please try again soon."));
} else if (request.ContentLength64 >= 65536) {
response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
} else {
try {
var d = HttpRequestHelper.DecodeFormData(request.InputStream);
Guid clientToken;
if (!d.TryGetValue("name", out var name)) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Missing name."));
return;
}
if (name.Length > 32) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Name is too long."));
return;
}
var maxPlayers = 2;
if (d.TryGetValue("maxPlayers", out var maxPlayersString)) {
if (!int.TryParse(maxPlayersString, out maxPlayers) || maxPlayers < 2 || maxPlayers > 4) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidMaxPlayers", "Invalid player limit."));
return;
}
}
int? turnTimeLimit = null;
if (d.TryGetValue("turnTimeLimit", out var turnTimeLimitString) && turnTimeLimitString != "") {
if (!int.TryParse(turnTimeLimitString, out var turnTimeLimit2) || turnTimeLimit2 < 10) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidTurnTimeLimit", "Invalid turn time limit."));
return;
}
turnTimeLimit = turnTimeLimit2;
}
int? goalWinCount = null;
if (d.TryGetValue("goalWinCount", out var goalWinCountString) && goalWinCountString != "") {
if (!int.TryParse(goalWinCountString, out var goalWinCount2) || goalWinCount2 < 1) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGoalWinCount", "Invalid goal win count."));
return;
}
goalWinCount = goalWinCount2;
}
if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") {
if (!Guid.TryParse(tokenString, out clientToken)) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidClientToken", "Invalid client token."));
return;
}
} else
clientToken = Guid.NewGuid();
bool allowUpcomingCards;
if (d.TryGetValue("allowUpcomingCards", out var allowUpcomingCardsString)) {
if (!bool.TryParse(allowUpcomingCardsString, out allowUpcomingCards))
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "allowUpcomingCards was invalid."));
} else
allowUpcomingCards = true;
bool allowCustomCards;
if (d.TryGetValue("allowCustomCards", out var allowCustomCardsString)) {
if (!bool.TryParse(allowCustomCardsString, out allowCustomCards))
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "allowCustomCards was invalid."));
} else
allowCustomCards = false;
StageSelectionRules? stageSelectionRuleFirst = null, stageSelectionRuleAfterWin = null, stageSelectionRuleAfterDraw = null;
if (d.TryGetValue("stageSelectionRuleFirst", out var json1)) {
if (!HttpRequestHelper.TryParseStageSelectionRule(json1, maxPlayers, out stageSelectionRuleFirst) || stageSelectionRuleFirst.Method is StageSelectionMethod.Same or StageSelectionMethod.Counterpick) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "stageSelectionRuleFirst was invalid."));
return;
}
} else
stageSelectionRuleFirst = StageSelectionRules.Default;
if (d.TryGetValue("stageSelectionRuleAfterWin", out var json2)) {
if (!HttpRequestHelper.TryParseStageSelectionRule(json2, maxPlayers, out stageSelectionRuleAfterWin)) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "stageSelectionRuleAfterWin was invalid."));
return;
}
} else
stageSelectionRuleAfterWin = stageSelectionRuleFirst;
if (d.TryGetValue("stageSelectionRuleAfterDraw", out var json3)) {
if (!HttpRequestHelper.TryParseStageSelectionRule(json3, maxPlayers, out stageSelectionRuleAfterDraw) || stageSelectionRuleAfterDraw.Method == StageSelectionMethod.Counterpick) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "stageSelectionRuleAfterDraw was invalid."));
return;
}
} else
stageSelectionRuleAfterDraw = stageSelectionRuleFirst;
var forceSameDeckAfterDraw = false;
if (d.TryGetValue("forceSameDeckAfterDraw", out var forceSameDeckAfterDrawString)) {
if (!bool.TryParse(forceSameDeckAfterDrawString, out forceSameDeckAfterDraw))
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "forceSameDeckAfterDraw was invalid."));
} else
forceSameDeckAfterDraw = false;
var spectate = false;
if (d.TryGetValue("spectate", out var spectateString)) {
if (!bool.TryParse(spectateString, out spectate))
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "spectate was invalid."));
} else
spectate = false;
var game = new Game(maxPlayers) { HostClientToken = clientToken, GoalWinCount = goalWinCount, TurnTimeLimit = turnTimeLimit, AllowUpcomingCards = allowUpcomingCards, AllowCustomCards = allowCustomCards, StageSelectionRuleFirst = stageSelectionRuleFirst, StageSelectionRuleAfterWin = stageSelectionRuleAfterWin, StageSelectionRuleAfterDraw = stageSelectionRuleAfterDraw, ForceSameDeckAfterDraw = forceSameDeckAfterDraw };
if (!spectate)
game.TryAddPlayer(new(game, name, clientToken), out _, out _);
Server.Instance.games.Add(game.ID, game);
Server.Instance.timer.Start();
response.SetResponse(HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { gameID = game.ID, clientToken, maxPlayers }));
Console.WriteLine($"New game started: {game.ID}; {Server.Instance.games.Count} games active; {Server.Instance.inactiveGames.Count} inactive");
} catch (ArgumentException) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data"));
}
}
}
[ApiEndpoint("/cards", "GET")]
public static void ApiCards(HttpListenerRequest request, HttpListenerResponse response)
=> HttpRequestHelper.SetStaticResponse(request, response, CardDatabase.JSON, CardDatabase.Version.ToString(), CardDatabase.LastModified);
[ApiEndpoint("/stages", "GET")]
public static void ApiStages(HttpListenerRequest request, HttpListenerResponse response)
=> HttpRequestHelper.SetStaticResponse(request, response, StageDatabase.JSON, StageDatabase.Version.ToString(), StageDatabase.LastModified);
[ApiEndpoint(ApiEndpointNamespace.Game, "/", "GET")]
public static void ApiGameRoot(Game game, HttpListenerRequest request, HttpListenerResponse response)
=> response.SetResponse(HttpStatusCode.OK, "application/json", JsonUtils.Serialise(game));
[ApiEndpoint(ApiEndpointNamespace.Game, "/playerData", "GET")]
public static void ApiGamePlayerData(Game game, HttpListenerRequest request, HttpListenerResponse response) {
if (request.QueryString["clientToken"] is not string s || !Guid.TryParse(s, out var clientToken))
clientToken = Guid.Empty;
response.SetResponse(HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new {
game,
playerData = game.GetPlayer(clientToken, out var playerIndex, out var player)
? new PlayerData(playerIndex, player)
: null
}));
}
[ApiEndpoint(ApiEndpointNamespace.Game, "/join", "POST")]
public static void ApiGameJoin(Game game, HttpListenerRequest request, HttpListenerResponse response) {
try {
var d = HttpRequestHelper.DecodeFormData(request.InputStream);
Guid clientToken;
if (!d.TryGetValue("name", out var name)) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Missing name."));
return;
}
if (name.Length > 32) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Name is too long."));
return;
}
if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") {
if (!Guid.TryParse(tokenString, out clientToken)) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
return;
}
} else
clientToken = Guid.NewGuid();
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
if (game.State != GameState.WaitingForPlayers) {
response.SetErrorResponse(new(HttpStatusCode.Gone, "GameAlreadyStarted", "The game has already started."));
return;
}
player = new Player(game, name, clientToken);
if (!game.TryAddPlayer(player, out playerIndex, out var error)) {
response.SetErrorResponse(error);
return;
}
game.SendEvent("join", new { playerIndex, player }, false);
}
// If they're already in the game, resend the original join response instead of an error.
response.SetResponse(HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { playerIndex, clientToken }));
Server.Instance.timer.Start();
} catch (ArgumentException) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data"));
}
}
[ApiEndpoint(ApiEndpointNamespace.Game, "/setGameSettings", "POST")]
public static void ApiGameSetGameSettings(Game game, HttpListenerRequest request, HttpListenerResponse response) {
var d = HttpRequestHelper.DecodeFormData(request.InputStream);
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
return;
}
if (clientToken != game.HostClientToken) {
response.SetErrorResponse(new(HttpStatusCode.Forbidden, "AccessDenied", "Only the host can do that."));
return;
}
if (game.State != GameState.WaitingForPlayers) {
response.SetErrorResponse(new(HttpStatusCode.Gone, "GameAlreadyStarted", "The game has already started."));
return;
}
if (d.TryGetValue("turnTimeLimit", out var turnTimeLimitString)) {
if (turnTimeLimitString == "")
game.TurnTimeLimit = null;
else if (!int.TryParse(turnTimeLimitString, out var turnTimeLimit2) || turnTimeLimit2 < 10) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "Invalid turn time limit."));
return;
} else
game.TurnTimeLimit = turnTimeLimit2;
}
if (d.TryGetValue("allowUpcomingCards", out var allowUpcomingCardsString)) {
if (!bool.TryParse(allowUpcomingCardsString, out var allowUpcomingCards)) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "Invalid allowUpcomingCards."));
return;
} else
game.AllowUpcomingCards = allowUpcomingCards;
}
if (d.TryGetValue("allowCustomCards", out var allowCustomCardsString)) {
if (!bool.TryParse(allowCustomCardsString, out var allowCustomCards)) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "Invalid allowCustomCards."));
return;
} else
game.AllowCustomCards = allowCustomCards;
}
game.SendEvent("settingsChange", game, false);
}
[ApiEndpoint(ApiEndpointNamespace.Game, "/chooseStage", "POST")]
public static void ApiGameChooseStage(Game game, HttpListenerRequest request, HttpListenerResponse response) {
var d = HttpRequestHelper.DecodeFormData(request.InputStream);
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
return;
}
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "NotInGame", "You're not in the game."));
return;
}
if (!d.TryGetValue("stages", out var stagesString)) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidStage", "Missing stages."));
return;
}
var stages = new HashSet<int>();
foreach (var field in stagesString.Split(DELIMITERS, StringSplitOptions.RemoveEmptyEntries)) {
if (!int.TryParse(field, out var i)) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidStage", "Invalid stages."));
return;
}
stages.Add(i);
}
if (!game.TryChooseStages(player, stages, out var error)) {
response.SetErrorResponse(error);
return;
}
response.StatusCode = (int) HttpStatusCode.NoContent;
game.SendPlayerReadyEvent(playerIndex, false);
Server.Instance.timer.Start();
}
[ApiEndpoint(ApiEndpointNamespace.Game, "/chooseDeck", "POST")]
public static void ApiGameChooseDeck(Game game, HttpListenerRequest request, HttpListenerResponse response) {
try {
var d = HttpRequestHelper.DecodeFormData(request.InputStream);
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
return;
}
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "NotInGame", "You're not in the game."));
return;
}
if (player.CurrentGameData.Deck != null) {
response.SetErrorResponse(new(HttpStatusCode.Conflict, "DeckAlreadyChosen", "You've already chosen a deck."));
return;
}
if (!d.TryGetValue("deckName", out var deckName)) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidDeckName", "Missing deck name."));
return;
}
var deckSleeves = 0;
if (d.TryGetValue("deckSleeves", out var deckSleevesString) && !int.TryParse(deckSleevesString, out deckSleeves)) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidDeckSleeves", "Invalid deck sleeves."));
return;
}
if (!d.TryGetValue("deckCards", out var deckString)) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidDeckCards", "Missing deck cards."));
return;
}
Dictionary<int, UserCustomCard>? userCustomCards = null;
List<KeyValuePair<int, Card>>? customCardsToAdd = null;
if (d.TryGetValue("customCards", out var customCardsString)) {
if (!game.AllowCustomCards) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "CustomCardsNotAllowed", "Custom cards cannot be used in this game."));
return;
}
userCustomCards = JsonUtils.Deserialise<Dictionary<int, UserCustomCard>>(customCardsString);
// Validate custom cards.
if (userCustomCards is null || userCustomCards.Count > 15 || userCustomCards.Keys.Any(k => k is not (<= -10000 and >= short.MinValue))) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidCustomCards", "Invalid custom cards."));
return;
}
customCardsToAdd = new(userCustomCards.Count);
foreach (var (k, v) in userCustomCards) {
if (!v.CheckGrid(out var hasSpecialSpace, out var size)) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidCustomCards", $"Custom card {k} is invalid."));
return;
}
// Allow resending the same custom card, but not a different custom card with the same key.
if (player.customCardMap.TryGetValue(k, out var existingCustomCardNumber)) {
if (!v.Equals(game.customCards[RECEIVED_CUSTOM_CARD_START - existingCustomCardNumber])) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidCustomCards", $"Cannot reuse custom card number {k}."));
return;
}
} else {
// TODO: Consolidate identical custom cards brought by different players.
var card = v.ToCard(RECEIVED_CUSTOM_CARD_START - (game.customCards.Count + customCardsToAdd.Count), k, !hasSpecialSpace && size >= 8 ? 3 : null);
customCardsToAdd.Add(new(k, card));
}
}
}
var array = deckString.Split([',', '+', ' '], 15);
if (array.Length != 15) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Invalid deck list."));
return;
}
int[]? upgrades = null;
if (d.TryGetValue("deckUpgrades", out var deckUpgradesString)) {
upgrades = new int[15];
var array2 = deckUpgradesString.Split([',', '+', ' '], 15);
for (var i = 0; i < 15; i++) {
if (int.TryParse(array2[i], out var j) && i is >= 0 and <= 2)
upgrades[i] = j;
else {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidDeckUpgrades", "Invalid deck upgrade list."));
return;
}
}
}
var cards = new int[15];
for (var i = 0; i < 15; i++) {
if (!int.TryParse(array[i], out var cardNumber) || (!CardDatabase.IsValidCardNumber(cardNumber) && (userCustomCards == null || !userCustomCards.ContainsKey(cardNumber)))) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Invalid deck list."));
return;
}
if (Array.IndexOf(cards, cardNumber, 0, i) >= 0) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Deck cannot have duplicates."));
return;
}
if (!game.AllowUpcomingCards && cardNumber is < 0 and > CUSTOM_CARD_START && CardDatabase.GetCard(cardNumber).Number < 0) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "ForbiddenDeck", "Upcoming cards cannot be used in this game."));
return;
}
// Translate custom card numbers from the player to game-scoped card numbers.
cards[i] = player.customCardMap.TryGetValue(cardNumber, out var n) ? n : cardNumber <= CUSTOM_CARD_START && customCardsToAdd?.FirstOrDefault(e => e.Key == cardNumber).Value is Card customCard ? customCard.Number : cardNumber;
}
if (customCardsToAdd != null) {
foreach (var (userKey, card) in customCardsToAdd) {
player.customCardMap.Add(userKey, card.Number);
game.customCards.Add(card);
}
}
player.CurrentGameData.Deck = game.GetDeck(deckName, deckSleeves, cards, upgrades ?? Enumerable.Repeat(0, 15));
response.StatusCode = (int) HttpStatusCode.NoContent;
game.SendPlayerReadyEvent(playerIndex, false);
Server.Instance.timer.Start();
} catch (ArgumentException) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data"));
}
}
[ApiEndpoint(ApiEndpointNamespace.Game, "/play", "POST")]
public static void ApiGamePlay(Game game, HttpListenerRequest request, HttpListenerResponse response) {
try {
var d = HttpRequestHelper.DecodeFormData(request.InputStream);
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
return;
}
if (game.State != GameState.Ongoing) {
response.SetErrorResponse(new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state."));
return;
}
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
response.SetErrorResponse(new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game."));
return;
}
if (player.Move != null) {
response.SetErrorResponse(new(HttpStatusCode.Conflict, "MoveAlreadyChosen", "You've already chosen a move."));
return;
}
if (!d.TryGetValue("cardNumber", out var cardNumberStr) || !int.TryParse(cardNumberStr, out var cardNumber)) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidCard", "Missing or invalid card number."));
return;
}
var handIndex = player.GetHandIndex(cardNumber);
if (handIndex < 0) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "MissingCard", "You don't have that card."));
return;
}
var isTimeout = d.TryGetValue("isTimeout", out var isTimeoutStr) && isTimeoutStr.ToLower() is not ("false" or "0");
var card = player.Hand![handIndex];
if (d.TryGetValue("isPass", out var isPassStr) && isPassStr.ToLower() is not ("false" or "0")) {
player.Move = new(card, true, 0, 0, 0, false, isTimeout);
} else {
var isSpecialAttack = d.TryGetValue("isSpecialAttack", out var isSpecialAttackStr) && isSpecialAttackStr.ToLower() is not ("false" or "0");
if (!d.TryGetValue("x", out var xs) || !int.TryParse(xs, out var x)
|| !d.TryGetValue("y", out var ys) || !int.TryParse(ys, out var y)
|| !d.TryGetValue("r", out var rs) || !int.TryParse(rs, out var r)) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidPosition", "Missing or invalid position."));
return;
}
r &= 3;
if (!game.CanPlay(playerIndex, card, x, y, r, isSpecialAttack)) {
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "IllegalMove", "Illegal move"));
return;
}
player.Move = new(card, false, x, y, r, isSpecialAttack, isTimeout);
}
response.StatusCode = (int) HttpStatusCode.NoContent;
game.SendPlayerReadyEvent(playerIndex, isTimeout);
Server.Instance.timer.Start();
} catch (ArgumentException) {
response.StatusCode = (int) HttpStatusCode.BadRequest;
}
}
[ApiEndpoint(ApiEndpointNamespace.Game, "/redraw", "POST")]
public static void ApiGameRedraw(Game game, HttpListenerRequest request, HttpListenerResponse response) {
try {
if (game.State != GameState.Redraw) {
response.SetErrorResponse(new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state."));
return;
}
var d = HttpRequestHelper.DecodeFormData(request.InputStream);
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
return;
}
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
response.SetErrorResponse(new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game."));
return;
}
if (player.Move != null) {
response.SetErrorResponse(new(HttpStatusCode.Conflict, "MoveAlreadyChosen", "You've already chosen a move."));
return;
}
var redraw = d.TryGetValue("redraw", out var redrawStr) && redrawStr.ToLower() is not ("false" or "0");
player.Move = new(player.Hand![0], false, 0, 0, 0, redraw, false);
response.StatusCode = (int) HttpStatusCode.NoContent;
game.SendPlayerReadyEvent(playerIndex, false);
Server.Instance.timer.Start();
} catch (ArgumentException) {
response.StatusCode = (int) HttpStatusCode.BadRequest;
}
}
[ApiEndpoint(ApiEndpointNamespace.Game, "/nextGame", "POST")]
public static void ApiGameNextGame(Game game, HttpListenerRequest request, HttpListenerResponse response) {
try {
if (game.State is not (GameState.GameEnded or GameState.SetEnded)) {
response.SetErrorResponse(new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state."));
return;
}
var d = HttpRequestHelper.DecodeFormData(request.InputStream);
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
return;
}
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
response.SetErrorResponse(new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game."));
return;
}
if (player.Move == null) {
player.Move = new(player.Hand![0], false, 0, 0, 0, false, false); // Dummy move to indicate that the player is ready.
game.SendPlayerReadyEvent(playerIndex, false);
}
response.StatusCode = (int) HttpStatusCode.NoContent;
Server.Instance.timer.Start();
} catch (ArgumentException) {
response.StatusCode = (int) HttpStatusCode.BadRequest;
}
}
[ApiEndpoint(ApiEndpointNamespace.Game, "/replay", "GET")]
public static void ApiGameReplay(Game game, HttpListenerRequest request, HttpListenerResponse response) {
if (game.State != GameState.SetEnded) {
response.SetErrorResponse(new(HttpStatusCode.Conflict, "GameInProgress", "You can't see the replay until the set has ended."));
return;
}
var ms = new MemoryStream();
game.WriteReplayData(ms);
response.SetResponse(HttpStatusCode.OK, "application/octet-stream", ms.ToArray());
}
}

View File

@ -58,7 +58,6 @@ public class Card {
}
}
this.Size = size;
this.SpecialCost = specialCost ?? size switch { <= 3 => 1, <= 5 => 2, <= 8 => 3, <= 11 => 4, <= 15 => 5, _ => 6 };
}

File diff suppressed because it is too large Load Diff

View File

@ -24,7 +24,7 @@ public class PlayerData(int playerIndex, Card[]? hand, Deck? deck, Move? move, L
public PlayerData(int playerIndex, Player player) : this(playerIndex, player.Hand, player.CurrentGameData.Deck, player.Move, player.CardsUsed, player.StageSelectionPrompt) { }
}
public record UserCustomCard(string Name, string? Line1, string? Line2, Colour InkColour1, Colour InkColour2, Rarity Rarity, Space[,] Grid) {
public record UserCustomCard(string Name, string? Line1, string? Line2, int? SpecialCost, Colour InkColour1, Colour InkColour2, Rarity Rarity, Space[,] Grid) {
public bool CheckGrid(out bool hasSpecialSpace, out int size) {
size = 0;
hasSpecialSpace = false;
@ -52,7 +52,7 @@ public record UserCustomCard(string Name, string? Line1, string? Line2, Colour I
}
public bool Equals(Card? card) {
if (card is null || card.Name != this.Name || card.Rarity != this.Rarity) return false;
if (card is null || card.Name != this.Name || card.Rarity != this.Rarity || card.SpecialCost != this.SpecialCost) return false;
for (var x = 0; x < 8; x++) {
for (var y = 0; y < 8; y++) {
if (this.Grid[x, y] != card.GetSpace(x, y, 0)) return false;
@ -61,5 +61,5 @@ public record UserCustomCard(string Name, string? Line1, string? Line2, Colour I
return true;
}
public Card ToCard(int number, int altNumber, int? specialCost) => new(number, altNumber, this.Line2 != null ? $"{this.Line1}\n{this.Line2}" : this.Name, this.Rarity, specialCost, null, this.Grid) { InkColour1 = this.InkColour1, InkColour2 = this.InkColour2 };
public Card ToCard(int number, int altNumber, int? defaultSpecialCost) => new(number, altNumber, this.Line2 != null ? $"{this.Line1}\n{this.Line2}" : this.Name, this.Rarity, this.SpecialCost ?? defaultSpecialCost, null, this.Grid) { InkColour1 = this.InkColour1, InkColour2 = this.InkColour2 };
}

View File

@ -137,7 +137,7 @@ public class Game(int maxPlayers) {
public Deck GetDeck(string name, int sleeves, IEnumerable<int> cardNumbers, IEnumerable<int> cardUpgrades) {
var deck = this.deckCache.FirstOrDefault(d => d.Name == name && d.Sleeves == sleeves && cardNumbers.SequenceEqual(from c in d.Cards select c.Number) && cardUpgrades.SequenceEqual(d.Upgrades));
if (deck == null) {
deck = new(name, sleeves, (from i in cardNumbers select i <= Program.RECEIVED_CUSTOM_CARD_START ? customCards[Program.RECEIVED_CUSTOM_CARD_START - i] : CardDatabase.GetCard(i)).ToArray(), cardUpgrades.ToArray());
deck = new(name, sleeves, (from i in cardNumbers select i <= ApiEndpoints.RECEIVED_CUSTOM_CARD_START ? customCards[ApiEndpoints.RECEIVED_CUSTOM_CARD_START - i] : CardDatabase.GetCard(i)).ToArray(), cardUpgrades.ToArray());
this.deckCache.Add(deck);
}
return deck;
@ -643,7 +643,7 @@ public class Game(int maxPlayers) {
}
public void WriteReplayData(Stream stream) {
const int VERSION = 4;
const int VERSION = 5;
if (this.State < GameState.SetEnded)
throw new InvalidOperationException("Can't save a replay until the set has ended.");

View File

@ -0,0 +1,81 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Net;
using System.Text;
using System.Web;
using Newtonsoft.Json;
using HttpListenerRequest = WebSocketSharp.Net.HttpListenerRequest;
using HttpListenerResponse = WebSocketSharp.Net.HttpListenerResponse;
namespace TableturfBattleServer;
internal static class HttpRequestHelper {
internal static readonly char[] DELIMITERS = [',', ' '];
internal const int CUSTOM_CARD_START = -10000;
internal const int RECEIVED_CUSTOM_CARD_START = -20000;
internal static void SetErrorResponse(this HttpListenerResponse response, Error error) {
var bytes = Encoding.UTF8.GetBytes(JsonUtils.Serialise(error));
SetResponse(response, error.HttpStatusCode, "application/json", bytes);
}
internal static void SetResponse(this HttpListenerResponse response, HttpStatusCode statusCode, string contentType, string content) {
var bytes = Encoding.UTF8.GetBytes(content);
SetResponse(response, statusCode, contentType, bytes);
}
internal static void SetResponse(this HttpListenerResponse response, HttpStatusCode statusCode, string contentType, byte[] content) {
response.StatusCode = (int) statusCode;
response.ContentType = contentType;
response.ContentLength64 = content.Length;
response.Close(content, true);
}
internal static Dictionary<string, string> DecodeFormData(Stream stream) => DecodeFormData(new StreamReader(stream).ReadToEnd());
internal static Dictionary<string, string> DecodeFormData(TextReader reader) => DecodeFormData(reader.ReadToEnd());
internal static Dictionary<string, string> DecodeFormData(string s) {
if (s.StartsWith('?')) s = s[1..];
return s != ""
? s.Split(['&']).Select(s => s.Split('=')).Select(a => a.Length == 2 ? a : throw new ArgumentException("Invalid form data"))
.ToDictionary(a => HttpUtility.UrlDecode(a[0]), a => HttpUtility.UrlDecode(a[1]))
: [];
}
internal static void SetStaticResponse(HttpListenerRequest request, HttpListenerResponse response, string jsonContent, string eTag, DateTime lastModified) {
if (request.HttpMethod is not ("GET" or "HEAD")) {
response.AddHeader("Allow", "GET, HEAD");
SetErrorResponse(response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
return;
}
response.AppendHeader("Cache-Control", "max-age=86400");
response.AppendHeader("ETag", eTag);
response.AppendHeader("Last-Modified", lastModified.ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\""));
var ifNoneMatch = request.Headers["If-None-Match"];
if (ifNoneMatch != null) {
if (request.Headers["If-None-Match"] == eTag)
response.StatusCode = (int) HttpStatusCode.NotModified;
else
SetResponse(response, HttpStatusCode.OK, "application/json", jsonContent);
} else {
if (DateTime.TryParseExact(request.Headers["If-Modified-Since"], "ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dateTime)
&& dateTime >= lastModified.ToUniversalTime())
response.StatusCode = (int) HttpStatusCode.NotModified;
else
SetResponse(response, HttpStatusCode.OK, "application/json", jsonContent);
}
}
internal static bool TryParseStageSelectionRule(string json, int maxPlayers, [MaybeNullWhen(false)] out StageSelectionRules stageSelectionRule) {
try {
stageSelectionRule = JsonUtils.Deserialise<StageSelectionRules>(json);
if (stageSelectionRule == null) return false;
stageSelectionRule.AddUnavailableStages(maxPlayers);
// Check that at least one stage is allowed.
for (var i = 0; i < StageDatabase.Stages.Count; i++) {
if (!stageSelectionRule.BannedStages.Contains(i)) return true;
}
return false;
} catch (JsonSerializationException) {
stageSelectionRule = null;
return false;
}
}
}

View File

@ -1,33 +1,21 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Net;
using System.Net;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using System.Timers;
using System.Web;
using Newtonsoft.Json;
using TableturfBattleServer.DTO;
using WebSocketSharp.Server;
using HttpListenerRequest = WebSocketSharp.Net.HttpListenerRequest;
using HttpListenerResponse = WebSocketSharp.Net.HttpListenerResponse;
using Timer = System.Timers.Timer;
namespace TableturfBattleServer;
internal delegate void ApiEndpointGlobalHandler(HttpListenerRequest request, HttpListenerResponse response);
internal delegate void ApiEndpointGameHandler(Game game, HttpListenerRequest request, HttpListenerResponse response);
internal partial class Program {
internal static HttpServer? httpServer;
internal static Dictionary<Guid, Game> games = [];
internal static Dictionary<Guid, Game> inactiveGames = [];
internal static readonly Timer timer = new(1000);
private static bool lockdown;
private const int InactiveGameLimit = 1000;
private static readonly TimeSpan InactiveGameTimeout = TimeSpan.FromMinutes(5);
internal static readonly char[] DELIMITERS = [',', ' '];
internal const int CUSTOM_CARD_START = -10000;
internal const int RECEIVED_CUSTOM_CARD_START = -20000;
private static readonly Dictionary<string, (ApiEndpointAttribute attribute, ApiEndpointGlobalHandler handler)> apiGlobalHandlers = [];
private static readonly Dictionary<string, (ApiEndpointAttribute attribute, ApiEndpointGameHandler handler)> apiGameHandlers = [];
private static readonly HashSet<string> spaPaths = [ "/", "/deckeditor", "/cardlist", "/game" , "/replay" ];
private static string? GetClientRootPath() {
var directory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
@ -40,9 +28,16 @@ internal partial class Program {
}
internal static void Main(string[] args) {
httpServer = new(args.Contains("--open") ? IPAddress.Any : IPAddress.Loopback, 3333) { DocumentRootPath = GetClientRootPath() };
foreach (var method in typeof(ApiEndpoints).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)) {
var attribute = method.GetCustomAttribute<ApiEndpointAttribute>();
if (attribute == null) continue;
if (attribute.Namespace == ApiEndpointNamespace.ApiRoot)
apiGlobalHandlers[attribute.Path] = (attribute, method.CreateDelegate<ApiEndpointGlobalHandler>());
else
apiGameHandlers[attribute.Path] = (attribute, method.CreateDelegate<ApiEndpointGameHandler>());
}
timer.Elapsed += Timer_Elapsed;
httpServer = new(args.Contains("--open") ? IPAddress.Any : IPAddress.Loopback, 3333) { DocumentRootPath = GetClientRootPath() };
httpServer.AddWebSocketService<TableturfWebSocketBehaviour>("/api/websocket");
httpServer.OnGet += HttpServer_OnRequest;
@ -61,727 +56,95 @@ internal partial class Program {
else {
s = s.Trim().ToLower();
if (s == "update") {
if (games.Count == 0)
if (Server.Instance.games.Count == 0)
Environment.Exit(2);
lockdown = true;
Server.Instance.Lockdown = true;
Console.WriteLine("Locking server for update.");
}
}
}
}
private static void Timer_Elapsed(object? sender, ElapsedEventArgs e) {
lock (games) {
foreach (var (id, game) in games) {
lock (game.Players) {
game.Tick();
if (DateTime.UtcNow - game.abandonedSince >= InactiveGameTimeout) {
games.Remove(id);
inactiveGames.Add(id, game);
Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive");
if (lockdown && games.Count == 0)
Environment.Exit(2);
}
}
}
if (inactiveGames.Count >= InactiveGameLimit) {
foreach (var (k, _) in inactiveGames.Select(e => (e.Key, e.Value.abandonedSince)).OrderBy(e => e.abandonedSince).Take(InactiveGameLimit / 2))
inactiveGames.Remove(k);
Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive");
}
}
}
private static void HttpServer_OnRequest(object? sender, HttpRequestEventArgs e) {
e.Response.AppendHeader("Access-Control-Allow-Origin", "*");
if (!e.Request.RawUrl.StartsWith("/api/")) {
var path = e.Request.RawUrl == "/" || e.Request.RawUrl.StartsWith("/deckeditor") || e.Request.RawUrl.StartsWith("/cardlist") || e.Request.RawUrl.StartsWith("/game/") || e.Request.RawUrl.StartsWith("/replay/")
? "index.html"
: HttpUtility.UrlDecode(e.Request.RawUrl[1..]);
if (e.TryReadFile(path, out var bytes))
SetResponse(e.Response, HttpStatusCode.OK,
Path.GetExtension(path) switch {
".html" or ".htm" => "text/html",
".css" => "text/css",
".js" => "text/javascript",
".png" => "image/png",
".webp" => "image/webp",
".woff" or ".woff2" => "font/woff",
_ => "application/octet-stream"
}, bytes);
else
SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "NotFound", "File not found."));
if (!e.Request.RawUrl.StartsWith('/')) {
e.Response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidRequestUrl", "Invalid request URL."));
return;
} else if (e.Request.RawUrl == "/api/games/new") {
if (e.Request.HttpMethod != "POST") {
e.Response.AddHeader("Allow", "POST");
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
} else if (lockdown) {
SetErrorResponse(e.Response, new(HttpStatusCode.ServiceUnavailable, "ServerLocked", "The server is temporarily locked for an update. Please try again soon."));
} else if (e.Request.ContentLength64 >= 65536) {
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
} else {
try {
var d = DecodeFormData(e.Request.InputStream);
Guid clientToken;
if (!d.TryGetValue("name", out var name)) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Missing name."));
return;
}
if (name.Length > 32) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Name is too long."));
return;
}
var maxPlayers = 2;
if (d.TryGetValue("maxPlayers", out var maxPlayersString)) {
if (!int.TryParse(maxPlayersString, out maxPlayers) || maxPlayers < 2 || maxPlayers > 4) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidMaxPlayers", "Invalid player limit."));
return;
}
}
int? turnTimeLimit = null;
if (d.TryGetValue("turnTimeLimit", out var turnTimeLimitString) && turnTimeLimitString != "") {
if (!int.TryParse(turnTimeLimitString, out var turnTimeLimit2) || turnTimeLimit2 < 10) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidTurnTimeLimit", "Invalid turn time limit."));
return;
}
turnTimeLimit = turnTimeLimit2;
}
int? goalWinCount = null;
if (d.TryGetValue("goalWinCount", out var goalWinCountString) && goalWinCountString != "") {
if (!int.TryParse(goalWinCountString, out var goalWinCount2) || goalWinCount2 < 1) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGoalWinCount", "Invalid goal win count."));
return;
}
goalWinCount = goalWinCount2;
}
if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") {
if (!Guid.TryParse(tokenString, out clientToken)) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidClientToken", "Invalid client token."));
return;
}
} else
clientToken = Guid.NewGuid();
}
bool allowUpcomingCards;
if (d.TryGetValue("allowUpcomingCards", out var allowUpcomingCardsString)) {
if (!bool.TryParse(allowUpcomingCardsString, out allowUpcomingCards))
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "allowUpcomingCards was invalid."));
} else
allowUpcomingCards = true;
bool allowCustomCards;
if (d.TryGetValue("allowCustomCards", out var allowCustomCardsString)) {
if (!bool.TryParse(allowCustomCardsString, out allowCustomCards))
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "allowCustomCards was invalid."));
} else
allowCustomCards = false;
StageSelectionRules? stageSelectionRuleFirst = null, stageSelectionRuleAfterWin = null, stageSelectionRuleAfterDraw = null;
if (d.TryGetValue("stageSelectionRuleFirst", out var json1)) {
if (!TryParseStageSelectionRule(json1, maxPlayers, 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, maxPlayers, 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, maxPlayers, out stageSelectionRuleAfterDraw) || stageSelectionRuleAfterDraw.Method == StageSelectionMethod.Counterpick) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "stageSelectionRuleAfterDraw was invalid."));
return;
}
} else
stageSelectionRuleAfterDraw = stageSelectionRuleFirst;
var forceSameDeckAfterDraw = false;
if (d.TryGetValue("forceSameDeckAfterDraw", out var forceSameDeckAfterDrawString)) {
if (!bool.TryParse(forceSameDeckAfterDrawString, out forceSameDeckAfterDraw))
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "forceSameDeckAfterDraw was invalid."));
} else
forceSameDeckAfterDraw = false;
var spectate = false;
if (d.TryGetValue("spectate", out var spectateString)) {
if (!bool.TryParse(spectateString, out spectate))
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "spectate was invalid."));
} else
spectate = false;
var game = new Game(maxPlayers) { HostClientToken = clientToken, GoalWinCount = goalWinCount, TurnTimeLimit = turnTimeLimit, AllowUpcomingCards = allowUpcomingCards, AllowCustomCards = allowCustomCards, StageSelectionRuleFirst = stageSelectionRuleFirst, StageSelectionRuleAfterWin = stageSelectionRuleAfterWin, StageSelectionRuleAfterDraw = stageSelectionRuleAfterDraw, ForceSameDeckAfterDraw = forceSameDeckAfterDraw };
if (!spectate)
game.TryAddPlayer(new(game, name, clientToken), out _, out _);
games.Add(game.ID, game);
timer.Start();
SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { gameID = game.ID, clientToken, maxPlayers }));
Console.WriteLine($"New game started: {game.ID}; {games.Count} games active; {inactiveGames.Count} inactive");
} catch (ArgumentException) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data"));
}
if (!e.Request.RawUrl.StartsWith("/api/")) {
// Static files
if (e.Request.HttpMethod is not ("GET" or "HEAD")) {
e.Response.AddHeader("Allow", "GET, HEAD");
e.Response.SetErrorResponse(new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
return;
}
} else if (e.Request.RawUrl == "/api/cards") {
SetStaticResponse(e.Request, e.Response, CardDatabase.JSON, CardDatabase.Version.ToString(), CardDatabase.LastModified);
} else if (e.Request.RawUrl == "/api/stages") {
SetStaticResponse(e.Request, e.Response, StageDatabase.JSON, StageDatabase.Version.ToString(), StageDatabase.LastModified);
var pos = e.Request.RawUrl.IndexOf('/', 1);
var topLevelFileName = pos < 0 ? e.Request.RawUrl : e.Request.RawUrl[..pos];
var path = spaPaths.Contains(topLevelFileName) ? "index.html" : HttpUtility.UrlDecode(e.Request.RawUrl[1..]);
if (e.TryReadFile(path, out var bytes))
e.Response.SetResponse(HttpStatusCode.OK, Path.GetExtension(path) switch {
".html" or ".htm" => "text/html",
".css" => "text/css",
".js" => "text/javascript",
".png" => "image/png",
".tar" => "application/x-tar",
".webp" => "image/webp",
".woff" or ".woff2" => "font/woff",
_ => "application/octet-stream"
}, bytes);
else
e.Response.SetErrorResponse(new(HttpStatusCode.NotFound, "NotFound", "File not found."));
} else {
var m = GamePathRegex().Match(e.Request.RawUrl);
if (m.Success) {
if (!Guid.TryParse(m.Groups[1].Value, out var gameID)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidGameID", "Invalid game ID."));
var pos = e.Request.RawUrl.IndexOf('?', 5);
var path = pos < 0 ? e.Request.RawUrl[4..] : e.Request.RawUrl[4..pos];
if (apiGlobalHandlers.TryGetValue(path, out var entry)) {
if ((e.Request.HttpMethod == "HEAD" ? "GET" : e.Request.HttpMethod) != entry.attribute.AllowedMethod) {
e.Response.AddHeader("Allow", entry.attribute.AllowedMethod == "GET" ? "GET, HEAD" : entry.attribute.AllowedMethod);
e.Response.SetErrorResponse(new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
return;
}
lock (games) {
if (!TryGetGame(gameID, out var game)) {
SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "GameNotFound", "Game not found."));
if (e.Request.ContentLength64 >= 65536) {
e.Response.SetErrorResponse(new(HttpStatusCode.RequestEntityTooLarge, "ContentTooLarge", "Request content is too large."));
return;
}
entry.handler(e.Request, e.Response);
} else {
if (!path.StartsWith("/games/")) {
e.Response.SetErrorResponse(new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found."));
return;
}
pos = path.IndexOf('/', 7);
var gameIdString = path[7..(pos < 0 ? ^0 : pos)];
path = pos < 0 ? "/" : path[pos..];
if (!Guid.TryParse(gameIdString, out var gameID)) {
e.Response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidGameID", "Invalid game ID."));
return;
}
lock (Server.Instance.games) {
if (!Server.Instance.TryGetGame(gameID, out var game)) {
e.Response.SetErrorResponse(new(HttpStatusCode.NotFound, "GameNotFound", "Game not found."));
return;
}
lock (game.Players) {
switch (m.Groups[2].Value) {
case "": {
if (e.Request.HttpMethod is not ("GET" or "HEAD")) {
e.Response.AddHeader("Allow", "GET, HEAD");
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
return;
}
SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(game));
break;
}
case "playerData": {
if (e.Request.HttpMethod is not ("GET" or "HEAD")) {
e.Response.AddHeader("Allow", "GET, HEAD");
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
return;
}
if (!Guid.TryParse(m.Groups[3].Value, out var clientToken))
clientToken = Guid.Empty;
SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new {
game,
playerData = game.GetPlayer(clientToken, out var playerIndex, out var player)
? new PlayerData(playerIndex, player)
: null
}));
break;
}
case "join": {
if (e.Request.HttpMethod != "POST") {
e.Response.AddHeader("Allow", "POST");
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
} else if (e.Request.ContentLength64 >= 65536) {
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
} else {
try {
var d = DecodeFormData(e.Request.InputStream);
Guid clientToken;
if (!d.TryGetValue("name", out var name)) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Missing name."));
return;
}
if (name.Length > 32) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Name is too long."));
return;
}
if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") {
if (!Guid.TryParse(tokenString, out clientToken)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
return;
}
} else
clientToken = Guid.NewGuid();
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
if (game.State != GameState.WaitingForPlayers) {
SetErrorResponse(e.Response, new(HttpStatusCode.Gone, "GameAlreadyStarted", "The game has already started."));
return;
}
player = new Player(game, name, clientToken);
if (!game.TryAddPlayer(player, out playerIndex, out var error)) {
SetErrorResponse(e.Response, error);
return;
}
game.SendEvent("join", new { playerIndex, player }, false);
}
// If they're already in the game, resend the original join response instead of an error.
SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { playerIndex, clientToken }));
timer.Start();
} catch (ArgumentException) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data"));
}
}
break;
}
case "setGameSettings": {
if (e.Request.HttpMethod != "POST") {
e.Response.AddHeader("Allow", "POST");
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
} else if (e.Request.ContentLength64 >= 65536) {
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
} else {
var d = DecodeFormData(e.Request.InputStream);
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
return;
}
if (clientToken != game.HostClientToken) {
SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "AccessDenied", "Only the host can do that."));
return;
}
if (game.State != GameState.WaitingForPlayers) {
SetErrorResponse(e.Response, new(HttpStatusCode.Gone, "GameAlreadyStarted", "The game has already started."));
return;
}
if (d.TryGetValue("turnTimeLimit", out var turnTimeLimitString)) {
if (turnTimeLimitString == "")
game.TurnTimeLimit = null;
else if (!int.TryParse(turnTimeLimitString, out var turnTimeLimit2) || turnTimeLimit2 < 10) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "Invalid turn time limit."));
return;
} else
game.TurnTimeLimit = turnTimeLimit2;
}
if (d.TryGetValue("allowUpcomingCards", out var allowUpcomingCardsString)) {
if (!bool.TryParse(allowUpcomingCardsString, out var allowUpcomingCards)) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "Invalid allowUpcomingCards."));
return;
} else
game.AllowUpcomingCards = allowUpcomingCards;
}
if (d.TryGetValue("allowCustomCards", out var allowCustomCardsString)) {
if (!bool.TryParse(allowCustomCardsString, out var allowCustomCards)) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "Invalid allowCustomCards."));
return;
} else
game.AllowCustomCards = allowCustomCards;
}
game.SendEvent("settingsChange", game, false);
}
break;
}
case "chooseStage": {
if (e.Request.HttpMethod != "POST") {
e.Response.AddHeader("Allow", "POST");
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
} else if (e.Request.ContentLength64 >= 65536) {
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
} else {
var d = DecodeFormData(e.Request.InputStream);
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
return;
}
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "NotInGame", "You're not in the game."));
return;
}
if (!d.TryGetValue("stages", out var stagesString)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidStage", "Missing stages."));
return;
}
var stages = new HashSet<int>();
foreach (var field in stagesString.Split(DELIMITERS, StringSplitOptions.RemoveEmptyEntries)) {
if (!int.TryParse(field, out var i)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidStage", "Invalid stages."));
return;
}
stages.Add(i);
}
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;
}
case "chooseDeck": {
if (e.Request.HttpMethod != "POST") {
e.Response.AddHeader("Allow", "POST");
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
} else if (e.Request.ContentLength64 >= 65536) {
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
} else {
try {
var d = DecodeFormData(e.Request.InputStream);
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
return;
}
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "NotInGame", "You're not in the game."));
return;
}
if (player.CurrentGameData.Deck != null) {
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "DeckAlreadyChosen", "You've already chosen a deck."));
return;
}
if (!d.TryGetValue("deckName", out var deckName)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidDeckName", "Missing deck name."));
return;
}
var deckSleeves = 0;
if (d.TryGetValue("deckSleeves", out var deckSleevesString) && (!int.TryParse(deckSleevesString, out deckSleeves) || deckSleeves is < 0 or >= 25)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidDeckSleeves", "Invalid deck sleeves."));
return;
}
if (!d.TryGetValue("deckCards", out var deckString)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidDeckCards", "Missing deck cards."));
return;
}
Dictionary<int, UserCustomCard>? userCustomCards = null;
List<KeyValuePair<int, Card>>? customCardsToAdd = null;
if (d.TryGetValue("customCards", out var customCardsString)) {
if (!game.AllowCustomCards) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "CustomCardsNotAllowed", "Custom cards cannot be used in this game."));
return;
}
userCustomCards = JsonUtils.Deserialise<Dictionary<int, UserCustomCard>>(customCardsString);
// Validate custom cards.
if (userCustomCards is null || userCustomCards.Count > 15 || userCustomCards.Keys.Any(k => k is not (<= -10000 and >= short.MinValue))) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidCustomCards", "Invalid custom cards."));
return;
}
customCardsToAdd = new(userCustomCards.Count);
foreach (var (k, v) in userCustomCards) {
if (!v.CheckGrid(out var hasSpecialSpace, out var size)) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidCustomCards", $"Custom card {k} is invalid."));
return;
}
// Allow resending the same custom card, but not a different custom card with the same key.
if (player.customCardMap.TryGetValue(k, out var existingCustomCardNumber)) {
if (!v.Equals(game.customCards[RECEIVED_CUSTOM_CARD_START - existingCustomCardNumber])) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidCustomCards", $"Cannot reuse custom card number {k}."));
return;
}
} else {
// TODO: Consolidate identical custom cards brought by different players.
var card = v.ToCard(RECEIVED_CUSTOM_CARD_START - (game.customCards.Count + customCardsToAdd.Count), k, !hasSpecialSpace && size >= 8 ? 3 : null);
customCardsToAdd.Add(new(k, card));
}
}
}
var array = deckString.Split([',', '+', ' '], 15);
if (array.Length != 15) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Invalid deck list."));
return;
}
int[]? upgrades = null;
if (d.TryGetValue("deckUpgrades", out var deckUpgradesString)) {
upgrades = new int[15];
var array2 = deckUpgradesString.Split([',', '+', ' '], 15);
for (var i = 0; i < 15; i++) {
if (int.TryParse(array2[i], out var j) && i is >= 0 and <= 2)
upgrades[i] = j;
else {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckUpgrades", "Invalid deck upgrade list."));
return;
}
}
}
var cards = new int[15];
for (var i = 0; i < 15; i++) {
if (!int.TryParse(array[i], out var cardNumber) || (!CardDatabase.IsValidCardNumber(cardNumber) && (userCustomCards == null || !userCustomCards.ContainsKey(cardNumber)))) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Invalid deck list."));
return;
}
if (Array.IndexOf(cards, cardNumber, 0, i) >= 0) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Deck cannot have duplicates."));
return;
}
if (!game.AllowUpcomingCards && cardNumber is < 0 and > CUSTOM_CARD_START && CardDatabase.GetCard(cardNumber).Number < 0) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "ForbiddenDeck", "Upcoming cards cannot be used in this game."));
return;
}
// Translate custom card numbers from the player to game-scoped card numbers.
cards[i] = player.customCardMap.TryGetValue(cardNumber, out var n) ? n : cardNumber <= CUSTOM_CARD_START && customCardsToAdd?.FirstOrDefault(e => e.Key == cardNumber).Value is Card customCard ? customCard.Number : cardNumber;
}
if (customCardsToAdd != null) {
foreach (var (userKey, card) in customCardsToAdd) {
player.customCardMap.Add(userKey, card.Number);
game.customCards.Add(card);
}
}
player.CurrentGameData.Deck = game.GetDeck(deckName, deckSleeves, cards, upgrades ?? Enumerable.Repeat(0, 15));
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
game.SendPlayerReadyEvent(playerIndex, false);
timer.Start();
} catch (ArgumentException) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data"));
}
}
break;
}
case "play": {
if (e.Request.HttpMethod != "POST") {
e.Response.AddHeader("Allow", "POST");
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
} else if (e.Request.ContentLength64 >= 65536) {
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
} else {
try {
var d = DecodeFormData(e.Request.InputStream);
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
return;
}
if (game.State != GameState.Ongoing) {
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state."));
return;
}
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game."));
return;
}
if (player.Move != null) {
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "MoveAlreadyChosen", "You've already chosen a move."));
return;
}
if (!d.TryGetValue("cardNumber", out var cardNumberStr) || !int.TryParse(cardNumberStr, out var cardNumber)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidCard", "Missing or invalid card number."));
return;
}
var handIndex = player.GetHandIndex(cardNumber);
if (handIndex < 0) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "MissingCard", "You don't have that card."));
return;
}
var isTimeout = d.TryGetValue("isTimeout", out var isTimeoutStr) && isTimeoutStr.ToLower() is not ("false" or "0");
var card = player.Hand![handIndex];
if (d.TryGetValue("isPass", out var isPassStr) && isPassStr.ToLower() is not ("false" or "0")) {
player.Move = new(card, true, 0, 0, 0, false, isTimeout);
} else {
var isSpecialAttack = d.TryGetValue("isSpecialAttack", out var isSpecialAttackStr) && isSpecialAttackStr.ToLower() is not ("false" or "0");
if (!d.TryGetValue("x", out var xs) || !int.TryParse(xs, out var x)
|| !d.TryGetValue("y", out var ys) || !int.TryParse(ys, out var y)
|| !d.TryGetValue("r", out var rs) || !int.TryParse(rs, out var r)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidPosition", "Missing or invalid position."));
return;
}
r &= 3;
if (!game.CanPlay(playerIndex, card, x, y, r, isSpecialAttack)) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "IllegalMove", "Illegal move"));
return;
}
player.Move = new(card, false, x, y, r, isSpecialAttack, isTimeout);
}
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
game.SendPlayerReadyEvent(playerIndex, isTimeout);
timer.Start();
} catch (ArgumentException) {
e.Response.StatusCode = (int) HttpStatusCode.BadRequest;
}
}
break;
}
case "redraw": {
if (e.Request.HttpMethod != "POST") {
e.Response.AddHeader("Allow", "POST");
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
} else if (e.Request.ContentLength64 >= 65536) {
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
} else {
try {
if (game.State != GameState.Redraw) {
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state."));
return;
}
var d = DecodeFormData(e.Request.InputStream);
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
return;
}
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game."));
return;
}
if (player.Move != null) {
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "MoveAlreadyChosen", "You've already chosen a move."));
return;
}
var redraw = d.TryGetValue("redraw", out var redrawStr) && redrawStr.ToLower() is not ("false" or "0");
player.Move = new(player.Hand![0], false, 0, 0, 0, redraw, false);
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
game.SendPlayerReadyEvent(playerIndex, false);
timer.Start();
} catch (ArgumentException) {
e.Response.StatusCode = (int) HttpStatusCode.BadRequest;
}
}
break;
}
case "nextGame": {
if (e.Request.HttpMethod != "POST") {
e.Response.AddHeader("Allow", "POST");
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
} else if (e.Request.ContentLength64 >= 65536) {
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
} else {
try {
if (game.State is not (GameState.GameEnded or GameState.SetEnded)) {
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state."));
return;
}
var d = DecodeFormData(e.Request.InputStream);
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
return;
}
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game."));
return;
}
if (player.Move == null) {
player.Move = new(player.Hand![0], false, 0, 0, 0, false, false); // Dummy move to indicate that the player is ready.
game.SendPlayerReadyEvent(playerIndex, false);
}
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
timer.Start();
} catch (ArgumentException) {
e.Response.StatusCode = (int) HttpStatusCode.BadRequest;
}
}
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.SetEnded) {
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameInProgress", "You can't see the replay until the set 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;
if (!apiGameHandlers.TryGetValue(path, out var entry2)) {
e.Response.SetErrorResponse(new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found."));
return;
}
if ((e.Request.HttpMethod == "HEAD" ? "GET" : e.Request.HttpMethod) != entry2.attribute.AllowedMethod) {
e.Response.AddHeader("Allow", entry2.attribute.AllowedMethod == "GET" ? "GET, HEAD" : entry2.attribute.AllowedMethod);
e.Response.SetErrorResponse(new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
return;
}
if (e.Request.ContentLength64 >= 65536) {
e.Response.SetErrorResponse(new(HttpStatusCode.RequestEntityTooLarge, "ContentTooLarge", "Request content is too large."));
return;
}
entry2.handler(game, e.Request, e.Response);
}
}
} else
SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found."));
}
}
private static void SetErrorResponse(HttpListenerResponse response, Error error) {
var bytes = Encoding.UTF8.GetBytes(JsonUtils.Serialise(error));
SetResponse(response, error.HttpStatusCode, "application/json", bytes);
}
private static void SetResponse(HttpListenerResponse response, HttpStatusCode statusCode, string contentType, string content) {
var bytes = Encoding.UTF8.GetBytes(content);
SetResponse(response, statusCode, contentType, bytes);
}
private static void SetResponse(HttpListenerResponse response, HttpStatusCode statusCode, string contentType, byte[] content) {
response.StatusCode = (int) statusCode;
response.ContentType = contentType;
response.ContentLength64 = content.Length;
response.Close(content, true);
}
private static Dictionary<string, string> DecodeFormData(Stream stream) {
using var reader = new StreamReader(stream);
var s = reader.ReadToEnd();
return s != ""
? s.Split(['&']).Select(s => s.Split('=')).Select(a => a.Length == 2 ? a : throw new ArgumentException("Invalid form data"))
.ToDictionary(a => HttpUtility.UrlDecode(a[0]), a => HttpUtility.UrlDecode(a[1]))
: [];
}
private static void SetStaticResponse(HttpListenerRequest request, HttpListenerResponse response, string jsonContent, string eTag, DateTime lastModified) {
if (request.HttpMethod is not ("GET" or "HEAD")) {
response.AddHeader("Allow", "GET, HEAD");
SetErrorResponse(response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
return;
}
response.AppendHeader("Cache-Control", "max-age=86400");
response.AppendHeader("ETag", eTag);
response.AppendHeader("Last-Modified", lastModified.ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\""));
var ifNoneMatch = request.Headers["If-None-Match"];
if (ifNoneMatch != null) {
if (request.Headers["If-None-Match"] == eTag)
response.StatusCode = (int) HttpStatusCode.NotModified;
else
SetResponse(response, HttpStatusCode.OK, "application/json", jsonContent);
} else {
if (DateTime.TryParseExact(request.Headers["If-Modified-Since"], "ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dateTime)
&& dateTime >= lastModified.ToUniversalTime())
response.StatusCode = (int) HttpStatusCode.NotModified;
else
SetResponse(response, HttpStatusCode.OK, "application/json", jsonContent);
}
}
internal static bool TryGetGame(Guid gameID, [MaybeNullWhen(false)] out Game game) {
if (games.TryGetValue(gameID, out game)) {
game.abandonedSince = DateTime.UtcNow;
return true;
} else if (inactiveGames.TryGetValue(gameID, out game)) {
inactiveGames.Remove(gameID);
games[gameID] = game;
game.abandonedSince = DateTime.UtcNow;
Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive");
return true;
}
return false;
}
private static bool TryParseStageSelectionRule(string json, int maxPlayers, [MaybeNullWhen(false)] out StageSelectionRules stageSelectionRule) {
try {
stageSelectionRule = JsonUtils.Deserialise<StageSelectionRules>(json);
if (stageSelectionRule == null) return false;
stageSelectionRule.AddUnavailableStages(maxPlayers);
// Check that at least one stage is allowed.
for (var i = 0; i < StageDatabase.Stages.Count; i++) {
if (!stageSelectionRule.BannedStages.Contains(i)) return true;
}
return false;
} catch (JsonSerializationException) {
stageSelectionRule = null;
return false;
}
}
[GeneratedRegex(@"^/api/games/([\w-]+)(?:/(\w+)(?:\?clientToken=([\w-]+))?)?$", RegexOptions.Compiled)]
private static partial Regex GamePathRegex();
}

View File

@ -0,0 +1,55 @@
using System.Diagnostics.CodeAnalysis;
using System.Timers;
using Timer = System.Timers.Timer;
namespace TableturfBattleServer;
internal class Server {
public static Server Instance { get; } = new();
internal Dictionary<Guid, Game> games = [];
internal Dictionary<Guid, Game> inactiveGames = [];
public bool Lockdown { get; set; }
internal readonly Timer timer = new(1000);
private const int InactiveGameLimit = 1000;
private static readonly TimeSpan InactiveGameTimeout = TimeSpan.FromMinutes(5);
private Server() => this.timer.Elapsed += this.Timer_Elapsed;
internal bool TryGetGame(Guid gameID, [MaybeNullWhen(false)] out Game game) {
if (games.TryGetValue(gameID, out game)) {
game.abandonedSince = DateTime.UtcNow;
return true;
} else if (inactiveGames.TryGetValue(gameID, out game)) {
inactiveGames.Remove(gameID);
games[gameID] = game;
game.abandonedSince = DateTime.UtcNow;
Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive");
return true;
}
return false;
}
private void Timer_Elapsed(object? sender, ElapsedEventArgs e) {
lock (games) {
foreach (var (id, game) in games) {
lock (game.Players) {
game.Tick();
if (DateTime.UtcNow - game.abandonedSince >= InactiveGameTimeout) {
games.Remove(id);
inactiveGames.Add(id, game);
Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive");
if (Lockdown && games.Count == 0)
Environment.Exit(2);
}
}
}
if (inactiveGames.Count >= InactiveGameLimit) {
foreach (var (k, _) in inactiveGames.Select(e => (e.Key, e.Value.abandonedSince)).OrderBy(e => e.abandonedSince).Take(InactiveGameLimit / 2))
inactiveGames.Remove(k);
Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive");
}
}
}
}

View File

@ -203,33 +203,32 @@ internal class StageDatabase {
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, B, E, E, E, E, E, E, E, E, E, E, E, A, E, E, E },
{ E, E, B, E, E, E, E, E, E, E, E, E, E, E, E, A, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o },
{ o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, B, E, E, E, E, E, E, E, E, E, E, E, A, E, E, E },
{ E, E, B, E, E, E, E, E, E, E, E, E, E, E, E, A, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
}, [
[new(3, 14), new(3, 2)]
[new(11, 15), new(11, 2)]
]),
new("Pedal to the Metal", new Space[,] {
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, B, B, B, E, E, E, E, E, E, E, E, E, E, E, E, A, A, A, E, E, E },
{ E, E, E, B, B, B, E, E, E, E, E, E, E, E, E, E, E, E, A, A, A, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, B, B, B, E, E, E, E, E, E, E, E, E, E, E, E, E, A, A, A, E, E, E },
{ E, E, E, B, B, B, E, E, E, E, E, E, E, E, E, E, E, E, E, A, A, A, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
}, [
[new(4, 19), new(4, 3)]
]),
@ -250,8 +249,8 @@ internal class StageDatabase {
]),
];
public static Version Version { get; } = new(2, 0, 0, 0);
public static DateTime LastModified { get; } = new(2024, 2, 21, 1, 0, 0, DateTimeKind.Utc);
public static Version Version { get; } = new(2, 0, 1, 0);
public static DateTime LastModified { get; } = new(2024, 2, 24, 10, 0, 0, DateTimeKind.Utc);
public static string JSON { get; }
public static ReadOnlyCollection<Stage> Stages { get; }

View File

@ -18,7 +18,7 @@ internal class TableturfWebSocketBehaviour : WebSocketBehavior {
this.ClientToken = clientToken;
// Send an initial state payload.
if (Program.TryGetGame(this.GameID, out var game)) {
if (Server.Instance.TryGetGame(this.GameID, out var game)) {
DTO.PlayerData? playerData = null;
for (int i = 0; i < game.Players.Count; i++) {
var player = game.Players[i];

View File

@ -1,6 +1,6 @@
MIT License
# MIT License
Copyright (c) 2022 Andrea Giannone
Copyright (c) 2022-2024 Andrio Celos
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal