Combine analytics and game types (#839)

## Description:

Combine analytics and game types. Simplify and remove redundant player
information.

- Remove ip address.
- Add playerID.
- Combine redundant player tables.
- Move game metadata in to GameEndInfo type, an extension of
GameStartInfo

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] 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-22 15:06:30 -04:00
committed by GitHub
parent 373de1a752
commit 9302af868d
14 changed files with 75 additions and 104 deletions
+3 -2
View File
@@ -187,9 +187,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,
@@ -210,7 +211,7 @@ export class ClientGameRunner {
}
const record = createGameRecord(
this.lobby.gameStartInfo.gameID,
this.lobby.gameStartInfo,
this.lobby.gameStartInfo.config,
players,
// Not saving turns locally
[],
+1 -1
View File
@@ -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");
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -291,7 +291,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,
},
() => {
+18 -16
View File
@@ -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<typeof ClientJoinMessageSchema>;
export type ClientLogMessage = z.infer<typeof ClientLogMessageSchema>;
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 Player = z.infer<typeof PlayerSchema>;
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
@@ -442,26 +439,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<typeof PlayerRecordSchema>;
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<typeof GameEndInfoSchema>;
export const AnalyticsRecordSchema = z.object({
info: GameEndInfoSchema,
version: z.literal("v0.0.2"),
gitCommit: z.string(),
});
export type AnalyticsRecord = z.infer<typeof AnalyticsRecordSchema>;
export const GameRecordSchema = AnalyticsRecordSchema.extend({
turns: z.array(TurnSchema),
});
export type GameRecord = z.infer<typeof GameRecordSchema>;
+25 -39
View File
@@ -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: [],
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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";
+18 -34
View File
@@ -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(
+2 -2
View File
@@ -393,7 +393,7 @@ export class GameServer {
);
}
return {
ip: ipAnonymize(client.ip),
playerID: client.playerID,
clientID: client.clientID,
username: client.username,
persistentID: client.persistentID,
@@ -403,7 +403,7 @@ export class GameServer {
archive(
createGameRecord(
this.id,
this.gameStartInfo,
this.gameStartInfo.config,
playerRecords,
this.turns,
this._startTime ?? 0,
-2
View File
@@ -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,