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();