mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-03 09:20:50 +00:00
format codebase with prettier
This commit is contained in:
+136
-120
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user