record game metadata to gcs

This commit is contained in:
Evan
2024-12-06 14:39:31 -08:00
parent b8db74247f
commit cd09c0a1d6
6 changed files with 92 additions and 12 deletions
+8 -1
View File
@@ -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
+19 -4
View File
@@ -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<typeof ClientPingMessageSchema>
export type ClientIntentMessage = z.infer<typeof ClientIntentMessageSchema>
export type ClientJoinMessage = z.infer<typeof ClientJoinMessageSchema>
export type GameRecord = z.infer<typeof GameRecordSchema>
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]);
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)
})
+24
View File
@@ -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<string>()
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);
}
+6
View File
@@ -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.
+23 -3
View File
@@ -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 => {
+12 -4
View File
@@ -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'
});
}