feat: remove LocalPersistantStats so we locally save GameRecords

GameRecords also now include PlayerStats
This commit is contained in:
ilan schemoul
2025-03-08 17:30:08 +01:00
parent 2b8cc2cf28
commit 1b76c46bc5
15 changed files with 157 additions and 123 deletions
+54 -16
View File
@@ -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 = () => {
+39 -64
View File
@@ -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);
}
}
+4 -2
View File
@@ -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 = [];
+6 -1
View File
@@ -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 {
+3 -13
View File
@@ -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();
});
+21
View File
@@ -77,6 +77,9 @@ export type ClientHashMessage = z.infer<typeof ClientHashSchema>;
export type PlayerRecord = z.infer<typeof PlayerRecordSchema>;
export type GameRecord = z.infer<typeof GameRecordSchema>;
export type AllPlayersStats = z.infer<typeof AllPlayersStatsSchema>;
export type PlayerStats = z.infer<typeof PlayerStatsSchema>;
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"]),
});
+5
View File
@@ -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) {
+1 -1
View File
@@ -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;
}
+3 -3
View File
@@ -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
+3 -2
View File
@@ -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,
});
}
+2 -2
View File
@@ -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;
}
+1 -2
View File
@@ -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;
+2 -9
View File
@@ -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;
}
+8 -8
View File
@@ -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;
}
}
+5
View File
@@ -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 {