diff --git a/TODO.txt b/TODO.txt index c735daea4..0f558c6ae 100644 --- a/TODO.txt +++ b/TODO.txt @@ -211,14 +211,17 @@ * add cities DONE 12/4/2024 * write multiplayer games to GCS DONE 12/6/2024 * write single player games to GCS +* bugfix: private game host game doesn't start * record game winner * record game difficulty +* bufix: mini map doesn't load in time * 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 +* remove alliance when player dies * record and replay games for debugging purposes * add bug report button in game * bugfix: destroyers can't find path to dst and freeze diff --git a/package-lock.json b/package-lock.json index fbbc30ad3..06a1a2f17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "jimp": "^0.22.12", "lit": "^3.2.1", "msgpack5": "^6.0.2", + "nanoid": "^5.0.9", "node-addon-api": "^8.1.0", "node-gyp": "^10.2.0", "priority-queue-typescript": "^1.0.1", @@ -10719,10 +10720,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", + "integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==", "funding": [ { "type": "github", @@ -10731,10 +10731,10 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/natural-compare": { @@ -11598,6 +11598,25 @@ "dev": true, "license": "MIT" }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/pretty-error": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", diff --git a/package.json b/package.json index ca13da352..6dc47bfcf 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "jimp": "^0.22.12", "lit": "^3.2.1", "msgpack5": "^6.0.2", + "nanoid": "^5.0.9", "node-addon-api": "^8.1.0", "node-gyp": "^10.2.0", "priority-queue-typescript": "^1.0.1", diff --git a/src/client/GameRunner.ts b/src/client/GameRunner.ts index 4f3b18c9a..a3923a87a 100644 --- a/src/client/GameRunner.ts +++ b/src/client/GameRunner.ts @@ -30,8 +30,19 @@ export function joinLobby(lobbyConfig: LobbyConfig, onjoin: () => void): () => v const playerID = uuidv4() const eventBus = new EventBus() const config = getConfig() + + let gameConfig: GameConfig = null + if (lobbyConfig.gameType == GameType.Singleplayer) { + gameConfig = { + gameType: GameType.Singleplayer, + gameMap: lobbyConfig.map, + difficulty: lobbyConfig.difficulty, + } + } + const transport = new Transport( lobbyConfig.gameType == GameType.Singleplayer, + gameConfig, eventBus, lobbyConfig.gameID, lobbyConfig.ip, @@ -49,12 +60,7 @@ export function joinLobby(lobbyConfig: LobbyConfig, onjoin: () => void): () => v if (message.type == "start") { console.log('lobby: game started') onjoin() - const gameConfig = { - gameMap: message.config?.gameMap || lobbyConfig.map, - difficulty: message.config?.difficulty || lobbyConfig.difficulty, - gameType: lobbyConfig.gameType - } - createClientGame(gameConfig, eventBus, transport, lobbyConfig.gameID, clientID).then(r => r.start()) + createClientGame(message.config, eventBus, transport, lobbyConfig.gameID, clientID).then(r => r.start()) }; } transport.connect(onconnect, onmessage) diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index e45f55c45..28562adfb 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -1,26 +1,31 @@ -import {Config} from "../core/configuration/Config"; -import {ClientMessage, ClientMessageSchema, Intent, ServerMessage, ServerTurnMessageSchema, Turn} from "../core/Schemas"; +import { Config } from "../core/configuration/Config"; +import { ClientMessage, ClientMessageSchema, GameConfig, GameID, GameRecordSchema, Intent, ServerMessage, ServerStartGameMessageSchema, ServerTurnMessageSchema, Turn } from "../core/Schemas"; +import { CreateGameRecord, generateGameID } from "../core/Util"; export class LocalServer { - private gameID = "LOCAL" - private turns: Turn[] = [] private intents: Intent[] = [] + private startedAt: number private endTurnIntervalID - constructor(private config: Config, private clientConnect: () => void, private clientMessage: (message: ServerMessage) => void) { + private gameID: GameID + + constructor(private config: Config, private gameConfig: GameConfig, private clientConnect: () => void, private clientMessage: (message: ServerMessage) => void) { + this.gameID = generateGameID() } start() { + this.startedAt = Date.now() this.endTurnIntervalID = setInterval(() => this.endTurn(), this.config.turnIntervalMs()); this.clientConnect() - this.clientMessage({ + this.clientMessage(ServerStartGameMessageSchema.parse({ type: "start", + config: this.gameConfig, turns: [], - }) + })) } onMessage(message: string) { @@ -43,4 +48,15 @@ export class LocalServer { turn: pastTurn }) } + + public endGame() { + console.log('local server ending game') + clearInterval(this.endTurnIntervalID) + const record = CreateGameRecord(this.gameID, this.gameConfig, this.turns, this.startedAt, Date.now()) + // For unload events, sendBeacon is the only reliable method + const blob = new Blob([JSON.stringify(GameRecordSchema.parse(record))], { + type: 'application/json' + }); + navigator.sendBeacon('/archive_singleplayer_game', blob); + } } \ No newline at end of file diff --git a/src/client/Main.ts b/src/client/Main.ts index 010303384..e1e4fa1e3 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -29,11 +29,10 @@ class Client { if (!this.usernameInput) { console.warn('Username input element not found'); } - const s = this.stopGame - window.addEventListener('beforeunload', function (event) { + window.addEventListener('beforeunload', (event) => { console.log('Browser is closing'); - if (s != null) { - s() + if (this.gameStop != null) { + this.gameStop() } }); diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index 77eac8ad0..74b982655 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -97,7 +97,7 @@ export class PublicLobby extends LitElement { @click=${() => this.lobbyClicked(lobby)} class="lobby-button ${this.isLobbyHighlighted ? 'highlighted' : ''}" > -
Game ${lobby.id.substring(0, 3)}
+
Game ${lobby.id}
Starts in: ${timeRemaining}s
Players: ${lobby.numClients}
diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index c67ca2e64..b2810bd39 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -1,6 +1,7 @@ import { LitElement, html, css } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { Difficulty, GameMap, GameType } from '../core/game/Game'; +import { generateGameID as generateGameID } from '../core/Util'; @customElement('single-player-modal') export class SinglePlayerModal extends LitElement { @@ -127,7 +128,7 @@ export class SinglePlayerModal extends LitElement { detail: { gameType: GameType.Singleplayer, lobby: { - id: "LOCAL", + id: generateGameID(), }, map: this.selectedMap, difficulty: this.selectedDifficulty diff --git a/src/client/Transport.ts b/src/client/Transport.ts index edd88933e..6fa98210d 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -1,7 +1,7 @@ import { Config } from "../core/configuration/Config" import { EventBus, GameEvent } from "../core/EventBus" import { AllianceRequest, AllPlayers, Cell, Player, PlayerID, PlayerType, Tile, UnitType } from "../core/game/Game" -import { ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, GameID, Intent, ServerMessage, ServerMessageSchema, ClientPingMessageSchema } from "../core/Schemas" +import { ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, GameID, Intent, ServerMessage, ServerMessageSchema, ClientPingMessageSchema, GameConfig } from "../core/Schemas" import { LocalServer } from "./LocalServer" @@ -99,6 +99,7 @@ export class Transport { constructor( private isLocal: boolean, + private gameConfig: GameConfig | null, private eventBus: EventBus, private gameID: GameID, private clientIP: string | null, @@ -150,7 +151,7 @@ export class Transport { } private connectLocal(onconnect: () => void, onmessage: (message: ServerMessage) => void) { - this.localServer = new LocalServer(this.config, onconnect, onmessage) + this.localServer = new LocalServer(this.config, this.gameConfig, onconnect, onmessage) this.localServer.start() } @@ -208,6 +209,7 @@ export class Transport { leaveGame() { if (this.isLocal) { + this.localServer.endGame() return } this.stopPing() diff --git a/src/core/Util.ts b/src/core/Util.ts index 887943fe4..9873d9693 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -5,7 +5,8 @@ import DOMPurify from 'dompurify'; import { Cell, Game, Player, TerraNullius, Tile, Unit } from "./game/Game"; import { number } from 'zod'; -import { GameRecord } from './Schemas'; +import { GameConfig, GameID, GameRecord, Turn } from './Schemas'; +import { nanoid } from 'nanoid'; export function manhattanDist(c1: Cell, c2: Cell): number { return Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y); @@ -223,13 +224,19 @@ export function onlyImages(html: string) { }); } -export function ProcessGameRecord(record: GameRecord): GameRecord { - const packed: GameRecord = structuredClone(record); - packed.turns = [] +export function CreateGameRecord(id: GameID, gameConfig: GameConfig, turns: Turn[], start: number, end: number): GameRecord { + const record: GameRecord = { + id: id, + gameConfig: gameConfig, + startTimestampMS: start, + endTimestampMS: end, + date: new Date().toISOString().split('T')[0], + turns: [] + } const usernames = new Set() - for (const turn of record.turns) { + for (const turn of turns) { if (turn.intents.length != 0) { - packed.turns.push(turn) + record.turns.push(turn) for (const intent of turn.intents) { if (intent.type == 'spawn') { usernames.add(intent.name) @@ -237,15 +244,15 @@ export function ProcessGameRecord(record: GameRecord): GameRecord { } } } - packed.usernames = Array.from(usernames) - packed.durationSeconds = Math.floor((record.endTimestampMS - record.startTimestampMS) / 1000) - return packed; -} - -export function ToBigQuery(record: GameRecord) { - + record.usernames = Array.from(usernames) + record.durationSeconds = Math.floor((record.endTimestampMS - record.startTimestampMS) / 1000) + return record; } export function assertNever(x: never): never { throw new Error('Unexpected value: ' + x); } + +export function generateGameID(): string { + return nanoid(8) +} \ No newline at end of file diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index efae9d73c..41ebc70e2 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -65,7 +65,6 @@ export interface Config { defensePostDefenseBonus(): number falloutDefenseModifier(): number maxUnitCost(): number - gameStorageBucketName(): string } export interface Theme { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 280491637..a1c712391 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -20,9 +20,6 @@ export class DefaultConfig implements Config { return 2 } - gameStorageBucketName(): string { - return "openfront-games" - } defensePostRange(): number { return 30 } diff --git a/src/server/Archive.ts b/src/server/Archive.ts new file mode 100644 index 000000000..d12b4bc53 --- /dev/null +++ b/src/server/Archive.ts @@ -0,0 +1,13 @@ +import { GameConfig, GameID, GameRecord, GameRecordSchema, Turn } from "../core/Schemas"; +import { Storage } from '@google-cloud/storage'; + +const storage = new Storage(); + +export async function archive(gameRecord: GameRecord) { + console.log(`writing game ${gameRecord.id} to gcs`) + const bucket = storage.bucket("openfront-games"); + const file = bucket.file(gameRecord.id); + await file.save(JSON.stringify(GameRecordSchema.parse(gameRecord)), { + contentType: 'application/json' + }); +} \ No newline at end of file diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 7e8bd3253..da42a04f1 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Client } from "./Client"; import { GamePhase, GameServer } from "./GameServer"; import { Difficulty, GameMap, GameType } from "../core/game/Game"; +import { generateGameID } from "../core/Util"; @@ -38,7 +39,7 @@ export class GameManager { } createPrivateGame(): string { - const id = genSmallGameID() + const id = generateGameID() this.games.push(new GameServer( id, Date.now(), @@ -77,9 +78,8 @@ export class GameManager { const now = Date.now() if (now > this.lastNewLobby + this.config.gameCreationRate()) { this.lastNewLobby = now - const id = uuidv4() lobbies.push(new GameServer( - id, + generateGameID(), now, true, this.config, @@ -97,15 +97,4 @@ export class GameManager { finished.map(g => g.endGame()); // Fire and forget this.games = [...lobbies, ...active] } -} - -function genSmallGameID(): string { - // Generate a UUID - const uuid: string = uuidv4(); - - // Convert UUID to base64 - const base64: string = btoa(uuid); - - // Take the first 4 characters of the base64 string - return base64.slice(0, 4); } \ No newline at end of file diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 49e32ad16..9b61cf434 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -4,9 +4,10 @@ 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"; +import { CreateGameRecord, CreateGameRecord as ProcessRecord } from "../core/Util"; +import { archive } from "./Archive"; +import { arc } from "d3"; -const storage = new Storage(); export enum GamePhase { Lobby = 'LOBBY', @@ -90,7 +91,12 @@ export class GameServer { } public startTime(): number { - return this._startTime + if (this._startTime > 0) { + return this._startTime + } else { + //game hasn't started yet, only works for public games + return this.createdAt + this.config.lobbyLifetime() + } } public start() { @@ -150,21 +156,8 @@ export class GameServer { console.log(`ending game ${this.id} with ${this.turns.length} turns`) try { if (this.turns.length > 100) { - console.log(`writing game ${this.id} to gcs`) - const bucket = storage.bucket(this.config.gameStorageBucketName()); - 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 - } - const processed = ProcessRecord(game) - await file.save(JSON.stringify(GameRecordSchema.parse(processed)), { - contentType: 'application/json' - }); + const record = CreateGameRecord(this.id, this.gameConfig, this.turns, this._startTime, Date.now()) + archive(record) } } catch (error) { console.log('error writing game to gcs: ' + error) diff --git a/src/server/Server.ts b/src/server/Server.ts index 691a03373..01230e1e2 100644 --- a/src/server/Server.ts +++ b/src/server/Server.ts @@ -9,9 +9,7 @@ import { getConfig } from '../core/configuration/Config'; import { LogSeverity, slog } from './StructuredLog'; import { Client } from './Client'; import { GamePhase, GameServer } from './GameServer'; -import { v4 as uuidv4 } from 'uuid'; -import { z } from 'zod'; -import { ProcessGameRecord } from '../core/Util'; +import { archive } from './Archive'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -25,7 +23,6 @@ app.use(express.static(path.join(__dirname, '../../out'))); app.use(express.json()) const gm = new GameManager(getConfig()) -const privateGames = new Map() // New GET endpoint to list lobbies app.get('/lobbies', (req, res) => { @@ -46,66 +43,23 @@ app.post('/private_lobby', (req, res) => { }); }); -app.post('/new_private_game_record', (req, res) => { + +app.post('/archive_singleplayer_game', (req, res) => { try { - // Validate the complete game record sent by client - const gameRecord = GameRecordSchema.parse(req.body); - privateGames.set(gameRecord.id, gameRecord); - - slog('new_private_game_record', 'Created new private game record', { id: gameRecord.id }, LogSeverity.DEBUG); - res.json({ id: gameRecord.id }); - } catch (error) { - slog('new_private_game_record', 'Failed to create new private game record', { error }, LogSeverity.ERROR); - res.status(400).json({ error: 'Invalid game record format' }); - } -}); - -app.put('/add_single_player_game_turn', (req, res) => { - const { gameId, turns } = req.body; - - try { - const gameRecord = privateGames.get(gameId); + const gameRecord = req.body if (!gameRecord) { + console.log('game record not found in request') res.status(404).json({ error: 'Game record not found' }); return; } - - // Validate the array of turns - const validatedTurns = z.array(TurnSchema).parse(turns); - - // Add the turns to the game record's turns - gameRecord.turns.push(...validatedTurns); - privateGames.set(gameId, gameRecord); - - res.json({ success: true, numTurns: validatedTurns.length }); - } catch (error) { - slog('add_single_player_game_turn', 'Failed to add turns', { error, gameId }, LogSeverity.ERROR); - res.status(400).json({ error: 'Invalid turns format' }); - } -}); - -app.put('/complete_single_player_game_record/:id', (req, res) => { - const gameId = req.params.id; - try { - let gameRecord = privateGames.get(gameId); - if (!gameRecord) { - res.status(404).json({ error: 'Game record not found' }); - return; - } - - gameRecord.endTimestampMS = Date.now(); - - gameRecord = ProcessGameRecord(gameRecord) - // TODO: send to gcs - GameRecordSchema.parse(gameRecord); - + console.log(`archiving singleplayer game ${gameRecord.id}`) + archive(gameRecord) res.json({ success: true, - durationSeconds: gameRecord.durationSeconds }); } catch (error) { - slog('complete_single_player_game_record', 'Failed to complete game record', { error, gameId }, LogSeverity.ERROR); + slog('complete_single_player_game_record', 'Failed to complete game record', { error }, LogSeverity.ERROR); res.status(400).json({ error: 'Invalid game record format' }); } }) diff --git a/webpack.config.js b/webpack.config.js index b3802d004..af1fc8a07 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,5 +1,5 @@ import path from 'path'; -import {fileURLToPath} from 'url'; +import { fileURLToPath } from 'url'; import HtmlWebpackPlugin from 'html-webpack-plugin'; import webpack from 'webpack'; @@ -95,7 +95,7 @@ export default (env, argv) => { ws: true, }, { - context: ['/lobbies', '/join_game', '/join_lobby', '/private_lobby', '/start_private_lobby', '/lobby'], + context: ['/lobbies', '/join_game', '/join_lobby', '/private_lobby', '/start_private_lobby', '/lobby', '/archive_singleplayer_game'], target: 'http://localhost:3000', secure: false, changeOrigin: true,