From 40932b9f5f883fbf5423324ef63431c3ae47067c Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Wed, 28 May 2025 13:33:20 -0400 Subject: [PATCH] Fix bigint serialization error (#916) ## Description: - JSON serialization, bigint to string handling - JSON deserialization, string to bigint handling ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/client/LocalPersistantStats.ts | 3 ++- src/client/LocalServer.ts | 11 +++++++---- src/client/Transport.ts | 3 ++- src/core/StatsSchemas.ts | 10 ++++++++-- src/core/Util.ts | 7 +++++++ src/server/Archive.ts | 8 +------- tests/Stats.test.ts | 2 +- 7 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/client/LocalPersistantStats.ts b/src/client/LocalPersistantStats.ts index 32ac81d72..616e40db7 100644 --- a/src/client/LocalPersistantStats.ts +++ b/src/client/LocalPersistantStats.ts @@ -1,5 +1,6 @@ import { consolex } from "../core/Consolex"; import { GameConfig, GameID, GameRecord } from "../core/Schemas"; +import { replacer } from "../core/Util"; export interface LocalStatsData { [key: GameID]: { @@ -19,7 +20,7 @@ function getStats(): LocalStatsData { function save(stats: LocalStatsData) { // To execute asynchronously setTimeout( - () => localStorage.setItem("game-records", JSON.stringify(stats)), + () => localStorage.setItem("game-records", JSON.stringify(stats, replacer)), 0, ); } diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 0c7e9719f..4d7be1a1c 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -11,7 +11,7 @@ import { ServerStartGameMessageSchema, Turn, } from "../core/Schemas"; -import { createGameRecord, decompressGameRecord } from "../core/Util"; +import { createGameRecord, decompressGameRecord, replacer } from "../core/Util"; import { LobbyConfig } from "./ClientGameRunner"; import { getPersistentID } from "./Main"; @@ -199,9 +199,12 @@ export class LocalServer { record.turns = []; } // For unload events, sendBeacon is the only reliable method - const blob = new Blob([JSON.stringify(GameRecordSchema.parse(record))], { - type: "application/json", - }); + const blob = new Blob( + [JSON.stringify(GameRecordSchema.parse(record), replacer)], + { + type: "application/json", + }, + ); const workerPath = this.lobbyConfig.serverConfig.workerPath( this.lobbyConfig.gameStartInfo.gameID, ); diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 0c84fbaf9..cbef4bbba 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -23,6 +23,7 @@ import { ServerMessageSchema, Winner, } from "../core/Schemas"; +import { replacer } from "../core/Util"; import { LobbyConfig } from "./ClientGameRunner"; import { LocalServer } from "./LocalServer"; @@ -536,7 +537,7 @@ export class Transport { winner: event.winner, allPlayersStats: event.allPlayersStats, } satisfies ClientSendWinnerMessage; - this.sendMsg(JSON.stringify(msg)); + this.sendMsg(JSON.stringify(msg, replacer)); } else { console.log( "WebSocket is not open. Current state:", diff --git a/src/core/StatsSchemas.ts b/src/core/StatsSchemas.ts index be84d6faf..a9ef657b4 100644 --- a/src/core/StatsSchemas.ts +++ b/src/core/StatsSchemas.ts @@ -84,13 +84,19 @@ export const OTHER_INDEX_DESTROY = 1; // Structures and warships destroyed export const OTHER_INDEX_CAPTURE = 2; // Structures captured export const OTHER_INDEX_LOST = 3; // Structures/warships destroyed/captured by others -const AtLeastOneNumberSchema = z.bigint().array().min(1); +const BigIntStringSchema = z.preprocess((val) => { + if (typeof val === "string" && /^\d+$/.test(val)) return BigInt(val); + if (typeof val === "bigint") return val; + return val; +}, z.bigint()); + +const AtLeastOneNumberSchema = BigIntStringSchema.array().min(1); export type AtLeastOneNumber = z.infer; export const PlayerStatsSchema = z .object({ attacks: AtLeastOneNumberSchema.optional(), - betrayals: z.bigint().positive().optional(), + betrayals: BigIntStringSchema.optional(), boats: z.record(BoatUnitSchema, AtLeastOneNumberSchema).optional(), bombs: z.record(BombUnitSchema, AtLeastOneNumberSchema).optional(), gold: AtLeastOneNumberSchema.optional(), diff --git a/src/core/Util.ts b/src/core/Util.ts index fc2b40bca..b518f5f1a 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -308,3 +308,10 @@ export const emojiTable: string[][] = [ ]; // 2d to 1d array export const flattenedEmojiTable: string[] = emojiTable.flat(); + +/** + * JSON.stringify replacer function that converts bigint values to strings. + */ +export function replacer(_key: string, value: any): any { + return typeof value === "bigint" ? value.toString() : value; +} diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 1e3544aff..d3b702660 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -1,6 +1,7 @@ import { S3 } from "@aws-sdk/client-s3"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { AnalyticsRecord, GameID, GameRecord } from "../core/Schemas"; +import { replacer } from "../core/Util"; import { logger } from "./Logger"; const config = getServerConfigFromServer(); @@ -147,10 +148,3 @@ export async function gameRecordExists(gameId: GameID): Promise { return false; } } - -/** - * JSON.stringify replacer function that converts bigint values to strings. - */ -export function replacer(_key: string, value: any): any { - return typeof value === "bigint" ? value.toString() : value; -} diff --git a/tests/Stats.test.ts b/tests/Stats.test.ts index 963e80d14..46b3b1b36 100644 --- a/tests/Stats.test.ts +++ b/tests/Stats.test.ts @@ -7,7 +7,7 @@ import { } from "../src/core/game/Game"; import { Stats } from "../src/core/game/Stats"; import { StatsImpl } from "../src/core/game/StatsImpl"; -import { replacer } from "../src/server/Archive"; +import { replacer } from "../src/core/Util"; import { setup } from "./util/Setup"; let stats: Stats;