mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-01 17:43:24 +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:
@@ -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