diff --git a/resources/sprites/samExplosion.png b/resources/sprites/samExplosion.png new file mode 100644 index 000000000..0e6cb8544 Binary files /dev/null and b/resources/sprites/samExplosion.png differ diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 50ab4e22a..31e18e8d5 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -188,9 +188,10 @@ export class ClientGameRunner { } private saveGame(update: WinUpdate) { + if (this.myPlayer === null) throw new Error("Not initialized"); const players: PlayerRecord[] = [ { - ip: null, + playerID: this.myPlayer.id(), persistentID: getPersistentIDFromCookie(), username: this.lobby.playerName, clientID: this.lobby.clientID, @@ -211,7 +212,7 @@ export class ClientGameRunner { } const record = createGameRecord( this.lobby.gameStartInfo.gameID, - this.lobby.gameStartInfo, + this.lobby.gameStartInfo.config, players, // Not saving turns locally [], diff --git a/src/client/LocalPersistantStats.ts b/src/client/LocalPersistantStats.ts index 9f8c93bd6..32ac81d72 100644 --- a/src/client/LocalPersistantStats.ts +++ b/src/client/LocalPersistantStats.ts @@ -47,7 +47,7 @@ export function endGame(gameRecord: GameRecord) { } const stats = getStats(); - const gameStat = stats[gameRecord.id]; + const gameStat = stats[gameRecord.info.gameID]; if (!gameStat) { consolex.log("LocalPersistantStats: game not found"); diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 93f910433..0467f1e26 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -176,7 +176,7 @@ export class LocalServer { } const players: PlayerRecord[] = [ { - ip: null, + playerID: this.lobbyConfig.clientID, // hack? persistentID: getPersistentIDFromCookie(), username: this.lobbyConfig.playerName, clientID: this.lobbyConfig.clientID, @@ -188,7 +188,7 @@ export class LocalServer { } const record = createGameRecord( this.lobbyConfig.gameStartInfo.gameID, - this.lobbyConfig.gameStartInfo, + this.lobbyConfig.gameStartInfo.config, players, this.turns, this.startedAt, diff --git a/src/client/Main.ts b/src/client/Main.ts index 55bc6149e..9e8790b16 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -305,7 +305,7 @@ class Client { playerName: this.usernameInput?.getCurrentUsername() ?? "", token: localStorage.getItem("token") ?? getPersistentIDFromCookie(), clientID: lobby.clientID, - gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.gameStartInfo, + gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, }, () => { diff --git a/src/client/graphics/AnimatedSpriteLoader.ts b/src/client/graphics/AnimatedSpriteLoader.ts index 63cb67ff6..42f88f8de 100644 --- a/src/client/graphics/AnimatedSpriteLoader.ts +++ b/src/client/graphics/AnimatedSpriteLoader.ts @@ -1,4 +1,5 @@ import nuke from "../../../resources/sprites/nukeExplosion.png"; +import SAMExplosion from "../../../resources/sprites/samExplosion.png"; import { AnimatedSprite } from "./AnimatedSprite"; import { FxType } from "./fx/Fx"; @@ -22,6 +23,15 @@ const ANIMATED_SPRITE_CONFIG: Partial> = { originX: 30, originY: 30, }, + [FxType.SAMExplosion]: { + url: SAMExplosion, + frameWidth: 48, + frameCount: 9, + frameDuration: 70, + looping: false, + originX: 23, + originY: 19, + }, }; const animatedSpriteImageMap: Map = new Map(); diff --git a/src/client/graphics/fx/Fx.ts b/src/client/graphics/fx/Fx.ts index 8956c4ca4..a80bae2af 100644 --- a/src/client/graphics/fx/Fx.ts +++ b/src/client/graphics/fx/Fx.ts @@ -4,4 +4,5 @@ export interface Fx { export enum FxType { Nuke = "Nuke", + SAMExplosion = "SAMExplosion", } diff --git a/src/client/graphics/fx/SAMExplosionFx.ts b/src/client/graphics/fx/SAMExplosionFx.ts new file mode 100644 index 000000000..3be5c3a79 --- /dev/null +++ b/src/client/graphics/fx/SAMExplosionFx.ts @@ -0,0 +1,34 @@ +import { AnimatedSprite } from "../AnimatedSprite"; +import { createAnimatedSpriteForUnit } from "../AnimatedSpriteLoader"; +import { Fx, FxType } from "./Fx"; + +/** + * Explosion effect: sprite animation of an explosion + */ +export class SAMExplosionFx implements Fx { + private lifeTime: number = 0; + private explosionSprite: AnimatedSprite | null; + constructor( + private x: number, + private y: number, + private duration: number, + ) { + this.explosionSprite = createAnimatedSpriteForUnit(FxType.SAMExplosion); + } + + renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean { + if (this.explosionSprite) { + this.lifeTime += frameTime; + if (this.lifeTime >= this.duration) { + return false; + } + if (this.explosionSprite.isActive()) { + this.explosionSprite.update(frameTime); + this.explosionSprite.draw(ctx, this.x, this.y); + return true; + } + return false; + } + return false; + } +} diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index f19147b65..34b46b458 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -4,6 +4,7 @@ import { GameView, UnitView } from "../../../core/game/GameView"; import { loadAllAnimatedSpriteImages } from "../AnimatedSpriteLoader"; import { Fx } from "../fx/Fx"; import { NukeExplosionFx, ShockwaveFx } from "../fx/NukeFx"; +import { SAMExplosionFx } from "../fx/SAMExplosionFx"; import { Layer } from "./Layer"; export class FxLayer implements Layer { @@ -35,25 +36,43 @@ export class FxLayer implements Layer { switch (unit.type()) { case UnitType.AtomBomb: case UnitType.MIRVWarhead: - this.handleNukeExplosion(unit, 70); + this.handleNukes(unit, 70); break; case UnitType.HydrogenBomb: - this.handleNukeExplosion(unit, 250); + this.handleNukes(unit, 250); break; } } - handleNukeExplosion(unit: UnitView, shockwaveRadius: number) { + handleNukes(unit: UnitView, shockwaveRadius: number) { if (!unit.isActive()) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); - const nuke = new NukeExplosionFx(x, y, 1000); - this.allFx.push(nuke as Fx); - const shockwave = new ShockwaveFx(x, y, 1500, shockwaveRadius); - this.allFx.push(shockwave as Fx); + if (unit.wasInterceptedBySAM()) { + this.handleSAMInterception(unit); + } else { + // Kaboom + this.handleNukeExplosion(unit, shockwaveRadius); + } } } + handleNukeExplosion(unit: UnitView, shockwaveRadius: number) { + const x = this.game.x(unit.lastTile()); + const y = this.game.y(unit.lastTile()); + const nuke = new NukeExplosionFx(x, y, 1000); + this.allFx.push(nuke as Fx); + const shockwave = new ShockwaveFx(x, y, 1500, shockwaveRadius); + this.allFx.push(shockwave as Fx); + } + + handleSAMInterception(unit: UnitView) { + const x = this.game.x(unit.lastTile()); + const y = this.game.y(unit.lastTile()); + const interception = new SAMExplosionFx(x, y, 1000); + this.allFx.push(interception as Fx); + const shockwave = new ShockwaveFx(x, y, 800, 40); + this.allFx.push(shockwave as Fx); + } + async init() { this.redraw(); try { diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index bfe495838..b373be063 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -1,6 +1,5 @@ import { z } from "zod"; import quickChatData from "../../resources/QuickChat.json" with { type: "json" }; -import { PlayerStatsSchema } from "./ArchiveSchemas"; import { AllPlayers, Difficulty, @@ -11,6 +10,7 @@ import { PlayerType, UnitType, } from "./game/Game"; +import { PlayerStatsSchema } from "./StatsSchemas"; import { flattenedEmojiTable } from "./Util"; export type GameID = string; @@ -88,9 +88,6 @@ export type ClientJoinMessage = z.infer; export type ClientLogMessage = z.infer; export type ClientHashMessage = z.infer; -export type PlayerRecord = z.infer; -export type GameRecord = z.infer; - export type AllPlayersStats = z.infer; export type Player = z.infer; export type GameStartInfo = z.infer; @@ -445,26 +442,31 @@ export const ClientMessageSchema = z.union([ ClientHashSchema, ]); -export const PlayerRecordSchema = z.object({ - clientID: ID, - username: SafeString, - ip: SafeString.nullable(), // WARNING: PII +export const PlayerRecordSchema = PlayerSchema.extend({ persistentID: PersistentIdSchema, // WARNING: PII stats: PlayerStatsSchema, }); +export type PlayerRecord = z.infer; -export const GameRecordSchema = z.object({ - id: ID, - gameStartInfo: GameStartInfoSchema, +export const GameEndInfoSchema = GameStartInfoSchema.extend({ players: z.array(PlayerRecordSchema), - startTimestampMS: z.number(), - endTimestampMS: z.number(), - durationSeconds: z.number(), - date: SafeString, + start: z.number(), + end: z.number(), + duration: z.number().nonnegative(), num_turns: z.number(), - turns: z.array(TurnSchema), winner: z.union([ID, SafeString]).nullable().optional(), winnerType: z.enum(["player", "team"]).nullable().optional(), +}); +export type GameEndInfo = z.infer; + +export const AnalyticsRecordSchema = z.object({ + info: GameEndInfoSchema, version: z.literal("v0.0.2"), gitCommit: z.string(), }); +export type AnalyticsRecord = z.infer; + +export const GameRecordSchema = AnalyticsRecordSchema.extend({ + turns: z.array(TurnSchema), +}); +export type GameRecord = z.infer; diff --git a/src/core/ArchiveSchemas.ts b/src/core/StatsSchemas.ts similarity index 100% rename from src/core/ArchiveSchemas.ts rename to src/core/StatsSchemas.ts diff --git a/src/core/Util.ts b/src/core/Util.ts index a793d8742..8cea53baf 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -5,9 +5,9 @@ import { Cell, Team, Unit } from "./game/Game"; import { GameMap, TileRef } from "./game/GameMap"; import { ClientID, + GameConfig, GameID, GameRecord, - GameStartInfo, PlayerRecord, Turn, } from "./Schemas"; @@ -184,53 +184,39 @@ export function onlyImages(html: string) { } export function createGameRecord( - id: GameID, - gameStartInfo: GameStartInfo, + gameID: GameID, + config: GameConfig, // username does not need to be set. players: PlayerRecord[], - turns: Turn[], - startTimestampMS: number, - endTimestampMS: number, + allTurns: Turn[], + start: number, + end: number, winner: ClientID | Team | null, winnerType: "player" | "team" | null, ): GameRecord { - const durationSeconds = Math.floor( - (endTimestampMS - startTimestampMS) / 1000, - ); - const date = new Date().toISOString().split("T")[0]; + const duration = Math.floor((end - start) / 1000); const version = "v0.0.2"; const gitCommit = ""; + const num_turns = allTurns.length; + const turns = allTurns.filter( + (t) => t.intents.length !== 0 || t.hash !== undefined, + ); const record: GameRecord = { - gitCommit, - id, - gameStartInfo, - players, - startTimestampMS, - endTimestampMS, - durationSeconds, - date, - num_turns: 0, - turns: [], + info: { + gameID, + config, + players, + start, + end, + duration, + num_turns, + winner, + winnerType, + }, version, - winner, - winnerType, + gitCommit, + turns, }; - - for (const turn of turns) { - if (turn.intents.length !== 0 || turn.hash !== undefined) { - record.turns.push(turn); - for (const intent of turn.intents) { - if (intent.type === "spawn") { - for (const playerRecord of players) { - if (playerRecord.clientID === intent.clientID) { - playerRecord.username = intent.name; - } - } - } - } - } - } - record.num_turns = turns.length; return record; } @@ -249,7 +235,7 @@ export function decompressGameRecord(gameRecord: GameRecord) { lastTurnNum = turn.turnNumber; } const turnLength = turns.length; - for (let i = turnLength; i < gameRecord.num_turns; i++) { + for (let i = turnLength; i < gameRecord.info.num_turns; i++) { turns.push({ turnNumber: i, intents: [], diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index a7ed24478..74e38ca35 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -1,4 +1,3 @@ -import { NukeType } from "../ArchiveSchemas"; import { consolex } from "../Consolex"; import { Execution, @@ -13,6 +12,7 @@ import { import { TileRef } from "../game/GameMap"; import { ParabolaPathFinder } from "../pathfinding/PathFinding"; import { PseudoRandom } from "../PseudoRandom"; +import { NukeType } from "../StatsSchemas"; export class NukeExecution implements Execution { private active = true; diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 2439f4462..9910a6aa3 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -180,7 +180,10 @@ export class SAMLauncherExecution implements Execution { this.sam.owner().id(), ); // Delete warheads - mirvWarheadTargets.forEach((u) => u.delete()); + mirvWarheadTargets.forEach((u) => { + u.setInterceptedBySam(); + u.delete(); + }); } else if (target !== null) { target.setTargetedBySAM(true); this.mg.addExecution( diff --git a/src/core/execution/SAMMissileExecution.ts b/src/core/execution/SAMMissileExecution.ts index e33bd9b61..9e1ad5b0d 100644 --- a/src/core/execution/SAMMissileExecution.ts +++ b/src/core/execution/SAMMissileExecution.ts @@ -1,4 +1,3 @@ -import { NukeType } from "../ArchiveSchemas"; import { Execution, Game, @@ -10,6 +9,7 @@ import { import { TileRef } from "../game/GameMap"; import { AirPathFinder } from "../pathfinding/PathFinding"; import { PseudoRandom } from "../PseudoRandom"; +import { NukeType } from "../StatsSchemas"; export class SAMMissileExecution implements Execution { private active = true; @@ -66,6 +66,7 @@ export class SAMMissileExecution implements Execution { this._owner.id(), ); this.active = false; + this.target.setInterceptedBySam(); this.target.delete(true, this._owner); this.SAMMissile.delete(false); diff --git a/src/core/execution/utils/BotNames.ts b/src/core/execution/utils/BotNames.ts index 59133b5ce..72e30aaf8 100644 --- a/src/core/execution/utils/BotNames.ts +++ b/src/core/execution/utils/BotNames.ts @@ -171,6 +171,13 @@ export const BOT_NAME_PREFIXES = [ "Baloch", "Afghan", "Persian", + "Kenyan", + "Ugandan", + "Bhutanese", + "Latin", + "Moldovan", + "Militant", + "Spartan", ]; export const BOT_NAME_SUFFIXES = [ "Empire", @@ -224,9 +231,24 @@ export const BOT_NAME_SUFFIXES = [ "Ascendancy", "Supremacy", "Province", - "Kingdoms", - "Tribes", + "Tribe", "Dominion", "Assembly", "Republics", + "Army", + "Dictatorship", + "Country", + "Oligarchy", + "Monkdom", + "Throng", + "Host", + "Area", + "District", + "Fief", + "Wilderness", + "Settlement", + "Parliament", + "Anarchy", + "Democracy", + "Autocracy", ]; diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index f1f4258f8..8a22a1ffd 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -346,6 +346,8 @@ export interface Unit { targetUnit(): Unit | undefined; setTargetedBySAM(targeted: boolean): void; targetedBySAM(): boolean; + setInterceptedBySam(): void; + interceptedBySam(): boolean; // Health hasHealth(): boolean; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 32824d17d..632618bab 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -73,6 +73,7 @@ export interface UnitUpdate { pos: TileRef; lastPos: TileRef; isActive: boolean; + wasIntercepted: boolean; retreating: boolean; targetUnitId?: number; // Only for trade ships targetTile?: TileRef; // Only for nukes diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 0935a0a02..693adf8f2 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -93,6 +93,9 @@ export class UnitView { isActive(): boolean { return this.data.isActive; } + wasInterceptedBySAM(): boolean { + return this.data.wasIntercepted; + } hasHealth(): boolean { return this.data.health !== undefined; } diff --git a/src/core/game/Stats.ts b/src/core/game/Stats.ts index 8a6058027..3290efa21 100644 --- a/src/core/game/Stats.ts +++ b/src/core/game/Stats.ts @@ -1,5 +1,5 @@ -import { NukeType, OtherUnitType, PlayerStats } from "../ArchiveSchemas"; import { AllPlayersStats } from "../Schemas"; +import { NukeType, OtherUnitType, PlayerStats } from "../StatsSchemas"; import { Player, TerraNullius } from "./Game"; export interface Stats { diff --git a/src/core/game/StatsImpl.ts b/src/core/game/StatsImpl.ts index c2bc66a64..e81f1d74c 100644 --- a/src/core/game/StatsImpl.ts +++ b/src/core/game/StatsImpl.ts @@ -1,3 +1,4 @@ +import { AllPlayersStats } from "../Schemas"; import { ATTACK_INDEX_CANCEL, ATTACK_INDEX_RECV, @@ -23,8 +24,7 @@ import { PlayerStats, unitTypeToBombUnit, unitTypeToOtherUnit, -} from "../ArchiveSchemas"; -import { AllPlayersStats } from "../Schemas"; +} from "../StatsSchemas"; import { Player, TerraNullius } from "./Game"; import { Stats } from "./Stats"; diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 44564880c..a68f566d7 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -21,6 +21,7 @@ export class UnitImpl implements Unit { private _lastTile: TileRef; private _retreating: boolean = false; private _targetedBySAM = false; + private _interceptedBySAM = false; private _lastSetSafeFromPirates: number; // Only for trade ships private _constructionType: UnitType | undefined; private _lastOwner: PlayerImpl | null = null; @@ -85,6 +86,7 @@ export class UnitImpl implements Unit { ownerID: this._owner.smallID(), lastOwnerID: this._lastOwner?.smallID(), isActive: this._active, + wasIntercepted: this._interceptedBySAM, retreating: this._retreating, pos: this._tile, lastPos: this._lastTile, @@ -304,6 +306,14 @@ export class UnitImpl implements Unit { return this._targetedBySAM; } + setInterceptedBySam(): void { + this._interceptedBySAM = true; + } + + interceptedBySam(): boolean { + return this._interceptedBySAM; + } + setSafeFromPirates(): void { this._lastSetSafeFromPirates = this.mg.ticks(); } diff --git a/src/server/Archive.ts b/src/server/Archive.ts index bee248213..f2fb29a0a 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -1,6 +1,6 @@ import { S3 } from "@aws-sdk/client-s3"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; -import { GameID, GameRecord } from "../core/Schemas"; +import { AnalyticsRecord, GameID, GameRecord } from "../core/Schemas"; import { logger } from "./Logger"; const config = getServerConfigFromServer(); @@ -30,12 +30,12 @@ export async function archive(gameRecord: GameRecord) { // Archive full game if there are turns if (gameRecord.turns.length > 0) { log.info( - `${gameRecord.id}: game has more than zero turns, attempting to write to full game to R2`, + `${gameRecord.info.gameID}: game has more than zero turns, attempting to write to full game to R2`, ); await archiveFullGameToR2(gameRecord); } } catch (error) { - log.error(`${gameRecord.id}: Final archive error: ${error}`, { + log.error(`${gameRecord.info.gameID}: Final archive error: ${error}`, { message: error?.message || error, stack: error?.stack, name: error?.name, @@ -46,29 +46,16 @@ export async function archive(gameRecord: GameRecord) { async function archiveAnalyticsToR2(gameRecord: GameRecord) { // Create analytics data object - const analyticsData = { - id: gameRecord.id, - env: config.env(), - start_time: new Date(gameRecord.startTimestampMS).toISOString(), - end_time: new Date(gameRecord.endTimestampMS).toISOString(), - duration_seconds: gameRecord.durationSeconds, - number_turns: gameRecord.num_turns, - game_mode: gameRecord.gameStartInfo.config.gameType, - winner: gameRecord.winner, - difficulty: gameRecord.gameStartInfo.config.difficulty, - mapType: gameRecord.gameStartInfo.config.gameMap, - players: gameRecord.players.map((p) => ({ - username: p.username, - ip: p.ip, - persistentID: p.persistentID, - clientID: p.clientID, - stats: p.stats, - })), + const { info, version, gitCommit } = gameRecord; + const analyticsData: AnalyticsRecord = { + info, + version, + gitCommit, }; try { // Store analytics data using just the game ID as the key - const analyticsKey = `${gameRecord.id}.json`; + const analyticsKey = `${info.gameID}.json`; await r2.putObject({ Bucket: bucket, @@ -77,17 +64,14 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) { ContentType: "application/json", }); - log.info(`${gameRecord.id}: successfully wrote game analytics to R2`); + log.info(`${info.gameID}: successfully wrote game analytics to R2`); } catch (error) { - log.error( - `${gameRecord.id}: Error writing game analytics to R2: ${error}`, - { - message: error?.message || error, - stack: error?.stack, - name: error?.name, - ...(error && typeof error === "object" ? error : {}), - }, - ); + log.error(`${info.gameID}: Error writing game analytics to R2: ${error}`, { + message: error?.message || error, + stack: error?.stack, + name: error?.name, + ...(error && typeof error === "object" ? error : {}), + }); throw error; } } @@ -110,11 +94,11 @@ async function archiveFullGameToR2(gameRecord: GameRecord) { ContentType: "application/json", }); } catch (error) { - log.error(`error saving game ${gameRecord.id}`); + log.error(`error saving game ${gameRecord.info.gameID}`); throw error; } - log.info(`${gameRecord.id}: game record successfully written to R2`); + log.info(`${gameRecord.info.gameID}: game record successfully written to R2`); } export async function readGameRecord( diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index bfb2e8f73..b82a8e62b 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -394,7 +394,7 @@ export class GameServer { ); } return { - ip: ipAnonymize(client.ip), + playerID: client.playerID, clientID: client.clientID, username: client.username, persistentID: client.persistentID, @@ -404,7 +404,7 @@ export class GameServer { archive( createGameRecord( this.id, - this.gameStartInfo, + this.gameStartInfo.config, playerRecords, this.turns, this._startTime ?? 0, diff --git a/src/server/Worker.ts b/src/server/Worker.ts index ed4a5baeb..70c87cf10 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -242,14 +242,12 @@ export function startWorker() { "/api/archive_singleplayer_game", gatekeeper.httpHandler(LimiterType.Post, async (req, res) => { const gameRecord: GameRecord = req.body; - const clientIP = req.ip || req.socket.remoteAddress || "unknown"; if (!gameRecord) { log.info("game record not found in request"); res.status(404).json({ error: "Game record not found" }); return; } - gameRecord.players.forEach((p) => (p.ip = clientIP)); archive(gameRecord); res.json({ success: true,