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,