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:
evanpelle
2025-09-08 18:14:08 -07:00
committed by GitHub
parent 043462e28a
commit defb6bb1d4
6 changed files with 119 additions and 51 deletions
+3 -3
View File
@@ -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
View File
@@ -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;
}
+2 -2
View File
@@ -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
View File
@@ -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) => {