From 8cf2d86a7034f66ab9e57ddd730a6bce643309a6 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Tue, 27 May 2025 22:13:05 -0400 Subject: [PATCH] Convert stats to bigints (#909) Fixes #880 ## Description: Convert numeric stat types to bigint, and serialize those values as strings. ## Please complete the following: - [x] I have added screenshots for all UI updates - [ ] 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/core/StatsSchemas.ts | 4 +- src/core/game/GameUpdates.ts | 2 +- src/core/game/Stats.ts | 30 +++-- src/core/game/StatsImpl.ts | 82 +++++++------ src/server/Archive.ts | 13 +- tests/Stats.test.ts | 232 +++++++++++++++++++++++++++++++++++ 6 files changed, 313 insertions(+), 50 deletions(-) create mode 100644 tests/Stats.test.ts diff --git a/src/core/StatsSchemas.ts b/src/core/StatsSchemas.ts index d7357e315..be84d6faf 100644 --- a/src/core/StatsSchemas.ts +++ b/src/core/StatsSchemas.ts @@ -84,13 +84,13 @@ 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.number().array().min(1); +const AtLeastOneNumberSchema = z.bigint().array().min(1); export type AtLeastOneNumber = z.infer; export const PlayerStatsSchema = z .object({ attacks: AtLeastOneNumberSchema.optional(), - betrayals: z.number().positive().optional(), + betrayals: z.bigint().positive().optional(), boats: z.record(BoatUnitSchema, AtLeastOneNumberSchema).optional(), bombs: z.record(BombUnitSchema, AtLeastOneNumberSchema).optional(), gold: AtLeastOneNumberSchema.optional(), diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 6fc973e30..7f3acd1cd 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -118,7 +118,7 @@ export interface PlayerUpdate { incomingAttacks: AttackUpdate[]; outgoingAllianceRequests: PlayerID[]; hasSpawned: boolean; - betrayals?: number; + betrayals?: bigint; } export interface AllianceRequestUpdate { diff --git a/src/core/game/Stats.ts b/src/core/game/Stats.ts index 3290efa21..e4f95758b 100644 --- a/src/core/game/Stats.ts +++ b/src/core/game/Stats.ts @@ -7,13 +7,17 @@ export interface Stats { stats(): AllPlayersStats; // Player attacks target - attack(player: Player, target: Player | TerraNullius, troops: number): void; + attack( + player: Player, + target: Player | TerraNullius, + troops: number | bigint, + ): void; // Player cancels attack on target attackCancel( player: Player, target: Player | TerraNullius, - troops: number, + troops: number | bigint, ): void; // Player betrays another player @@ -23,10 +27,14 @@ export interface Stats { boatSendTrade(player: Player, target: Player): void; // Player's trade ship arrives at target, both players earn gold - boatArriveTrade(player: Player, target: Player, gold: number): void; + boatArriveTrade(player: Player, target: Player, gold: number | bigint): void; // Player's trade ship, captured from target, arrives. Player earns gold. - boatCapturedTrade(player: Player, target: Player, gold: number): void; + boatCapturedTrade( + player: Player, + target: Player, + gold: number | bigint, + ): void; // Player destroys target's trade ship boatDestroyTrade(player: Player, target: Player): void; @@ -35,18 +43,22 @@ export interface Stats { boatSendTroops( player: Player, target: Player | TerraNullius, - troops: number, + troops: number | bigint, ): void; // Player's transport ship arrives at target with troops boatArriveTroops( player: Player, target: Player | TerraNullius, - troops: number, + troops: number | bigint, ): void; // Player destroys target's transport ship with troops - boatDestroyTroops(player: Player, target: Player, troops: number): void; + boatDestroyTroops( + player: Player, + target: Player, + troops: number | bigint, + ): void; // Player launches bomb at target bombLaunch( @@ -62,10 +74,10 @@ export interface Stats { bombIntercept(player: Player, attacker: Player, type: NukeType): void; // Player earns gold from conquering tiles or trade ships from captured - goldWar(player: Player, captured: Player, gold: number): void; + goldWar(player: Player, captured: Player, gold: number | bigint): void; // Player earns gold from workers - goldWork(player: Player, gold: number): void; + goldWork(player: Player, gold: number | bigint): void; // Player builds a unit of type unitBuild(player: Player, type: OtherUnitType): void; diff --git a/src/core/game/StatsImpl.ts b/src/core/game/StatsImpl.ts index 3ccd06233..d0b98f53d 100644 --- a/src/core/game/StatsImpl.ts +++ b/src/core/game/StatsImpl.ts @@ -52,21 +52,21 @@ export class StatsImpl implements Stats { return data; } - private _addAttack(player: Player, index: number, value: number) { + private _addAttack(player: Player, index: number, value: number | bigint) { const p = this._makePlayerStats(player); if (p === undefined) return; - if (p.attacks === undefined) p.attacks = [0]; - while (p.attacks.length <= index) p.attacks.push(0); - p.attacks[index] += value; + if (p.attacks === undefined) p.attacks = [0n]; + while (p.attacks.length <= index) p.attacks.push(0n); + p.attacks[index] += BigInt(value); } - private _addBetrayal(player: Player, value: number) { + private _addBetrayal(player: Player, value: number | bigint) { const data = this._makePlayerStats(player); if (data === undefined) return; if (data.betrayals === undefined) { - data.betrayals = value; + data.betrayals = BigInt(value); } else { - data.betrayals += value; + data.betrayals += BigInt(value); } } @@ -74,55 +74,59 @@ export class StatsImpl implements Stats { player: Player, type: BoatUnit, index: number, - value: number, + value: number | bigint, ) { const p = this._makePlayerStats(player); if (p === undefined) return; - if (p.boats === undefined) p.boats = { [type]: [0] }; - if (p.boats[type] === undefined) p.boats[type] = [0]; - while (p.boats[type].length <= index) p.boats[type].push(0); - p.boats[type][index] += value; + if (p.boats === undefined) p.boats = { [type]: [0n] }; + if (p.boats[type] === undefined) p.boats[type] = [0n]; + while (p.boats[type].length <= index) p.boats[type].push(0n); + p.boats[type][index] += BigInt(value); } private _addBomb( player: Player, nukeType: NukeType, index: number, - value: number, + value: number | bigint, ): void { const type = unitTypeToBombUnit[nukeType]; const p = this._makePlayerStats(player); if (p === undefined) return; - if (p.bombs === undefined) p.bombs = { [type]: [0] }; - if (p.bombs[type] === undefined) p.bombs[type] = [0]; - while (p.bombs[type].length <= index) p.bombs[type].push(0); - p.bombs[type][index] += value; + if (p.bombs === undefined) p.bombs = { [type]: [0n] }; + if (p.bombs[type] === undefined) p.bombs[type] = [0n]; + while (p.bombs[type].length <= index) p.bombs[type].push(0n); + p.bombs[type][index] += BigInt(value); } - private _addGold(player: Player, index: number, value: number) { + private _addGold(player: Player, index: number, value: number | bigint) { const p = this._makePlayerStats(player); if (p === undefined) return; - if (p.gold === undefined) p.gold = [0]; - while (p.gold.length <= index) p.gold.push(0); - p.gold[index] += value; + if (p.gold === undefined) p.gold = [0n]; + while (p.gold.length <= index) p.gold.push(0n); + p.gold[index] += BigInt(value); } private _addOtherUnit( player: Player, otherUnitType: OtherUnitType, index: number, - value: number, + value: number | bigint, ) { const type = unitTypeToOtherUnit[otherUnitType]; const p = this._makePlayerStats(player); if (p === undefined) return; - if (p.units === undefined) p.units = { [type]: [0] }; - if (p.units[type] === undefined) p.units[type] = [0]; - while (p.units[type].length <= index) p.units[type].push(0); - p.units[type][index] += value; + if (p.units === undefined) p.units = { [type]: [0n] }; + if (p.units[type] === undefined) p.units[type] = [0n]; + while (p.units[type].length <= index) p.units[type].push(0n); + p.units[type][index] += BigInt(value); } - attack(player: Player, target: Player | TerraNullius, troops: number): void { + attack( + player: Player, + target: Player | TerraNullius, + troops: number | bigint, + ): void { this._addAttack(player, ATTACK_INDEX_SENT, troops); if (target.isPlayer()) { this._addAttack(target, ATTACK_INDEX_RECV, troops); @@ -132,7 +136,7 @@ export class StatsImpl implements Stats { attackCancel( player: Player, target: Player | TerraNullius, - troops: number, + troops: number | bigint, ): void { this._addAttack(player, ATTACK_INDEX_CANCEL, troops); this._addAttack(player, ATTACK_INDEX_SENT, -troops); @@ -149,13 +153,17 @@ export class StatsImpl implements Stats { this._addBoat(player, "trade", BOAT_INDEX_SENT, 1); } - boatArriveTrade(player: Player, target: Player, gold: number): void { + boatArriveTrade(player: Player, target: Player, gold: number | bigint): void { this._addBoat(player, "trade", BOAT_INDEX_ARRIVE, 1); this._addGold(player, GOLD_INDEX_TRADE, gold); this._addGold(target, GOLD_INDEX_TRADE, gold); } - boatCapturedTrade(player: Player, target: Player, gold: number): void { + boatCapturedTrade( + player: Player, + target: Player, + gold: number | bigint, + ): void { this._addBoat(player, "trade", BOAT_INDEX_CAPTURE, 1); this._addGold(player, GOLD_INDEX_STEAL, gold); } @@ -167,7 +175,7 @@ export class StatsImpl implements Stats { boatSendTroops( player: Player, target: Player | TerraNullius, - troops: number, + troops: number | bigint, ): void { this._addBoat(player, "trans", BOAT_INDEX_SENT, 1); } @@ -175,12 +183,16 @@ export class StatsImpl implements Stats { boatArriveTroops( player: Player, target: Player | TerraNullius, - troops: number, + troops: number | bigint, ): void { this._addBoat(player, "trans", BOAT_INDEX_ARRIVE, 1); } - boatDestroyTroops(player: Player, target: Player, troops: number): void { + boatDestroyTroops( + player: Player, + target: Player, + troops: number | bigint, + ): void { this._addBoat(player, "trans", BOAT_INDEX_DESTROY, 1); } @@ -204,11 +216,11 @@ export class StatsImpl implements Stats { this._addBomb(player, type, BOMB_INDEX_INTERCEPT, 1); } - goldWork(player: Player, gold: number): void { + goldWork(player: Player, gold: number | bigint): void { this._addGold(player, GOLD_INDEX_WORK, gold); } - goldWar(player: Player, captured: Player, gold: number): void { + goldWar(player: Player, captured: Player, gold: number | bigint): void { this._addGold(player, GOLD_INDEX_WAR, gold); } diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 75c3d1614..1e3544aff 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -60,7 +60,7 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) { await r2.putObject({ Bucket: bucket, Key: `${analyticsFolder}/${analyticsKey}`, - Body: JSON.stringify(analyticsData), + Body: JSON.stringify(analyticsData, replacer), ContentType: "application/json", }); @@ -78,7 +78,7 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) { async function archiveFullGameToR2(gameRecord: GameRecord) { // Create a deep copy to avoid modifying the original - const recordCopy: GameRecord = JSON.parse(JSON.stringify(gameRecord)); + const recordCopy = structuredClone(gameRecord); // Players may see this so make sure to clear PII recordCopy.info.players.forEach((p) => { @@ -89,7 +89,7 @@ async function archiveFullGameToR2(gameRecord: GameRecord) { await r2.putObject({ Bucket: bucket, Key: `${gameFolder}/${recordCopy.info.gameID}`, - Body: JSON.stringify(recordCopy), + Body: JSON.stringify(recordCopy, replacer), ContentType: "application/json", }); } catch (error) { @@ -147,3 +147,10 @@ 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 new file mode 100644 index 000000000..963e80d14 --- /dev/null +++ b/tests/Stats.test.ts @@ -0,0 +1,232 @@ +import { + Game, + Player, + PlayerInfo, + PlayerType, + UnitType, +} 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 { setup } from "./util/Setup"; + +let stats: Stats; +let game: Game; +let player1: Player; +let player2: Player; + +describe("Stats", () => { + beforeEach(async () => { + stats = new StatsImpl(); + game = await setup("half_land_half_ocean", {}, [ + new PlayerInfo( + "us", + "boat dude", + PlayerType.Human, + "client1", + "player_1_id", + ), + new PlayerInfo( + "us", + "boat dude", + PlayerType.Human, + "client2", + "player_2_id", + ), + ]); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + player1 = game.player("player_1_id"); + player2 = game.player("player_2_id"); + }); + + test("attack", () => { + stats.attack(player1, player2, 1); + expect(stats.stats()).toStrictEqual({ + client1: { + attacks: [1n], + }, + client2: { + attacks: [0n, 1n], + }, + }); + }); + + test("attackCancel", () => { + stats.attackCancel(player1, player2, 1); + expect(stats.stats()).toStrictEqual({ + client1: { + attacks: [-1n, 0n, 1n], + }, + client2: { + attacks: [0n, -1n], + }, + }); + }); + + test("betray", () => { + stats.betray(player1); + expect(stats.stats()).toStrictEqual({ + client1: { + betrayals: 1n, + }, + }); + }); + + test("boatSendTrade", () => { + stats.boatSendTrade(player1, player2); + expect(stats.stats()).toStrictEqual({ + client1: { + boats: { + trade: [1n], + }, + }, + }); + }); + + test("boatArriveTrade", () => { + stats.boatArriveTrade(player1, player2, 1); + expect(stats.stats()).toStrictEqual({ + client1: { + boats: { trade: [0n, 1n] }, + gold: [0n, 0n, 1n], + }, + client2: { + gold: [0n, 0n, 1n], + }, + }); + }); + + test("boatCapturedTrade", () => { + stats.boatCapturedTrade(player1, player2, 1); + expect(stats.stats()).toStrictEqual({ + client1: { + boats: { trade: [0n, 0n, 1n] }, + gold: [0n, 0n, 0n, 1n], + }, + }); + }); + + test("boatDestroyTrade", () => { + stats.boatDestroyTrade(player1, player2); + expect(stats.stats()).toStrictEqual({ + client1: { + boats: { trade: [0n, 0n, 0n, 1n] }, + }, + }); + }); + + test("boatSendTroops", () => { + stats.boatSendTroops(player1, player2, 1); + expect(stats.stats()).toStrictEqual({ + client1: { + boats: { + trans: [1n], + }, + }, + }); + }); + + test("boatArriveTroops", () => { + stats.boatArriveTroops(player1, player2, 1); + expect(stats.stats()).toStrictEqual({ + client1: { + boats: { trans: [0n, 1n] }, + }, + }); + }); + + test("boatDestroyTroops", () => { + stats.boatDestroyTroops(player1, player2, 1); + expect(stats.stats()).toStrictEqual({ + client1: { + boats: { trans: [0n, 0n, 0n, 1n] }, + }, + }); + }); + + test("bombLaunch", () => { + stats.bombLaunch(player1, player2, UnitType.AtomBomb); + expect(stats.stats()).toStrictEqual({ + client1: { bombs: { abomb: [1n] } }, + }); + }); + + test("bombLand", () => { + stats.bombLand(player1, player2, UnitType.HydrogenBomb); + expect(stats.stats()).toStrictEqual({ + client1: { bombs: { hbomb: [0n, 1n] } }, + }); + }); + + test("bombIntercept", () => { + stats.bombIntercept(player1, player2, UnitType.MIRVWarhead); + expect(stats.stats()).toStrictEqual({ + client1: { bombs: { mirvw: [0n, 0n, 1n] } }, + }); + }); + + test("goldWar", () => { + stats.goldWar(player1, player2, 1); + expect(stats.stats()).toStrictEqual({ + client1: { gold: [0n, 1n] }, + }); + }); + + test("goldWork", () => { + stats.goldWork(player1, 1); + expect(stats.stats()).toStrictEqual({ + client1: { gold: [1n] }, + }); + }); + + test("unitBuild", () => { + stats.unitBuild(player1, UnitType.City); + expect(stats.stats()).toStrictEqual({ + client1: { units: { city: [1n] } }, + }); + }); + + test("unitCapture", () => { + stats.unitCapture(player1, UnitType.DefensePost); + expect(stats.stats()).toStrictEqual({ + client1: { + units: { + defp: [0n, 0n, 1n], + }, + }, + }); + }); + + test("unitDestroy", () => { + stats.unitDestroy(player1, UnitType.MissileSilo); + expect(stats.stats()).toStrictEqual({ + client1: { + units: { + silo: [0n, 1n], + }, + }, + }); + }); + + test("unitLose", () => { + stats.unitLose(player1, UnitType.Port); + expect(stats.stats()).toStrictEqual({ + client1: { + units: { + port: [0n, 0n, 0n, 1n], + }, + }, + }); + }); + + test("stringify", () => { + stats.unitLose(player1, UnitType.Port); + expect(JSON.stringify(stats.stats(), replacer)).toBe( + '{"client1":{"units":{"port":["0","0","0","1"]}}}', + ); + }); +});