From cd09c0a1d6f8f679b0a9b1607686ea066e8679fe Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 6 Dec 2024 14:39:31 -0800 Subject: [PATCH] record game metadata to gcs --- TODO.txt | 9 ++++++++- src/core/Schemas.ts | 23 +++++++++++++++++++---- src/core/Util.ts | 24 ++++++++++++++++++++++++ src/core/game/Game.ts | 6 ++++++ src/server/GameManager.ts | 26 +++++++++++++++++++++++--- src/server/GameServer.ts | 16 ++++++++++++---- 6 files changed, 92 insertions(+), 12 deletions(-) diff --git a/TODO.txt b/TODO.txt index 29376d42a..c735daea4 100644 --- a/TODO.txt +++ b/TODO.txt @@ -209,12 +209,19 @@ * log stack traces & display them on screen DONE 12/3/2024 * add radiation from nuke DONE 12/4/2024 * add cities DONE 12/4/2024 +* write multiplayer games to GCS DONE 12/6/2024 +* write single player games to GCS +* record game winner +* record game difficulty +* standardize game ids +* record commit hash of game +* store metadata in BigQuery +* replay stored games * max price for units * when player dies, don't remove atom bombs * record and replay games for debugging purposes * add bug report button in game * bugfix: destroyers can't find path to dst and freeze -* record single player game stats * alert on attack * alert on unit captured or destroyed * stop requesting lobby when playing game diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 3e352a60d..3754cfdf8 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { Difficulty, GameMap, PlayerType, UnitType } from './game/Game'; +import { Difficulty, GameMap, GameType, PlayerType, UnitType } from './game/Game'; export type GameID = string export type ClientID = string @@ -41,6 +41,8 @@ export type ClientPingMessage = z.infer export type ClientIntentMessage = z.infer export type ClientJoinMessage = z.infer +export type GameRecord = z.infer + const PlayerTypeSchema = z.nativeEnum(PlayerType); // TODO: create Cell schema @@ -53,7 +55,8 @@ export interface Lobby { const GameConfigSchema = z.object({ gameMap: z.nativeEnum(GameMap), - difficulty: z.nativeEnum(Difficulty) + difficulty: z.nativeEnum(Difficulty), + gameType: z.nativeEnum(GameType) }) const EmojiSchema = z.string().refine( @@ -64,6 +67,8 @@ const EmojiSchema = z.string().refine( message: "Must contain at least one emoji character" } ); + + // Zod schemas const BaseIntentSchema = z.object({ type: z.enum(['attack', 'spawn', 'boat', 'name', 'targetPlayer', 'emoji', 'troop_ratio', 'build_unit']), @@ -81,7 +86,6 @@ export const AttackIntentSchema = BaseIntentSchema.extend({ targetY: z.number().nullable() }); - export const SpawnIntentSchema = BaseIntentSchema.extend({ type: z.literal('spawn'), playerID: z.string(), @@ -223,4 +227,15 @@ export const ClientJoinMessageSchema = ClientBaseMessageSchema.extend({ lastTurn: z.number() // The last turn the client saw. }) -export const ClientMessageSchema = z.union([ClientPingMessageSchema, ClientIntentMessageSchema, ClientJoinMessageSchema]); \ No newline at end of file +export const ClientMessageSchema = z.union([ClientPingMessageSchema, ClientIntentMessageSchema, ClientJoinMessageSchema]); + +export const GameRecordSchema = z.object({ + id: z.string(), + gameConfig: GameConfigSchema, + startTimestampMS: z.number(), + endTimestampMS: z.number(), + durationSeconds: z.number(), + date: z.string(), + usernames: z.array(z.string()), + turns: z.array(TurnSchema) +}) \ No newline at end of file diff --git a/src/core/Util.ts b/src/core/Util.ts index f49cd57c9..887943fe4 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -5,6 +5,7 @@ import DOMPurify from 'dompurify'; import { Cell, Game, Player, TerraNullius, Tile, Unit } from "./game/Game"; import { number } from 'zod'; +import { GameRecord } from './Schemas'; export function manhattanDist(c1: Cell, c2: Cell): number { return Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y); @@ -222,6 +223,29 @@ export function onlyImages(html: string) { }); } +export function ProcessGameRecord(record: GameRecord): GameRecord { + const packed: GameRecord = structuredClone(record); + packed.turns = [] + const usernames = new Set() + for (const turn of record.turns) { + if (turn.intents.length != 0) { + packed.turns.push(turn) + for (const intent of turn.intents) { + if (intent.type == 'spawn') { + usernames.add(intent.name) + } + } + } + } + packed.usernames = Array.from(usernames) + packed.durationSeconds = Math.floor((record.endTimestampMS - record.startTimestampMS) / 1000) + return packed; +} + +export function ToBigQuery(record: GameRecord) { + +} + export function assertNever(x: never): never { throw new Error('Unexpected value: ' + x); } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index d940ab40f..f7e65d9a6 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -23,6 +23,12 @@ export enum GameMap { Mena } +export enum GameType { + Singleplayer, + Public, + Private, +} + export interface UnitInfo { cost: (player: Player) => Gold // Determines if its owner changes when its tile is conquered. diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index ea8ee1c1e..7e8bd3253 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -3,7 +3,7 @@ import { ClientID, GameConfig, GameID } from "../core/Schemas"; import { v4 as uuidv4 } from 'uuid'; import { Client } from "./Client"; import { GamePhase, GameServer } from "./GameServer"; -import { Difficulty, GameMap } from "../core/game/Game"; +import { Difficulty, GameMap, GameType } from "../core/game/Game"; @@ -39,7 +39,17 @@ export class GameManager { createPrivateGame(): string { const id = genSmallGameID() - this.games.push(new GameServer(id, Date.now(), false, this.config, { gameMap: GameMap.World, difficulty: Difficulty.Medium })) + this.games.push(new GameServer( + id, + Date.now(), + false, + this.config, + { + gameMap: GameMap.World, + gameType: GameType.Private, + difficulty: Difficulty.Medium + } + )) return id } @@ -68,7 +78,17 @@ export class GameManager { if (now > this.lastNewLobby + this.config.gameCreationRate()) { this.lastNewLobby = now const id = uuidv4() - lobbies.push(new GameServer(id, now, true, this.config, { gameMap: GameMap.World, difficulty: Difficulty.Medium })) + lobbies.push(new GameServer( + id, + now, + true, + this.config, + { + gameMap: GameMap.World, + gameType: GameType.Public, + difficulty: Difficulty.Medium + } + )) } active.filter(g => !g.hasStarted() && g.isPublic).forEach(g => { diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 8e88be7f9..49e32ad16 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -1,9 +1,10 @@ -import { ClientMessage, ClientMessageSchema, GameConfig, Intent, ServerStartGameMessage, ServerStartGameMessageSchema, ServerTurnMessageSchema, Turn } from "../core/Schemas"; +import { ClientMessage, ClientMessageSchema, GameConfig, GameRecordSchema, Intent, ServerStartGameMessage, ServerStartGameMessageSchema, ServerTurnMessageSchema, Turn } from "../core/Schemas"; import { Config } from "../core/configuration/Config"; import { Client } from "./Client"; import WebSocket from 'ws'; import { slog } from "./StructuredLog"; import { Storage } from '@google-cloud/storage'; +import { ProcessGameRecord as ProcessRecord } from "../core/Util"; const storage = new Storage(); @@ -22,6 +23,7 @@ export class GameServer { private intents: Intent[] = [] private clients: Client[] = [] private _hasStarted = false + private _startTime: number = null private endTurnIntervalID @@ -88,11 +90,13 @@ export class GameServer { } public startTime(): number { - return this.createdAt + this.config.lobbyLifetime() + return this._startTime } public start() { this._hasStarted = true + this._startTime = Date.now() + this.clients.forEach(c => { console.log(`game ${this.id} sending start message to ${c.id}`) this.sendStartGameMsg(c.ws, 0) @@ -138,7 +142,7 @@ export class GameServer { // Close all WebSocket connections clearInterval(this.endTurnIntervalID); this.clients.forEach(client => { - client.ws.removeAllListeners('message'); + client.ws.removeAllListeners('message'); // TODO: remove this? if (client.ws.readyState === WebSocket.OPEN) { client.ws.close(1000, "game has ended"); } @@ -151,10 +155,14 @@ export class GameServer { const file = bucket.file(this.id); const game = { id: this.id, + gameConfig: this.gameConfig, + startTimestampMS: this._startTime, + endTimestampMS: Date.now(), date: new Date().toISOString().split('T')[0], turns: this.turns } - await file.save(JSON.stringify(game), { + const processed = ProcessRecord(game) + await file.save(JSON.stringify(GameRecordSchema.parse(processed)), { contentType: 'application/json' }); }