mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
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
This commit is contained in:
Generated
+19
-23
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+56
-12
@@ -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<Uint8Array> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+37
-11
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user