feat: remove LocalPersistantStats so we locally save GameRecords

GameRecords also now include PlayerStats
This commit is contained in:
ilan schemoul
2025-03-08 17:39:41 +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();
});