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>
This commit is contained in:
Scott Anderson
2025-05-27 22:13:05 -04:00
committed by GitHub
parent 0b79d0be16
commit 8cf2d86a70
6 changed files with 313 additions and 50 deletions
+2 -2
View File
@@ -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<typeof AtLeastOneNumberSchema>;
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(),
+1 -1
View File
@@ -118,7 +118,7 @@ export interface PlayerUpdate {
incomingAttacks: AttackUpdate[];
outgoingAllianceRequests: PlayerID[];
hasSpawned: boolean;
betrayals?: number;
betrayals?: bigint;
}
export interface AllianceRequestUpdate {
+21 -9
View File
@@ -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;
+47 -35
View File
@@ -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);
}
+10 -3
View File
@@ -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<boolean> {
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;
}
+232
View File
@@ -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"]}}}',
);
});
});