From defb6bb1d441774bd91e5805c97802f55f7dd19e Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 8 Sep 2025 18:14:08 -0700 Subject: [PATCH] Store full game for singleplayer, add more validation (#2031) ## Description: onunload allows up to 64kb, but reducing the number of hash messages and compressing using gzip, we can reduce the GameRecord size to stay under 64kb. I played a 10 minute game and the compressed GameRecord was only a few kb. Also verify the game is singleplayer and has only 1 player ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- package-lock.json | 42 ++++++++++----------- package.json | 2 + src/client/ClientGameRunner.ts | 6 +-- src/client/LocalServer.ts | 68 ++++++++++++++++++++++++++++------ src/client/Transport.ts | 4 +- src/server/Worker.ts | 48 ++++++++++++++++++------ 6 files changed, 119 insertions(+), 51 deletions(-) diff --git a/package-lock.json b/package-lock.json index acaa7eaff..af38b8920 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,10 @@ "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.32.0", "@opentelemetry/winston-transport": "^0.11.0", + "@types/compression": "^1.8.1", "colord": "^2.9.3", "colorjs.io": "^0.5.2", + "compression": "^1.8.1", "dompurify": "^3.1.7", "dotenv": "^16.5.0", "express": "^4.21.1", @@ -6323,7 +6325,6 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -6347,11 +6348,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -6699,7 +6709,6 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -6712,7 +6721,6 @@ "version": "4.19.6", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -6760,7 +6768,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/http-proxy": { @@ -6851,7 +6858,6 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, "license": "MIT" }, "node_modules/@types/msgpack5": { @@ -6901,14 +6907,12 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/readable-stream": { @@ -6939,7 +6943,6 @@ "version": "0.17.5", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -6960,7 +6963,6 @@ "version": "1.15.8", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -9134,7 +9136,6 @@ "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": ">= 1.43.0 < 2" @@ -9144,17 +9145,16 @@ } }, "node_modules/compression": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", - "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", - "dev": true, + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", - "on-headers": "~1.0.2", + "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" }, @@ -9166,7 +9166,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -9176,14 +9175,12 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, "license": "MIT" }, "node_modules/compression/node_modules/negotiator": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -15916,10 +15913,9 @@ } }, "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "dev": true, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "license": "MIT", "engines": { "node": ">= 0.8" diff --git a/package.json b/package.json index d86bcd6e4..ff51e1e23 100644 --- a/package.json +++ b/package.json @@ -107,8 +107,10 @@ "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.32.0", "@opentelemetry/winston-transport": "^0.11.0", + "@types/compression": "^1.8.1", "colord": "^2.9.3", "colorjs.io": "^0.5.2", + "compression": "^1.8.1", "dompurify": "^3.1.7", "dotenv": "^16.5.0", "express": "^4.21.1", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index c6f17706b..68ebb2077 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -271,7 +271,7 @@ export class ClientGameRunner { this.lobby.clientID, ); console.error(gu.stack); - this.stop(true); + this.stop(); return; } this.transport.turnComplete(); @@ -361,12 +361,12 @@ export class ClientGameRunner { this.transport.connect(onconnect, onmessage); } - public stop(saveFullGame: boolean = false) { + public stop() { if (!this.isActive) return; this.isActive = false; this.worker.cleanup(); - this.transport.leaveGame(saveFullGame); + this.transport.leaveGame(); if (this.connectionCheckInterval) { clearInterval(this.connectionCheckInterval); this.connectionCheckInterval = null; diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 24cf46782..5f73f9c05 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -103,8 +103,10 @@ export class LocalServer { } if (clientMsg.type === "hash") { if (!this.lobbyConfig.gameRecord) { - // If we are playing a singleplayer then store hash. - this.turns[clientMsg.turnNumber].hash = clientMsg.hash; + if (clientMsg.turnNumber % 100 === 0) { + // In singleplayer, only store hash every 100 turns to reduce size of game record. + this.turns[clientMsg.turnNumber].hash = clientMsg.hash; + } return; } // If we are replaying a game then verify hash. @@ -169,7 +171,7 @@ export class LocalServer { }); } - public endGame(saveFullGame: boolean = false) { + public endGame() { console.log("local server ending game"); clearInterval(this.turnCheckInterval); if (this.isReplay) { @@ -196,23 +198,65 @@ export class LocalServer { this.winner?.winner, this.lobbyConfig.serverConfig, ); - if (!saveFullGame) { - // Clear turns because beacon only supports up to 64kb - record.turns = []; - } - // For unload events, sendBeacon is the only reliable method + const result = GameRecordSchema.safeParse(record); if (!result.success) { const error = z.prettifyError(result.error); console.error("Error parsing game record", error); return; } - const blob = new Blob([JSON.stringify(result.data, replacer)], { - type: "application/json", - }); const workerPath = this.lobbyConfig.serverConfig.workerPath( this.lobbyConfig.gameStartInfo.gameID, ); - navigator.sendBeacon(`/${workerPath}/api/archive_singleplayer_game`, blob); + + const jsonString = JSON.stringify(result.data, replacer); + + compress(jsonString) + .then((compressedData) => { + return fetch(`/${workerPath}/api/archive_singleplayer_game`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Encoding": "gzip", + }, + body: compressedData, + keepalive: true, // Ensures request completes even if page unloads + }); + }) + .catch((error) => { + console.error("Failed to archive singleplayer game:", error); + }); } } + +async function compress(data: string): Promise { + const stream = new CompressionStream("gzip"); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + // Write the data to the compression stream + writer.write(new TextEncoder().encode(data)); + writer.close(); + + // Read the compressed data + const chunks: Uint8Array[] = []; + let done = false; + while (!done) { + const { value, done: readerDone } = await reader.read(); + done = readerDone; + if (value) { + chunks.push(value); + } + } + + // Combine all chunks into a single Uint8Array + const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0); + const compressedData = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + compressedData.set(chunk, offset); + offset += chunk.length; + } + + return compressedData; +} diff --git a/src/client/Transport.ts b/src/client/Transport.ts index abd0410ff..b49419881 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -373,9 +373,9 @@ export class Transport { } satisfies ClientJoinMessage); } - leaveGame(saveFullGame: boolean = false) { + leaveGame() { if (this.isLocal) { - this.localServer.endGame(saveFullGame); + this.localServer.endGame(); return; } this.stopPing(); diff --git a/src/server/Worker.ts b/src/server/Worker.ts index ae6e413e7..6bcf46d99 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -1,3 +1,4 @@ +import compression from "compression"; import express, { NextFunction, Request, Response } from "express"; import rateLimit from "express-rate-limit"; import http from "http"; @@ -81,6 +82,7 @@ export async function startWorker() { }); app.set("trust proxy", 3); + app.use(compression()); app.use(express.json()); app.use(express.static(path.join(__dirname, "../../out"))); app.use( @@ -247,18 +249,42 @@ export async function startWorker() { }); app.post("/api/archive_singleplayer_game", async (req, res) => { - const result = GameRecordSchema.safeParse(req.body); - if (!result.success) { - const error = z.prettifyError(result.error); - log.info(error); - return res.status(400).json({ error }); - } + try { + const record = req.body; - const gameRecord: GameRecord = result.data; - archive(gameRecord); - res.json({ - success: true, - }); + const result = GameRecordSchema.safeParse(record); + if (!result.success) { + const error = z.prettifyError(result.error); + log.info(error); + return res.status(400).json({ error }); + } + const gameRecord: GameRecord = result.data; + + if (gameRecord.info.config.gameType !== GameType.Singleplayer) { + log.warn( + `cannot archive singleplayer with game type ${gameRecord.info.config.gameType}`, + { + gameID: gameRecord.info.gameID, + }, + ); + return res.status(400).json({ error: "Invalid request" }); + } + + if (result.data.info.players.length !== 1) { + log.warn(`cannot archive singleplayer game multiple players`, { + gameID: gameRecord.info.gameID, + }); + return res.status(400).json({ error: "Invalid request" }); + } + + archive(gameRecord); + res.json({ + success: true, + }); + } catch (error) { + log.error("Error processing archive request:", error); + res.status(500).json({ error: "Internal server error" }); + } }); app.post("/api/kick_player/:gameID/:clientID", async (req, res) => {