format codebase with prettier

This commit is contained in:
Evan
2025-01-30 19:46:36 -08:00
parent cd121a5cd4
commit 4ee37323f9
98 changed files with 12191 additions and 10234 deletions
+136 -120
View File
@@ -1,7 +1,12 @@
import { GameConfig, GameID, GameRecord, GameRecordSchema, Turn } from "../core/Schemas";
import { Storage } from '@google-cloud/storage';
import { BigQuery } from '@google-cloud/bigquery';
import {
GameConfig,
GameID,
GameRecord,
GameRecordSchema,
Turn,
} from "../core/Schemas";
import { Storage } from "@google-cloud/storage";
import { BigQuery } from "@google-cloud/bigquery";
const storage = new Storage();
const bucket = storage.bucket("openfront-games");
@@ -11,166 +16,177 @@ const MAX_RETRIES = 5;
const INITIAL_RETRY_DELAY_MS = 1000; // Start with 1 second delay
export async function archive(gameRecord: GameRecord) {
try {
// First archive to BigQuery with retries
await withRetry(
() => archiveToBigQuery(gameRecord),
'BigQuery archive',
gameRecord.id
);
try {
// First archive to BigQuery with retries
await withRetry(
() => archiveToBigQuery(gameRecord),
"BigQuery archive",
gameRecord.id,
);
// Then archive to GCS with retries if there are turns
if (gameRecord.turns.length > 0) {
console.log(`${gameRecord.id}: game has more than zero turns, attempting to write to GCS`);
await withRetry(
() => archiveToGCS(gameRecord),
'GCS archive',
gameRecord.id
);
}
} catch (error) {
console.error(`${gameRecord.id}: Final archive error: ${error}`, {
message: error?.message || error,
stack: error?.stack,
name: error?.name,
...(error && typeof error === 'object' ? error : {})
});
// Then archive to GCS with retries if there are turns
if (gameRecord.turns.length > 0) {
console.log(
`${gameRecord.id}: game has more than zero turns, attempting to write to GCS`,
);
await withRetry(
() => archiveToGCS(gameRecord),
"GCS archive",
gameRecord.id,
);
}
} catch (error) {
console.error(`${gameRecord.id}: Final archive error: ${error}`, {
message: error?.message || error,
stack: error?.stack,
name: error?.name,
...(error && typeof error === "object" ? error : {}),
});
}
}
async function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function withRetry<T>(
operation: () => Promise<T>,
operationName: string,
gameId: string,
operation: () => Promise<T>,
operationName: string,
gameId: string,
): Promise<T> {
let lastError: Error | null = null;
let lastError: Error | null = null;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
if (attempt < MAX_RETRIES) {
const backoffDelay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt);
const jitter = Math.random() * 1000;
const totalDelay = backoffDelay + jitter;
if (attempt < MAX_RETRIES) {
const backoffDelay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt);
const jitter = Math.random() * 1000;
const totalDelay = backoffDelay + jitter;
console.log(`${gameId}: ${operationName} attempt ${attempt + 1} failed with ${error}. Retrying in ${Math.round(totalDelay)}ms...`);
await delay(totalDelay);
}
}
console.log(
`${gameId}: ${operationName} attempt ${attempt + 1} failed with ${error}. Retrying in ${Math.round(totalDelay)}ms...`,
);
await delay(totalDelay);
}
}
}
console.error(`${gameId}: All ${MAX_RETRIES + 1} ${operationName} attempts failed. Last error:`, lastError);
throw lastError;
console.error(
`${gameId}: All ${MAX_RETRIES + 1} ${operationName} attempts failed. Last error:`,
lastError,
);
throw lastError;
}
async function archiveToBigQuery(gameRecord: GameRecord) {
const row = {
id: gameRecord.id,
start: new Date(gameRecord.startTimestampMS),
end: new Date(gameRecord.endTimestampMS),
duration_seconds: gameRecord.durationSeconds,
number_turns: gameRecord.num_turns,
game_mode: gameRecord.gameConfig.gameType,
winner: gameRecord.winner,
difficulty: gameRecord.gameConfig.difficulty,
map: gameRecord.gameConfig.gameMap,
players: gameRecord.players.map(p => ({
username: p.username,
ip: anonymizeIP(p.ip),
persistentID: p.persistentID,
clientID: p.clientID,
})),
};
const row = {
id: gameRecord.id,
start: new Date(gameRecord.startTimestampMS),
end: new Date(gameRecord.endTimestampMS),
duration_seconds: gameRecord.durationSeconds,
number_turns: gameRecord.num_turns,
game_mode: gameRecord.gameConfig.gameType,
winner: gameRecord.winner,
difficulty: gameRecord.gameConfig.difficulty,
map: gameRecord.gameConfig.gameMap,
players: gameRecord.players.map((p) => ({
username: p.username,
ip: anonymizeIP(p.ip),
persistentID: p.persistentID,
clientID: p.clientID,
})),
};
const [apiResponse] = await bigquery
.dataset('game_archive')
.table('game_results')
.insert([row]);
const [apiResponse] = await bigquery
.dataset("game_archive")
.table("game_results")
.insert([row]);
console.log(`${gameRecord.id}: wrote game metadata to BigQuery`);
return apiResponse;
console.log(`${gameRecord.id}: wrote game metadata to BigQuery`);
return apiResponse;
}
async function archiveToGCS(gameRecord: GameRecord) {
// Create a deep copy to avoid modifying the original
const recordCopy = JSON.parse(JSON.stringify(gameRecord));
// Create a deep copy to avoid modifying the original
const recordCopy = JSON.parse(JSON.stringify(gameRecord));
// Players may see this so make sure to clear PII
recordCopy.players.forEach(p => {
p.ip = "REDACTED";
p.persistentID = "REDACTED";
});
// Players may see this so make sure to clear PII
recordCopy.players.forEach((p) => {
p.ip = "REDACTED";
p.persistentID = "REDACTED";
});
const file = bucket.file(recordCopy.id);
await file.save(JSON.stringify(GameRecordSchema.parse(recordCopy)), {
contentType: 'application/json'
});
const file = bucket.file(recordCopy.id);
await file.save(JSON.stringify(GameRecordSchema.parse(recordCopy)), {
contentType: "application/json",
});
console.log(`${gameRecord.id}: game record successfully written to GCS`);
console.log(`${gameRecord.id}: game record successfully written to GCS`);
}
function anonymizeIPv4(ipv4: string): string | null {
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (!ipv4Regex.test(ipv4)) {
return null;
}
if (!ipv4Regex.test(ipv4)) {
return null;
}
const octets = ipv4.split('.');
const octets = ipv4.split(".");
if (!octets.every(octet => {
const num = parseInt(octet);
return num >= 0 && num <= 255;
})) {
return null;
}
if (
!octets.every((octet) => {
const num = parseInt(octet);
return num >= 0 && num <= 255;
})
) {
return null;
}
octets[3] = 'xxx';
octets[3] = "xxx";
return octets.join('.');
return octets.join(".");
}
function anonymizeIPv6(ipv6: string): string | null {
const ipv6Regex = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
const ipv6Regex = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
const normalizedIPv6 = ipv6.toUpperCase()
.replace(/([^:]):([^:])/g, '$1:0$2')
.replace(/::/, ':0000:');
const normalizedIPv6 = ipv6
.toUpperCase()
.replace(/([^:]):([^:])/g, "$1:0$2")
.replace(/::/, ":0000:");
if (!ipv6Regex.test(normalizedIPv6)) {
return null;
}
if (!ipv6Regex.test(normalizedIPv6)) {
return null;
}
const segments = normalizedIPv6.split(':');
const segments = normalizedIPv6.split(":");
if (!segments.every(segment => {
const hex = parseInt(segment, 16);
return hex >= 0 && hex <= 65535;
})) {
return null;
}
if (
!segments.every((segment) => {
const hex = parseInt(segment, 16);
return hex >= 0 && hex <= 65535;
})
) {
return null;
}
for (let i = 4; i < 8; i++) {
segments[i] = 'xxxx';
}
for (let i = 4; i < 8; i++) {
segments[i] = "xxxx";
}
return segments.join(':');
return segments.join(":");
}
function anonymizeIP(ip: string): string | null {
const ipv4Result = anonymizeIPv4(ip);
if (ipv4Result) {
return ipv4Result;
}
const ipv4Result = anonymizeIPv4(ip);
if (ipv4Result) {
return ipv4Result;
}
const ipv6 = anonymizeIPv6(ip);
return ipv6
const ipv6 = anonymizeIPv6(ip);
return ipv6;
}
+11 -13
View File
@@ -1,16 +1,14 @@
import WebSocket from 'ws';
import { ClientID } from '../core/Schemas';
import WebSocket from "ws";
import { ClientID } from "../core/Schemas";
export class Client {
public lastPing: number;
public lastPing: number
constructor(
public readonly clientID: ClientID,
public readonly persistentID: string,
public readonly ip: string | null,
public readonly username: string,
public readonly ws: WebSocket,
) { }
}
constructor(
public readonly clientID: ClientID,
public readonly persistentID: string,
public readonly ip: string | null,
public readonly username: string,
public readonly ws: WebSocket,
) {}
}
+56 -55
View File
@@ -1,60 +1,61 @@
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
import { Client, Events, GatewayIntentBits } from 'discord.js';
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import { Client, Events, GatewayIntentBits } from "discord.js";
export class DiscordBot {
private client: Client;
private secretManager: SecretManagerServiceClient;
private client: Client;
private secretManager: SecretManagerServiceClient;
constructor() {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
this.secretManager = new SecretManagerServiceClient();
this.setupEventHandlers();
constructor() {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
this.secretManager = new SecretManagerServiceClient();
this.setupEventHandlers();
}
private setupEventHandlers(): void {
this.client.once(Events.ClientReady, (c) => {
console.log(`Ready! Logged in as ${c.user.tag}`);
});
this.client.on(Events.MessageCreate, async (message) => {
if (message.author.bot) return;
if (message.content === "!ping") {
await message.reply("Pong! 🏓");
}
if (message.content === "!hello") {
await message.reply(`Hello ${message.author.username}! 👋`);
}
});
}
private async getToken(): Promise<string | undefined> {
const name =
"projects/openfrontio/secrets/discord-bot-token/versions/latest";
const [version] = await this.secretManager.accessSecretVersion({ name });
return version.payload?.data?.toString().trim();
}
public async start(): Promise<void> {
try {
const token = await this.getToken();
if (!token) {
throw new Error("Failed to retrieve Discord token");
}
await this.client.login(token);
} catch (error) {
console.error("Failed to start bot:", error);
throw error;
}
}
private setupEventHandlers(): void {
this.client.once(Events.ClientReady, (c) => {
console.log(`Ready! Logged in as ${c.user.tag}`);
});
this.client.on(Events.MessageCreate, async (message) => {
if (message.author.bot) return;
if (message.content === '!ping') {
await message.reply('Pong! 🏓');
}
if (message.content === '!hello') {
await message.reply(`Hello ${message.author.username}! 👋`);
}
});
}
private async getToken(): Promise<string | undefined> {
const name = 'projects/openfrontio/secrets/discord-bot-token/versions/latest';
const [version] = await this.secretManager.accessSecretVersion({ name });
return version.payload?.data?.toString().trim();
}
public async start(): Promise<void> {
try {
const token = await this.getToken();
if (!token) {
throw new Error('Failed to retrieve Discord token');
}
await this.client.login(token);
} catch (error) {
console.error('Failed to start bot:', error);
throw error;
}
}
public stop(): void {
this.client.destroy();
}
}
public stop(): void {
this.client.destroy();
}
}
+85 -90
View File
@@ -1,104 +1,99 @@
import { Config, ServerConfig } from "../core/configuration/Config";
import { ClientID, GameConfig, GameID } from "../core/Schemas";
import { v4 as uuidv4 } from 'uuid';
import { v4 as uuidv4 } from "uuid";
import { Client } from "./Client";
import { GamePhase, GameServer } from "./GameServer";
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { generateID } from "../core/Util";
export class GameManager {
private lastNewLobby: number = 0;
private lastNewLobby: number = 0
private games: GameServer[] = [];
private games: GameServer[] = []
constructor(private config: ServerConfig) {}
constructor(private config: ServerConfig) { }
public game(id: GameID): GameServer | null {
return this.games.find((g) => g.id == id);
}
public game(id: GameID): GameServer | null {
return this.games.find(g => g.id == id)
gamesByPhase(phase: GamePhase): GameServer[] {
return this.games.filter((g) => g.phase() == phase);
}
addClient(client: Client, gameID: GameID, lastTurn: number) {
const game = this.games.find((g) => g.id == gameID);
if (!game) {
console.log(`game id ${gameID} not found`);
return;
}
game.addClient(client, lastTurn);
}
updateGameConfig(gameID: GameID, gameConfig: GameConfig) {
const game = this.games.find((g) => g.id == gameID);
if (game == null) {
console.warn(`game ${gameID} not found`);
return;
}
game.updateGameConfig(gameConfig);
}
createPrivateGame(): string {
const id = generateID();
this.games.push(
new GameServer(id, Date.now(), false, this.config, {
gameMap: GameMapType.World,
gameType: GameType.Private,
difficulty: Difficulty.Medium,
}),
);
return id;
}
hasActiveGame(gameID: GameID): boolean {
const game = this.games
.filter(
(g) => g.phase() == GamePhase.Lobby || g.phase() == GamePhase.Active,
)
.find((g) => g.id == gameID);
return game != null;
}
// TODO: stop private games to prevent memory leak.
startPrivateGame(gameID: GameID) {
const game = this.games.find((g) => g.id == gameID);
console.log(`found game ${game}`);
if (game) {
game.start();
} else {
throw new Error(`cannot start private game, game ${gameID} not found`);
}
}
tick() {
const lobbies = this.gamesByPhase(GamePhase.Lobby);
const active = this.gamesByPhase(GamePhase.Active);
const finished = this.gamesByPhase(GamePhase.Finished);
const now = Date.now();
if (now > this.lastNewLobby + this.config.gameCreationRate()) {
this.lastNewLobby = now;
lobbies.push(
new GameServer(generateID(), now, true, this.config, {
gameMap: GameMapType.World,
gameType: GameType.Public,
difficulty: Difficulty.Medium,
}),
);
}
gamesByPhase(phase: GamePhase): GameServer[] {
return this.games.filter(g => g.phase() == phase)
}
addClient(client: Client, gameID: GameID, lastTurn: number) {
const game = this.games.find(g => g.id == gameID)
if (!game) {
console.log(`game id ${gameID} not found`)
return
}
game.addClient(client, lastTurn)
}
updateGameConfig(gameID: GameID, gameConfig: GameConfig) {
const game = this.games.find(g => g.id == gameID)
if (game == null) {
console.warn(`game ${gameID} not found`)
return
}
game.updateGameConfig(gameConfig)
}
createPrivateGame(): string {
const id = generateID()
this.games.push(new GameServer(
id,
Date.now(),
false,
this.config,
{
gameMap: GameMapType.World,
gameType: GameType.Private,
difficulty: Difficulty.Medium
}
))
return id
}
hasActiveGame(gameID: GameID): boolean {
const game = this.games.filter(g => g.phase() == GamePhase.Lobby || g.phase() == GamePhase.Active).find(g => g.id == gameID)
return game != null
}
// TODO: stop private games to prevent memory leak.
startPrivateGame(gameID: GameID) {
const game = this.games.find(g => g.id == gameID)
console.log(`found game ${game}`)
if (game) {
game.start()
} else {
throw new Error(`cannot start private game, game ${gameID} not found`)
}
}
tick() {
const lobbies = this.gamesByPhase(GamePhase.Lobby)
const active = this.gamesByPhase(GamePhase.Active)
const finished = this.gamesByPhase(GamePhase.Finished)
const now = Date.now()
if (now > this.lastNewLobby + this.config.gameCreationRate()) {
this.lastNewLobby = now
lobbies.push(new GameServer(
generateID(),
now,
true,
this.config,
{
gameMap: GameMapType.World,
gameType: GameType.Public,
difficulty: Difficulty.Medium
}
))
}
active.filter(g => !g.hasStarted() && g.isPublic).forEach(g => {
g.start()
})
finished.map(g => g.endGame()); // Fire and forget
this.games = [...lobbies, ...active]
}
}
active
.filter((g) => !g.hasStarted() && g.isPublic)
.forEach((g) => {
g.start();
});
finished.map((g) => g.endGame()); // Fire and forget
this.games = [...lobbies, ...active];
}
}
+270 -236
View File
@@ -1,280 +1,314 @@
import { ClientID, ClientMessage, ClientMessageSchema, GameConfig, GameRecordSchema, Intent, PlayerRecord, ServerPingMessageSchema, ServerStartGameMessage, ServerStartGameMessageSchema, ServerTurnMessageSchema, Turn } from "../core/Schemas";
import {
ClientID,
ClientMessage,
ClientMessageSchema,
GameConfig,
GameRecordSchema,
Intent,
PlayerRecord,
ServerPingMessageSchema,
ServerStartGameMessage,
ServerStartGameMessageSchema,
ServerTurnMessageSchema,
Turn,
} from "../core/Schemas";
import { Config, ServerConfig } from "../core/configuration/Config";
import { Client } from "./Client";
import WebSocket from 'ws';
import WebSocket from "ws";
import { slog } from "./StructuredLog";
import { CreateGameRecord } from "../core/Util";
import { archive } from "./Archive";
export enum GamePhase {
Lobby = 'LOBBY',
Active = 'ACTIVE',
Finished = 'FINISHED'
Lobby = "LOBBY",
Active = "ACTIVE",
Finished = "FINISHED",
}
export class GameServer {
private maxGameDuration = 5 * 60 * 60 * 1000; // 5 hours
private turns: Turn[] = [];
private intents: Intent[] = [];
public activeClients: Client[] = [];
// Used for record record keeping
private allClients: Map<ClientID, Client> = new Map();
private _hasStarted = false;
private _startTime: number = null;
private maxGameDuration = 5 * 60 * 60 * 1000 // 5 hours
private endTurnIntervalID;
private turns: Turn[] = []
private intents: Intent[] = []
public activeClients: Client[] = []
// Used for record record keeping
private allClients: Map<ClientID, Client> = new Map()
private _hasStarted = false
private _startTime: number = null
private lastPingUpdate = 0;
private endTurnIntervalID
private winner: ClientID | null = null;
private lastPingUpdate = 0
constructor(
public readonly id: string,
public readonly createdAt: number,
public readonly isPublic: boolean,
private config: ServerConfig,
private gameConfig: GameConfig,
) {}
private winner: ClientID | null = null
public updateGameConfig(gameConfig: GameConfig): void {
if (gameConfig.gameMap != null) {
this.gameConfig.gameMap = gameConfig.gameMap;
}
if (gameConfig.difficulty != null) {
this.gameConfig.difficulty = gameConfig.difficulty;
}
}
constructor(
public readonly id: string,
public readonly createdAt: number,
public readonly isPublic: boolean,
private config: ServerConfig,
private gameConfig: GameConfig,
public addClient(client: Client, lastTurn: number) {
console.log(`${this.id}: adding client ${client.clientID}`);
slog({
logKey: "client_joined_game",
msg: `client ${client.clientID} (re)joining game ${this.id}`,
data: {
clientID: client.clientID,
clientIP: client.ip,
gameID: this.id,
isRejoin: lastTurn > 0,
},
clientID: client.clientID,
persistentID: client.persistentID,
gameID: this.id,
});
// Remove stale client if this is a reconnect
const existing = this.activeClients.find(
(c) => c.clientID == client.clientID,
);
if (existing != null) {
existing.ws.removeAllListeners("message");
}
this.activeClients = this.activeClients.filter(
(c) => c.clientID != client.clientID,
);
this.activeClients.push(client);
client.lastPing = Date.now();
) { }
this.allClients.set(client.clientID, client);
public updateGameConfig(gameConfig: GameConfig): void {
if (gameConfig.gameMap != null) {
this.gameConfig.gameMap = gameConfig.gameMap
client.ws.on("message", (message: string) => {
try {
const clientMsg: ClientMessage = ClientMessageSchema.parse(
JSON.parse(message),
);
if (clientMsg.type == "intent") {
if (clientMsg.gameID == this.id) {
this.addIntent(clientMsg.intent);
} else {
console.warn(
`${this.id}: client ${clientMsg.clientID} sent to wrong game`,
);
}
}
if (gameConfig.difficulty != null) {
this.gameConfig.difficulty = gameConfig.difficulty
if (clientMsg.type == "ping") {
this.lastPingUpdate = Date.now();
client.lastPing = Date.now();
}
}
public addClient(client: Client, lastTurn: number) {
console.log(`${this.id}: adding client ${client.clientID}`)
slog({
logKey: 'client_joined_game',
msg: `client ${client.clientID} (re)joining game ${this.id}`,
data: {
clientID: client.clientID,
clientIP: client.ip,
gameID: this.id,
isRejoin: lastTurn > 0
},
clientID: client.clientID,
persistentID: client.persistentID,
gameID: this.id,
})
// Remove stale client if this is a reconnect
const existing = this.activeClients.find(c => c.clientID == client.clientID)
if (existing != null) {
existing.ws.removeAllListeners('message')
if (clientMsg.type == "winner") {
this.winner = clientMsg.winner;
}
this.activeClients = this.activeClients.filter(c => c.clientID != client.clientID)
this.activeClients.push(client)
client.lastPing = Date.now()
} catch (error) {
console.log(
`error handline websocket request in game server: ${error}`,
);
}
});
client.ws.on("close", () => {
console.log(`${this.id}: client ${client.clientID} disconnected`);
this.activeClients = this.activeClients.filter(
(c) => c.clientID != client.clientID,
);
});
this.allClients.set(client.clientID, client)
client.ws.on('message', (message: string) => {
try {
const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message))
if (clientMsg.type == "intent") {
if (clientMsg.gameID == this.id) {
this.addIntent(clientMsg.intent)
} else {
console.warn(`${this.id}: client ${clientMsg.clientID} sent to wrong game`)
}
}
if (clientMsg.type == "ping") {
this.lastPingUpdate = Date.now()
client.lastPing = Date.now()
}
if (clientMsg.type == "winner") {
this.winner = clientMsg.winner
}
} catch (error) {
console.log(`error handline websocket request in game server: ${error}`)
}
})
client.ws.on('close', () => {
console.log(`${this.id}: client ${client.clientID} disconnected`)
this.activeClients = this.activeClients.filter(c => c.clientID != client.clientID)
})
// In case a client joined the game late and missed the start message.
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, lastTurn)
}
// In case a client joined the game late and missed the start message.
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, lastTurn);
}
}
public numClients(): number {
return this.activeClients.length
public numClients(): number {
return this.activeClients.length;
}
public startTime(): number {
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 startTime(): number {
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() {
this._hasStarted = true;
this._startTime = Date.now();
// Set last ping to start so we don't immediately stop the game
// if no client connects/pings.
this.lastPingUpdate = Date.now();
public start() {
this._hasStarted = true
this._startTime = Date.now()
// Set last ping to start so we don't immediately stop the game
// if no client connects/pings.
this.lastPingUpdate = Date.now()
this.endTurnIntervalID = setInterval(
() => this.endTurn(),
this.config.turnIntervalMs(),
);
this.activeClients.forEach((c) => {
console.log(`${this.id}: sending start message to ${c.clientID}`);
this.sendStartGameMsg(c.ws, 0);
});
}
this.endTurnIntervalID = setInterval(() => this.endTurn(), this.config.turnIntervalMs());
this.activeClients.forEach(c => {
console.log(`${this.id}: sending start message to ${c.clientID}`)
this.sendStartGameMsg(c.ws, 0)
})
}
private addIntent(intent: Intent) {
this.intents.push(intent);
}
private addIntent(intent: Intent) {
this.intents.push(intent)
}
private sendStartGameMsg(ws: WebSocket, lastTurn: number) {
ws.send(
JSON.stringify(
ServerStartGameMessageSchema.parse({
type: "start",
turns: this.turns.slice(lastTurn),
config: this.gameConfig,
}),
),
);
}
private sendStartGameMsg(ws: WebSocket, lastTurn: number) {
ws.send(JSON.stringify(ServerStartGameMessageSchema.parse(
{
type: "start",
turns: this.turns.slice(lastTurn),
config: this.gameConfig
}
)))
}
private endTurn() {
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.id,
intents: this.intents,
};
this.turns.push(pastTurn);
this.intents = [];
private endTurn() {
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.id,
intents: this.intents
}
this.turns.push(pastTurn)
this.intents = []
const msg = JSON.stringify(
ServerTurnMessageSchema.parse({
type: "turn",
turn: pastTurn,
}),
);
this.activeClients.forEach((c) => {
c.ws.send(msg);
});
}
const msg = JSON.stringify(ServerTurnMessageSchema.parse(
{
type: "turn",
turn: pastTurn
}
))
this.activeClients.forEach(c => {
c.ws.send(msg)
})
}
async endGame() {
// Close all WebSocket connections
clearInterval(this.endTurnIntervalID);
this.activeClients.forEach(client => {
client.ws.removeAllListeners('message'); // TODO: remove this?
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.close(1000, "game has ended");
}
});
console.log(`${this.id}: ending game ${this.id} with ${this.turns.length} turns`)
async endGame() {
// Close all WebSocket connections
clearInterval(this.endTurnIntervalID);
this.activeClients.forEach((client) => {
client.ws.removeAllListeners("message"); // TODO: remove this?
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.close(1000, "game has ended");
}
});
console.log(
`${this.id}: ending game ${this.id} with ${this.turns.length} turns`,
);
try {
if (this.allClients.size > 0) {
const playerRecords: PlayerRecord[] = Array.from(
this.allClients.values(),
).map((client) => ({
ip: client.ip,
clientID: client.clientID,
username: client.username,
persistentID: client.persistentID,
}));
archive(
CreateGameRecord(
this.id,
this.gameConfig,
playerRecords,
this.turns,
this._startTime,
Date.now(),
this.winner,
),
);
} else {
console.log(`${this.id}: no clients joined, not archiving game`);
}
} catch (error) {
let errorDetails;
if (error instanceof Error) {
errorDetails = {
message: error.message,
stack: error.stack,
};
} else if (Array.isArray(error)) {
errorDetails = error; // Now we'll actually see the array contents
} else {
try {
if (this.allClients.size > 0) {
const playerRecords: PlayerRecord[] = Array.from(this.allClients.values()).map(client => ({
ip: client.ip,
clientID: client.clientID,
username: client.username,
persistentID: client.persistentID,
}));
archive(
CreateGameRecord(
this.id,
this.gameConfig,
playerRecords,
this.turns,
this._startTime,
Date.now(),
this.winner
)
)
} else {
console.log(`${this.id}: no clients joined, not archiving game`)
}
} catch (error) {
let errorDetails;
if (error instanceof Error) {
errorDetails = {
message: error.message,
stack: error.stack
};
} else if (Array.isArray(error)) {
errorDetails = error; // Now we'll actually see the array contents
} else {
try {
errorDetails = JSON.stringify(error, null, 2);
} catch (e) {
errorDetails = String(error);
}
}
console.error("Error archiving game record details:", {
gameId: this.id,
errorType: typeof error,
error: errorDetails
});
errorDetails = JSON.stringify(error, null, 2);
} catch (e) {
errorDetails = String(error);
}
}
console.error("Error archiving game record details:", {
gameId: this.id,
errorType: typeof error,
error: errorDetails,
});
}
}
phase(): GamePhase {
const now = Date.now();
const alive = [];
for (const client of this.activeClients) {
if (now - client.lastPing > 60_000) {
console.log(
`${this.id}: no pings from ${client.clientID}, terminating connection`,
);
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.close(1000, "no heartbeats received, closing connection");
}
} else {
alive.push(client);
}
}
this.activeClients = alive;
if (
now >
this.createdAt + this.config.lobbyLifetime() + this.maxGameDuration
) {
console.warn(`${this.id}: game past max duration ${this.id}`);
return GamePhase.Finished;
}
phase(): GamePhase {
const now = Date.now()
const alive = []
for (const client of this.activeClients) {
if (now - client.lastPing > 60_000) {
console.log(`${this.id}: no pings from ${client.clientID}, terminating connection`)
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.close(1000, "no heartbeats received, closing connection");
}
} else {
alive.push(client)
}
}
this.activeClients = alive
if (now > this.createdAt + this.config.lobbyLifetime() + this.maxGameDuration) {
console.warn(`${this.id}: game past max duration ${this.id}`)
return GamePhase.Finished
}
const noRecentPings = now > this.lastPingUpdate + 20 * 1000;
const noActive = this.activeClients.length == 0;
const noRecentPings = now > this.lastPingUpdate + 20 * 1000
const noActive = this.activeClients.length == 0
if (!this.isPublic) {
if (this._hasStarted) {
if (noActive && noRecentPings) {
console.log(`${this.id}: private game: ${this.id} complete`)
return GamePhase.Finished
} else {
return GamePhase.Active
}
} else {
return GamePhase.Lobby
}
if (!this.isPublic) {
if (this._hasStarted) {
if (noActive && noRecentPings) {
console.log(`${this.id}: private game: ${this.id} complete`);
return GamePhase.Finished;
} else {
return GamePhase.Active;
}
if (now - this.createdAt < this.config.lobbyLifetime()) {
return GamePhase.Lobby
}
const warmupOver = now > this.createdAt + this.config.lobbyLifetime() + 30 * 1000
if (noActive && warmupOver && noRecentPings) {
return GamePhase.Finished
}
return GamePhase.Active
} else {
return GamePhase.Lobby;
}
}
hasStarted(): boolean {
return this._hasStarted
if (now - this.createdAt < this.config.lobbyLifetime()) {
return GamePhase.Lobby;
}
const warmupOver =
now > this.createdAt + this.config.lobbyLifetime() + 30 * 1000;
if (noActive && warmupOver && noRecentPings) {
return GamePhase.Finished;
}
return GamePhase.Active;
}
}
hasStarted(): boolean {
return this._hasStarted;
}
}
+169 -150
View File
@@ -1,17 +1,26 @@
import express, { json } from 'express';
import http from 'http';
import { WebSocketServer } from 'ws';
import path from 'path';
import { fileURLToPath } from 'url';
import { GameManager } from './GameManager';
import { ClientMessage, ClientMessageSchema, GameRecord, GameRecordSchema, LogSeverity } from '../core/Schemas';
import { getConfig, getServerConfig } from '../core/configuration/Config';
import { slog } from './StructuredLog';
import { Client } from './Client';
import { GamePhase, GameServer } from './GameServer';
import { archive } from './Archive';
import { DiscordBot } from './DiscordBot';
import { sanitizeUsername, validateUsername } from "../core/validations/username";
import express, { json } from "express";
import http from "http";
import { WebSocketServer } from "ws";
import path from "path";
import { fileURLToPath } from "url";
import { GameManager } from "./GameManager";
import {
ClientMessage,
ClientMessageSchema,
GameRecord,
GameRecordSchema,
LogSeverity,
} from "../core/Schemas";
import { getConfig, getServerConfig } from "../core/configuration/Config";
import { slog } from "./StructuredLog";
import { Client } from "./Client";
import { GamePhase, GameServer } from "./GameServer";
import { archive } from "./Archive";
import { DiscordBot } from "./DiscordBot";
import {
sanitizeUsername,
validateUsername,
} from "../core/validations/username";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -21,170 +30,180 @@ const server = http.createServer(app);
const wss = new WebSocketServer({ server });
// Serve static files from the 'out' directory
app.use(express.static(path.join(__dirname, '../../out')));
app.use(express.json())
app.use(express.static(path.join(__dirname, "../../out")));
app.use(express.json());
const gm = new GameManager(getServerConfig())
const gm = new GameManager(getServerConfig());
const bot = new DiscordBot();
try {
await bot.start();
await bot.start();
} catch (error) {
console.error('Failed to start bot:', error);
console.error("Failed to start bot:", error);
}
// New GET endpoint to list lobbies
app.get('/lobbies', (req, res) => {
const now = Date.now()
res.json({
lobbies: gm.gamesByPhase(GamePhase.Lobby)
.filter(g => g.isPublic)
.map(g => ({ id: g.id, msUntilStart: g.startTime() - now, numClients: g.numClients() }))
.sort((a, b) => a.msUntilStart - b.msUntilStart),
});
app.get("/lobbies", (req, res) => {
const now = Date.now();
res.json({
lobbies: gm
.gamesByPhase(GamePhase.Lobby)
.filter((g) => g.isPublic)
.map((g) => ({
id: g.id,
msUntilStart: g.startTime() - now,
numClients: g.numClients(),
}))
.sort((a, b) => a.msUntilStart - b.msUntilStart),
});
});
app.post('/private_lobby', (req, res) => {
const id = gm.createPrivateGame()
console.log('creating private lobby with id ${id}')
res.json({
id: id
});
app.post("/private_lobby", (req, res) => {
const id = gm.createPrivateGame();
console.log("creating private lobby with id ${id}");
res.json({
id: id,
});
});
app.post('/archive_singleplayer_game', (req, res) => {
app.post("/archive_singleplayer_game", (req, res) => {
try {
const gameRecord: GameRecord = req.body;
const clientIP = req.ip || req.socket.remoteAddress || "unknown"; // Added this line
if (!gameRecord) {
console.log("game record not found in request");
res.status(404).json({ error: "Game record not found" });
return;
}
gameRecord.players.forEach((p) => (p.ip = clientIP));
GameRecordSchema.parse(gameRecord);
archive(gameRecord);
res.json({
success: true,
});
} catch (error) {
slog({
logKey: "complete_single_player_game_record",
msg: `Failed to complete game record: ${error}`,
severity: LogSeverity.Error,
});
res.status(400).json({ error: "Invalid game record format" });
}
});
app.post("/start_private_lobby/:id", (req, res) => {
console.log(`starting private lobby with id ${req.params.id}`);
gm.startPrivateGame(req.params.id);
});
app.put("/private_lobby/:id", (req, res) => {
const lobbyID = req.params.id;
gm.updateGameConfig(lobbyID, {
gameMap: req.body.gameMap,
difficulty: req.body.difficulty,
});
});
app.get("/lobby/:id/exists", (req, res) => {
const lobbyId = req.params.id;
console.log(`checking lobby ${lobbyId} exists`);
const lobbyExists = gm.hasActiveGame(lobbyId);
res.json({
exists: lobbyExists,
});
});
app.get("/lobby/:id", (req, res) => {
const game = gm.game(req.params.id);
if (game == null) {
console.log(`lobby ${req.params.id} not found`);
return res.status(404).json({ error: "Game not found" });
}
res.json({
players: game.activeClients.map((c) => ({
username: c.username,
clientID: c.clientID,
})),
});
});
app.get("/private_lobby/:id", (req, res) => {
res.json({
hi: "5",
});
});
wss.on("connection", (ws, req) => {
ws.on("message", (message: string) => {
try {
const gameRecord: GameRecord = req.body
const clientIP = req.ip || req.socket.remoteAddress || 'unknown'; // Added this line
if (!gameRecord) {
console.log('game record not found in request')
res.status(404).json({ error: 'Game record not found' });
return;
const clientMsg: ClientMessage = ClientMessageSchema.parse(
JSON.parse(message),
);
slog({
logKey: "websocket_msg",
msg: "server received websocket message",
data: clientMsg,
severity: LogSeverity.Debug,
});
if (clientMsg.type == "join") {
const forwarded = req.headers["x-forwarded-for"];
let ip = Array.isArray(forwarded)
? forwarded[0] // Get the first IP if it's an array
: forwarded || req.socket.remoteAddress;
if (Array.isArray(ip)) {
ip = ip[0];
}
gameRecord.players.forEach(p => p.ip = clientIP)
GameRecordSchema.parse(gameRecord);
archive(gameRecord)
res.json({
success: true,
});
} catch (error) {
const { isValid, error } = validateUsername(clientMsg.username);
if (!isValid) {
console.log(
`game ${clientMsg.gameID}, client ${clientMsg.clientID} received invalid username, ${error}`,
);
return;
}
clientMsg.username = sanitizeUsername(clientMsg.username);
gm.addClient(
new Client(
clientMsg.clientID,
clientMsg.persistentID,
ip,
clientMsg.username,
ws,
),
clientMsg.gameID,
clientMsg.lastTurn,
);
}
if (clientMsg.type == "log") {
slog({
logKey: 'complete_single_player_game_record',
msg: `Failed to complete game record: ${error}`,
severity: LogSeverity.Error,
logKey: "client_console_log",
msg: clientMsg.log,
severity: clientMsg.severity,
clientID: clientMsg.clientID,
gameID: clientMsg.gameID,
persistentID: clientMsg.persistentID,
});
res.status(400).json({ error: 'Invalid game record format' });
}
} catch (error) {
console.log(`errror handling websocket message: ${error}`);
}
})
app.post('/start_private_lobby/:id', (req, res) => {
console.log(`starting private lobby with id ${req.params.id}`)
gm.startPrivateGame(req.params.id)
});
app.put('/private_lobby/:id', (req, res) => {
const lobbyID = req.params.id
gm.updateGameConfig(lobbyID, { gameMap: req.body.gameMap, difficulty: req.body.difficulty })
});
app.get('/lobby/:id/exists', (req, res) => {
const lobbyId = req.params.id;
console.log(`checking lobby ${lobbyId} exists`)
const lobbyExists = gm.hasActiveGame(lobbyId);
res.json({
exists: lobbyExists
});
});
app.get('/lobby/:id', (req, res) => {
const game = gm.game(req.params.id)
if (game == null) {
console.log(`lobby ${req.params.id} not found`)
return res.status(404).json({ error: 'Game not found' });
}
res.json({
players: game.activeClients.map(c => ({
username: c.username,
clientID: c.clientID
}))
});
});
app.get('/private_lobby/:id', (req, res) => {
res.json({
hi: '5'
});
});
wss.on('connection', (ws, req) => {
ws.on('message', (message: string) => {
try {
const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message))
slog({
logKey: 'websocket_msg',
msg: 'server received websocket message',
data: clientMsg,
severity: LogSeverity.Debug
})
if (clientMsg.type == "join") {
const forwarded = req.headers['x-forwarded-for']
let ip = Array.isArray(forwarded)
? forwarded[0] // Get the first IP if it's an array
: forwarded || req.socket.remoteAddress;
if (Array.isArray(ip)) {
ip = ip[0]
}
const { isValid, error } = validateUsername(clientMsg.username);
if (!isValid) {
console.log(`game ${clientMsg.gameID}, client ${clientMsg.clientID} received invalid username, ${error}`)
return;
}
clientMsg.username = sanitizeUsername(clientMsg.username)
gm.addClient(
new Client(
clientMsg.clientID,
clientMsg.persistentID,
ip,
clientMsg.username,
ws
),
clientMsg.gameID,
clientMsg.lastTurn
)
}
if (clientMsg.type == "log") {
slog({
logKey: "client_console_log",
msg: clientMsg.log,
severity: clientMsg.severity,
clientID: clientMsg.clientID,
gameID: clientMsg.gameID,
persistentID: clientMsg.persistentID,
})
}
} catch (error) {
console.log(`errror handling websocket message: ${error}`)
}
})
});
});
function runGame() {
setInterval(() => tick(), 1000);
setInterval(() => tick(), 1000);
}
function tick() {
gm.tick()
gm.tick();
}
const PORT = process.env.PORT || 3000;
console.log(`Server will try to run on http://localhost:${PORT}`);
server.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
console.log(`Server is running on http://localhost:${PORT}`);
});
runGame()
runGame();
+25 -25
View File
@@ -1,33 +1,33 @@
import { ClientID, GameID, LogSeverity } from "../core/Schemas";
export interface slogMsg {
logKey: string,
msg: string,
data?: any
severity?: LogSeverity
gameID?: GameID
clientID?: ClientID
persistentID?: string
logKey: string;
msg: string;
data?: any;
severity?: LogSeverity;
gameID?: GameID;
clientID?: ClientID;
persistentID?: string;
}
export function slog(msg: slogMsg): void {
msg.severity = msg.severity ?? LogSeverity.Info;
msg.severity = msg.severity ?? LogSeverity.Info;
if (process.env.GAME_ENV == 'dev') {
// Avoid blowing up the log during development.
if (msg.logKey == 'client_console_log') {
return
}
if (msg.severity != LogSeverity.Debug) {
console.log(msg.msg)
}
} else {
try {
console.log(JSON.stringify(msg));
} catch (error) {
console.error('Failed to stringify log message:', error);
// Fallback to basic logging
console.log(`${msg.severity}: ${msg.msg}`);
}
if (process.env.GAME_ENV == "dev") {
// Avoid blowing up the log during development.
if (msg.logKey == "client_console_log") {
return;
}
}
if (msg.severity != LogSeverity.Debug) {
console.log(msg.msg);
}
} else {
try {
console.log(JSON.stringify(msg));
} catch (error) {
console.error("Failed to stringify log message:", error);
// Fallback to basic logging
console.log(`${msg.severity}: ${msg.msg}`);
}
}
}