diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 0613624e8..02c6adf1e 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -2,7 +2,13 @@ import { PlayerID, GameMapType, Difficulty, GameType } from "../core/game/Game"; import { EventBus } from "../core/EventBus"; import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; import { InputHandler, MouseUpEvent } from "./InputHandler"; -import { ClientID, GameConfig, GameID, ServerMessage } from "../core/Schemas"; +import { + ClientID, + GameConfig, + GameID, + ServerMessage, + PlayerRecord, +} from "../core/Schemas"; import { loadTerrainMap } from "../core/game/TerrainMapLoader"; import { SendAttackIntentEvent, @@ -15,6 +21,7 @@ import { ErrorUpdate, GameUpdateType, HashUpdate, + WinUpdate, } from "../core/game/GameUpdates"; import { WorkerClient } from "../core/worker/WorkerClient"; import { consolex, initRemoteSender } from "../core/Consolex"; @@ -23,6 +30,8 @@ import { GameView, PlayerView } from "../core/game/GameView"; import { GameUpdateViewData } from "../core/game/GameUpdates"; import { UserSettings } from "../core/game/UserSettings"; import { LocalPersistantStats } from "./LocalPersistantStats"; +import { CreateGameRecord } from "../core/Util"; +import { getPersistentIDFromCookie } from "./Main"; export interface LobbyConfig { serverConfig: ServerConfig; @@ -54,19 +63,21 @@ export function joinLobby( ); const userSettings: UserSettings = new UserSettings(); - let gameConfig: GameConfig = null; - if (lobbyConfig.gameType == GameType.Singleplayer) { - gameConfig = { - gameType: GameType.Singleplayer, - gameMap: lobbyConfig.map, - difficulty: lobbyConfig.difficulty, - disableNPCs: lobbyConfig.disableNPCs, - bots: lobbyConfig.bots, - infiniteGold: lobbyConfig.infiniteGold, - infiniteTroops: lobbyConfig.infiniteTroops, - instantBuild: lobbyConfig.instantBuild, - }; - } + const gameConfig: GameConfig = { + gameType: lobbyConfig.gameType, + gameMap: lobbyConfig.map, + difficulty: lobbyConfig.difficulty, + disableNPCs: lobbyConfig.disableNPCs, + bots: lobbyConfig.bots, + infiniteGold: lobbyConfig.infiniteGold, + infiniteTroops: lobbyConfig.infiniteTroops, + instantBuild: lobbyConfig.instantBuild, + }; + LocalPersistantStats.startGame( + lobbyConfig.gameID, + lobbyConfig.playerID, + gameConfig, + ); const transport = new Transport( lobbyConfig, @@ -138,6 +149,7 @@ export async function createClientGame( ); return new ClientGameRunner( + gameConfig, lobbyConfig, eventBus, gameRenderer, @@ -149,7 +161,6 @@ export async function createClientGame( } export class ClientGameRunner { - private localPersistantsStats = new LocalPersistantStats(); private myPlayer: PlayerView; private isActive = false; @@ -157,6 +168,7 @@ export class ClientGameRunner { private hasJoined = false; constructor( + private gameConfig: GameConfig, private lobby: LobbyConfig, private eventBus: EventBus, private renderer: GameRenderer, @@ -166,8 +178,30 @@ export class ClientGameRunner { private gameView: GameView, ) {} + private saveGame(update: WinUpdate) { + const players: PlayerRecord[] = [ + { + ip: null, + persistentID: getPersistentIDFromCookie(), + username: this.lobby.playerName(), + clientID: this.lobby.clientID, + }, + ]; + const record = CreateGameRecord( + this.lobby.gameID, + this.gameConfig, + players, + // Not saving turns locally + [], + LocalPersistantStats.startTime(), + Date.now(), + this.gameView.playerBySmallID(update.winnerID).id(), + update.allPlayersStats, + ); + LocalPersistantStats.endGame(record); + } + public start() { - this.localPersistantsStats.startGame(this.lobby); consolex.log("starting client game"); this.isActive = true; this.eventBus.on(MouseUpEvent, (e) => this.inputEvent(e)); @@ -184,6 +218,10 @@ export class ClientGameRunner { }); this.gameView.update(gu); this.renderer.tick(); + + if (gu.updates[GameUpdateType.Win].length > 0) { + this.saveGame(gu.updates[GameUpdateType.Win][0]); + } }); const worker = this.worker; const keepWorkerAlive = () => { diff --git a/src/client/LocalPersistantStats.ts b/src/client/LocalPersistantStats.ts index 5cdb9917e..ee3f49f89 100644 --- a/src/client/LocalPersistantStats.ts +++ b/src/client/LocalPersistantStats.ts @@ -1,88 +1,63 @@ import { consolex } from "../core/Consolex"; -import { Difficulty, GameMapType, GameType } from "../core/game/Game"; -import { PlayerStats } from "../core/game/Stats"; -import { ClientID, GameID } from "../core/Schemas"; -import { LobbyConfig } from "./ClientGameRunner"; +import { PlayerID } from "../core/game/Game"; +import { GameConfig, GameID, GameRecord } from "../core/Schemas"; -export interface GameStat { - lobby: { - clientID: ClientID; - persistentID: string; - map: GameMapType | null; - gameType: GameType; - difficulty: Difficulty | null; - infiniteGold: boolean | null; - infiniteTroops: boolean | null; - instantBuild: boolean | null; - bots: number | null; - disableNPCs: boolean | null; - }; - playerStats?: PlayerStats; - outcome?: "victory" | "defeat"; -} - -export class PersistantStats { - // Can be used to handle breaking changes - version: "v0.0.1"; - games: { - [key: GameID]: GameStat; +export interface LocalStatsData { + [key: GameID]: { + playerId: PlayerID; + lobby: GameConfig; + // Only once the game is over + gameRecord?: GameRecord; }; } -export class LocalPersistantStats { - private getStats() { - const statsStr = localStorage.getItem("stats"); - let stats: PersistantStats; - if (!statsStr) { - stats = { version: "v0.0.1", games: {} }; - } else { - stats = JSON.parse(statsStr); - } +export namespace LocalPersistantStats { + let _startTime: number; - return stats; + function getStats(): LocalStatsData { + const statsStr = localStorage.getItem("game-records"); + return statsStr ? JSON.parse(statsStr) : {}; } - public startGame(lobby: LobbyConfig) { + function save(stats: LocalStatsData) { + // To execute asynchronously + setTimeout( + () => localStorage.setItem("game-records", JSON.stringify(stats)), + 0, + ); + } + + // The user can quit the game anytime so better save the lobby as soon as the + // game starts. + export function startGame(id: GameID, playerId: PlayerID, lobby: GameConfig) { if (typeof localStorage === "undefined") { return; } - const stats = this.getStats(); - stats.games[lobby.gameID] = { - lobby: { - clientID: lobby.clientID, - persistentID: lobby.persistentID, - map: lobby.map, - gameType: lobby.gameType, - difficulty: lobby.difficulty, - infiniteGold: lobby.infiniteGold, - infiniteTroops: lobby.infiniteTroops, - instantBuild: lobby.instantBuild, - bots: lobby.bots, - disableNPCs: lobby.disableNPCs, - }, - }; - localStorage.setItem("stats", JSON.stringify(stats)); + _startTime = Date.now(); + const stats = getStats(); + stats[id] = { playerId, lobby }; + save(stats); } - public endGame( - id: GameID, - playerStats: PlayerStats, - outcome: GameStat["outcome"], - ) { + export function startTime() { + return _startTime; + } + + export function endGame(gameRecord: GameRecord) { if (typeof localStorage === "undefined") { return; } - const stats = this.getStats(); - const gameStat = stats.games[id]; + const stats = getStats(); + const gameStat = stats[gameRecord.id]; + if (!gameStat) { - consolex.log("game not found"); + consolex.log("LocalPersistantStats: game not found"); return; } - gameStat.outcome = outcome; - gameStat.playerStats = playerStats; - localStorage.setItem("stats", JSON.stringify(stats)); + gameStat.gameRecord = gameRecord; + save(stats); } } diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index ef3ac8c3c..ad1b374b5 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -2,6 +2,7 @@ import { Config, GameEnv, ServerConfig } from "../core/configuration/Config"; import { consolex } from "../core/Consolex"; import { GameEvent } from "../core/EventBus"; import { + AllPlayersStats, ClientID, ClientMessage, ClientMessageSchema, @@ -17,20 +18,19 @@ import { } from "../core/Schemas"; import { CreateGameRecord, generateID } from "../core/Util"; import { LobbyConfig } from "./ClientGameRunner"; -import { LocalPersistantStats } from "./LocalPersistantStats"; import { getPersistentIDFromCookie } from "./Main"; export class LocalServer { private turns: Turn[] = []; private intents: Intent[] = []; private startedAt: number; - private localPersistantsStats = new LocalPersistantStats(); private endTurnIntervalID; private paused = false; private winner: ClientID | null = null; + private allPlayersStats: AllPlayersStats = {}; constructor( private serverConfig: ServerConfig, @@ -81,6 +81,7 @@ export class LocalServer { } if (clientMsg.type == "winner") { this.winner = clientMsg.winner; + this.allPlayersStats = clientMsg.allPlayersStats; } } @@ -120,6 +121,7 @@ export class LocalServer { this.startedAt, Date.now(), this.winner, + this.allPlayersStats, ); // Clear turns because beacon only supports up to 64kb record.turns = []; diff --git a/src/client/Transport.ts b/src/client/Transport.ts index fb7fd9351..def2ee561 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -25,6 +25,7 @@ import { ClientLogMessageSchema, ClientSendWinnerSchema, ClientMessageSchema, + AllPlayersStats, } from "../core/Schemas"; import { LobbyConfig } from "./ClientGameRunner"; import { LocalServer } from "./LocalServer"; @@ -122,7 +123,10 @@ export class SendSetTargetTroopRatioEvent implements GameEvent { } export class SendWinnerEvent implements GameEvent { - constructor(public readonly winner: ClientID) {} + constructor( + public readonly winner: ClientID, + public readonly allPlayersStats: AllPlayersStats, + ) {} } export class SendHashEvent implements GameEvent { constructor( @@ -480,6 +484,7 @@ export class Transport { persistentID: this.lobbyConfig.persistentID, gameID: this.lobbyConfig.gameID, winner: event.winner, + allPlayersStats: event.allPlayersStats, }); this.sendMsg(JSON.stringify(msg)); } else { diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index 680f66b3a..321da47a0 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -9,7 +9,6 @@ import { PseudoRandom } from "../../../core/PseudoRandom"; import { simpleHash } from "../../../core/Util"; import { EventBus } from "../../../core/EventBus"; import { SendWinnerEvent } from "../../Transport"; -import { GameStat, LocalPersistantStats } from "../../LocalPersistantStats"; // Add this at the top of your file declare global { @@ -28,7 +27,6 @@ export class WinModal extends LitElement implements Layer { private rand: PseudoRandom; private hasShownDeathModal = false; - private localPersistantsStats = new LocalPersistantStats(); @state() isVisible = false; @@ -222,14 +220,6 @@ export class WinModal extends LitElement implements Layer { this.rand = new PseudoRandom(simpleHash(this.game.myClientID())); } - private updateGameStats(outcome: GameStat["outcome"]) { - this.localPersistantsStats.endGame( - this.game.gameID(), - this.game.myPlayer().stats(), - outcome, - ); - } - tick() { const myPlayer = this.game.myPlayer(); if (!this.hasShownDeathModal && myPlayer && !myPlayer.isAlive()) { @@ -240,15 +230,15 @@ export class WinModal extends LitElement implements Layer { } this.game.updatesSinceLastTick()[GameUpdateType.Win].forEach((wu) => { const winner = this.game.playerBySmallID(wu.winnerID) as PlayerView; - this.eventBus.emit(new SendWinnerEvent(winner.clientID())); + this.eventBus.emit( + new SendWinnerEvent(winner.clientID(), wu.allPlayersStats), + ); if (winner == this.game.myPlayer()) { this._title = "You Won!"; this.won = true; - this.updateGameStats("victory"); } else { this._title = `${winner.name()} has won!`; this.won = false; - this.updateGameStats("defeat"); } this.show(); }); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 2d20dd7c5..c17afe9d8 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -77,6 +77,9 @@ export type ClientHashMessage = z.infer; export type PlayerRecord = z.infer; export type GameRecord = z.infer; +export type AllPlayersStats = z.infer; +export type PlayerStats = z.infer; + const PlayerTypeSchema = z.nativeEnum(PlayerType); export interface GameInfo { @@ -132,6 +135,21 @@ const ID = z .regex(/^[a-zA-Z0-9]+$/) .length(8); +const NukesEnum = z.enum([ + "Atom Bomb", + "Hydrogen Bomb", + "MIRV", + "MIRV Warhead", +]); + +const NukeStatsSchema = z.record(NukesEnum, z.number()); + +export const PlayerStatsSchema = z.object({ + sentNukes: z.record(ID, NukeStatsSchema), +}); + +export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema); + // Zod schemas const BaseIntentSchema = z.object({ type: z.enum([ @@ -313,6 +331,7 @@ const ClientBaseMessageSchema = z.object({ export const ClientSendWinnerSchema = ClientBaseMessageSchema.extend({ type: z.literal("winner"), winner: ID.nullable(), + allPlayersStats: AllPlayersStatsSchema, }); export const ClientHashSchema = ClientBaseMessageSchema.extend({ @@ -371,4 +390,6 @@ export const GameRecordSchema = z.object({ num_turns: z.number(), turns: z.array(TurnSchema), winner: ID.nullable(), + allPlayersStats: z.record(ID, PlayerStatsSchema), + version: z.enum(["v0.0.1"]), }); diff --git a/src/core/Util.ts b/src/core/Util.ts index 5fd8b742c..6dd69a070 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -3,11 +3,13 @@ import twemoji from "twemoji"; import DOMPurify from "dompurify"; import { Cell, Game, Player, Unit } from "./game/Game"; import { + AllPlayersStats, ClientID, GameConfig, GameID, GameRecord, PlayerRecord, + PlayerStats, Turn, } from "./Schemas"; import { customAlphabet, nanoid } from "nanoid"; @@ -262,6 +264,7 @@ export function CreateGameRecord( start: number, end: number, winner: ClientID | null, + allPlayersStats: AllPlayersStats, ): GameRecord { const record: GameRecord = { id: id, @@ -270,6 +273,8 @@ export function CreateGameRecord( endTimestampMS: end, date: new Date().toISOString().split("T")[0], turns: [], + allPlayersStats, + version: "v0.0.1", }; for (const turn of turns) { diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index 492c4daf8..1b2645c02 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -33,7 +33,7 @@ export class WinCheckExecution implements Execution { (max.numTilesOwned() / numTilesWithoutFallout) * 100 > this.mg.config().percentageTilesOwnedToWin() ) { - this.mg.setWinner(max); + this.mg.setWinner(max, this.mg.stats().stats()); console.log(`${max.name()} has won the game`); this.active = false; } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 51e76be13..c256600a2 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -1,7 +1,7 @@ import { Config } from "../configuration/Config"; import { GameEvent } from "../EventBus"; import { PlayerView } from "./GameView"; -import { ClientID, GameConfig, GameID } from "../Schemas"; +import { ClientID, GameConfig, GameID, AllPlayersStats } from "../Schemas"; import { GameMap, GameMapImpl, TileRef } from "./GameMap"; import { GameUpdate, @@ -9,7 +9,7 @@ import { PlayerUpdate, UnitUpdate, } from "./GameUpdates"; -import { PlayerStats, Stats } from "./Stats"; +import { Stats } from "./Stats"; export type PlayerID = string; export type Tick = number; @@ -378,7 +378,7 @@ export interface Game extends GameMap { ticks(): Tick; inSpawnPhase(): boolean; executeNextTick(): GameUpdates; - setWinner(winner: Player): void; + setWinner(winner: Player, allPlayersStats: AllPlayersStats): void; config(): Config; // Units diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 35639c676..bdb6819e6 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -24,7 +24,7 @@ import { PlayerImpl } from "./PlayerImpl"; import { TerraNulliusImpl } from "./TerraNulliusImpl"; import { AllianceRequestImpl } from "./AllianceRequestImpl"; import { AllianceImpl } from "./AllianceImpl"; -import { ClientID, GameConfig } from "../Schemas"; +import { ClientID, AllPlayersStats } from "../Schemas"; import { MessageType } from "./Game"; import { UnitImpl } from "./UnitImpl"; import { consolex } from "../Consolex"; @@ -516,10 +516,11 @@ export class GameImpl implements Game { }); } - setWinner(winner: Player): void { + setWinner(winner: Player, allPlayersStats: AllPlayersStats): void { this.addUpdate({ type: GameUpdateType.Win, winnerID: winner.smallID(), + allPlayersStats, }); } diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 80e838c6c..20cc9f1bc 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -1,4 +1,4 @@ -import { ClientID } from "../Schemas"; +import { ClientID, PlayerStats, AllPlayersStats } from "../Schemas"; import { AllianceRequest, EmojiMessage, @@ -12,7 +12,6 @@ import { UnitType, } from "./Game"; import { TileRef, TileUpdate } from "./GameMap"; -import { PlayerStats } from "./Stats"; export interface GameUpdateViewData { tick: number; @@ -156,6 +155,7 @@ export interface DisplayMessageUpdate { export interface WinUpdate { type: GameUpdateType.Win; + allPlayersStats: AllPlayersStats; winnerID: number; } diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 06af71213..2c685c3b0 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -24,13 +24,12 @@ import { UnitInfo, UnitType, } from "./Game"; -import { ClientID, GameID } from "../Schemas"; +import { ClientID, GameID, PlayerStats } from "../Schemas"; import { TerraNulliusImpl } from "./TerraNulliusImpl"; import { WorkerClient } from "../worker/WorkerClient"; import { GameMap, GameMapImpl, TileRef, TileUpdate } from "./GameMap"; import { GameUpdateViewData } from "./GameUpdates"; import { DefenseGrid } from "./DefensePostGrid"; -import { PlayerStats } from "./Stats"; export class UnitView { public _wasUpdated = true; diff --git a/src/core/game/Stats.ts b/src/core/game/Stats.ts index 9a037a603..76888bdbf 100644 --- a/src/core/game/Stats.ts +++ b/src/core/game/Stats.ts @@ -1,15 +1,8 @@ +import { AllPlayersStats, PlayerStats } from "../Schemas"; import { NukeType, PlayerID } from "./Game"; -export interface PlayerStats { - sentNukes: { - // target - [key: PlayerID]: { - [key in NukeType]: number; - }; - }; -} - export interface Stats { increaseNukeCount(sender: PlayerID, target: PlayerID, type: NukeType): void; getPlayerStats(player: PlayerID): PlayerStats; + stats(): AllPlayersStats; } diff --git a/src/core/game/StatsImpl.ts b/src/core/game/StatsImpl.ts index 44752e6d3..c512b6748 100644 --- a/src/core/game/StatsImpl.ts +++ b/src/core/game/StatsImpl.ts @@ -1,13 +1,9 @@ -import { NukeType, Player, PlayerID, UnitType } from "./Game"; -import { PlayerStats, Stats } from "./Stats"; - -interface StatsInternalData { - // player - [key: PlayerID]: PlayerStats; -} +import { AllPlayersStats, PlayerStats } from "../Schemas"; +import { NukeType, PlayerID, UnitType } from "./Game"; +import { Stats } from "./Stats"; export class StatsImpl implements Stats { - data: StatsInternalData = {}; + data: AllPlayersStats = {}; _createUserData(sender: PlayerID, target: PlayerID): void { if (!this.data[sender]) { @@ -31,4 +27,8 @@ export class StatsImpl implements Stats { getPlayerStats(player: PlayerID): PlayerStats { return this.data[player]; } + + stats() { + return this.data; + } } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 07bbe2c37..c787c5a94 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -1,6 +1,7 @@ import { RateLimiterMemory } from "rate-limiter-flexible"; import WebSocket from "ws"; import { + AllPlayersStats, ClientID, ClientMessage, ClientMessageSchema, @@ -45,6 +46,8 @@ export class GameServer { private lastPingUpdate = 0; private winner: ClientID | null = null; + // This field is currently only filled at victory + private allPlayersStats: AllPlayersStats = {}; constructor( public readonly id: string, @@ -162,6 +165,7 @@ export class GameServer { } if (clientMsg.type == "winner") { this.winner = clientMsg.winner; + this.allPlayersStats = clientMsg.allPlayersStats; } } catch (error) { console.log( @@ -298,6 +302,7 @@ export class GameServer { this._startTime, Date.now(), this.winner, + this.allPlayersStats, ), ); } else {