diff --git a/Dockerfile b/Dockerfile index fb6cad03b..686fb8937 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,6 @@ COPY . . # Build the client-side application RUN npm run build-prod # Expose the port the app runs on -EXPOSE 3000 +EXPOSE 3000 9229 # Define the command to run the app CMD ["npm", "run", "start:server"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e9cb94d28..a3e36cf8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,10 @@ -version: '3' +version: "3" services: game-server: build: . ports: - "3000:3000" + - "9229:9229" environment: - NODE_ENV=production nginx: @@ -15,4 +16,4 @@ services: - ./nginx.conf:/etc/nginx/nginx.conf - /etc/letsencrypt:/etc/letsencrypt depends_on: - - game-server \ No newline at end of file + - game-server diff --git a/package.json b/package.json index df0b0c8b7..08c4fbba6 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,12 @@ "build-dev": "webpack --config webpack.config.js --mode development", "build-prod": "webpack --config webpack.config.js --mode production", "start:client": "webpack serve --open --node-env development", - "start:server": "node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts", + "start:server": "node --inspect=0.0.0.0:9229 --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts", "start:server-dev": "GAME_ENV=dev node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts", "dev": "GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\"", "tunnel": "npm run build-prod && npm run start:server", "test": "jest", - "tailwind": "tailwindcss build -i ./src/client/tailwind.css -o public/tailwind.css" + "start:server-dev-profile": "GAME_ENV=dev node --inspect --trace-gc --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/resources/.DS_Store b/resources/.DS_Store new file mode 100644 index 000000000..a81528f49 Binary files /dev/null and b/resources/.DS_Store differ diff --git a/src/client/index.html b/src/client/index.html index 79b6b7083..4633aab18 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -35,7 +35,7 @@ OpenFront.io

- (v0.14.0) + (v0.14.2)

diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index f2991967d..39a5730c8 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -1,4 +1,17 @@ -import { Difficulty, GameType, Gold, Player, PlayerID, PlayerInfo, TerraNullius, Tick, Tile, Unit, UnitInfo, UnitType } from "../game/Game"; +import { + Difficulty, + GameType, + Gold, + Player, + PlayerID, + PlayerInfo, + TerraNullius, + Tick, + Tile, + Unit, + UnitInfo, + UnitType, +} from "../game/Game"; import { Colord, colord } from "colord"; import { preprodConfig } from "./PreprodConfig"; import { prodConfig } from "./ProdConfig"; @@ -8,100 +21,110 @@ import { DefaultConfig } from "./DefaultConfig"; import { DevConfig, DevServerConfig } from "./DevConfig"; export enum GameEnv { - Dev, - Prod + Dev, + Prod, } export function getConfig(gameConfig: GameConfig): Config { - const sc = getServerConfig() - switch (process.env.GAME_ENV) { - case 'dev': - return new DevConfig(sc, gameConfig) - case 'preprod': - case 'prod': - consolex.log('using prod config') - return new DefaultConfig(sc, gameConfig) - default: - throw Error(`unsupported server configuration: ${process.env.GAME_ENV}`) - } + const sc = getServerConfig(); + switch (process.env.GAME_ENV) { + case "dev": + return new DevConfig(sc, gameConfig); + case "preprod": + case "prod": + consolex.log("using prod config"); + return new DefaultConfig(sc, gameConfig); + default: + throw Error(`unsupported server configuration: ${process.env.GAME_ENV}`); + } } export function getServerConfig(): ServerConfig { - switch (process.env.GAME_ENV) { - case 'dev': - consolex.log('using dev config') - return new DevServerConfig() - case 'preprod': - consolex.log('using preprod config') - return preprodConfig - case 'prod': - consolex.log('using prod config') - return prodConfig - default: - throw Error(`unsupported server configuration: ${process.env.GAME_ENV}`) - } + switch (process.env.GAME_ENV) { + case "dev": + consolex.log("using dev config"); + return new DevServerConfig(); + case "preprod": + consolex.log("using preprod config"); + return preprodConfig; + case "prod": + default: + consolex.log("using prod config"); + return prodConfig; + // default: + // throw Error(`unsupported server configuration: ${process.env.GAME_ENV}`) + } } export interface ServerConfig { - turnIntervalMs(): number - gameCreationRate(): number - lobbyLifetime(): number + turnIntervalMs(): number; + gameCreationRate(): number; + lobbyLifetime(): number; } export interface Config { - serverConfig(): ServerConfig - gameConfig(): GameConfig - theme(): Theme; - percentageTilesOwnedToWin(): number - numBots(): number - spawnNPCs(): boolean - numSpawnPhaseTurns(gameType: GameType): number + serverConfig(): ServerConfig; + gameConfig(): GameConfig; + theme(): Theme; + percentageTilesOwnedToWin(): number; + numBots(): number; + spawnNPCs(): boolean; + numSpawnPhaseTurns(gameType: GameType): number; - startManpower(playerInfo: PlayerInfo): number - populationIncreaseRate(player: Player): number - goldAdditionRate(player: Player): number - troopAdjustmentRate(player: Player): number - attackTilesPerTick(attckTroops: number, attacker: Player, defender: Player | TerraNullius, numAdjacentTilesWithEnemy: number): number - attackLogic(attackTroops: number, attacker: Player, defender: Player | TerraNullius, tileToConquer: Tile): { - attackerTroopLoss: number, - defenderTroopLoss: number, - tilesPerTickUsed: number - } - attackAmount(attacker: Player, defender: Player | TerraNullius): number - maxPopulation(player: Player): number - cityPopulationIncrease(): number - boatAttackAmount(attacker: Player, defender: Player | TerraNullius): number - boatMaxDistance(): number - boatMaxNumber(): number - allianceDuration(): Tick - allianceRequestCooldown(): Tick - targetDuration(): Tick - targetCooldown(): Tick - emojiMessageCooldown(): Tick - emojiMessageDuration(): Tick - donateCooldown(): Tick - defaultDonationAmount(sender: Player): number - unitInfo(type: UnitType): UnitInfo - tradeShipGold(src: Unit, dst: Unit): Gold - tradeShipSpawnRate(): number - defensePostRange(): number - defensePostDefenseBonus(): number - falloutDefenseModifier(): number - difficultyModifier(difficulty: Difficulty): number + startManpower(playerInfo: PlayerInfo): number; + populationIncreaseRate(player: Player): number; + goldAdditionRate(player: Player): number; + troopAdjustmentRate(player: Player): number; + attackTilesPerTick( + attckTroops: number, + attacker: Player, + defender: Player | TerraNullius, + numAdjacentTilesWithEnemy: number + ): number; + attackLogic( + attackTroops: number, + attacker: Player, + defender: Player | TerraNullius, + tileToConquer: Tile + ): { + attackerTroopLoss: number; + defenderTroopLoss: number; + tilesPerTickUsed: number; + }; + attackAmount(attacker: Player, defender: Player | TerraNullius): number; + maxPopulation(player: Player): number; + cityPopulationIncrease(): number; + boatAttackAmount(attacker: Player, defender: Player | TerraNullius): number; + boatMaxDistance(): number; + boatMaxNumber(): number; + allianceDuration(): Tick; + allianceRequestCooldown(): Tick; + targetDuration(): Tick; + targetCooldown(): Tick; + emojiMessageCooldown(): Tick; + emojiMessageDuration(): Tick; + donateCooldown(): Tick; + defaultDonationAmount(sender: Player): number; + unitInfo(type: UnitType): UnitInfo; + tradeShipGold(src: Unit, dst: Unit): Gold; + tradeShipSpawnRate(): number; + defensePostRange(): number; + defensePostDefenseBonus(): number; + falloutDefenseModifier(): number; + difficultyModifier(difficulty: Difficulty): number; } export interface Theme { - playerInfoColor(id: PlayerID): Colord; - territoryColor(playerInfo: PlayerInfo): Colord; - borderColor(playerInfo: PlayerInfo): Colord; - defendedBorderColor(playerInfo: PlayerInfo): Colord; - terrainColor(tile: Tile): Colord; - backgroundColor(): Colord; - falloutColor(): Colord - font(): string; - // unit color for alternate view - selfColor(): Colord - allyColor(): Colord - enemyColor(): Colord - spawnHighlightColor(): Colord + playerInfoColor(id: PlayerID): Colord; + territoryColor(playerInfo: PlayerInfo): Colord; + borderColor(playerInfo: PlayerInfo): Colord; + defendedBorderColor(playerInfo: PlayerInfo): Colord; + terrainColor(tile: Tile): Colord; + backgroundColor(): Colord; + falloutColor(): Colord; + font(): string; + // unit color for alternate view + selfColor(): Colord; + allyColor(): Colord; + enemyColor(): Colord; + spawnHighlightColor(): Colord; } - diff --git a/src/server/Server.ts b/src/server/Server.ts index 80b79b6b0..583a5b60e 100644 --- a/src/server/Server.ts +++ b/src/server/Server.ts @@ -1,17 +1,27 @@ -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"; +import { Request, Response } from "express"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -21,170 +31,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); } +let lobbiesString = ""; + // 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: Request, res: Response) => { + res.send(lobbiesString); }); -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) + ); + 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); + setInterval(() => updateLobbies(), 100); } function tick() { - gm.tick() + gm.tick(); +} + +function updateLobbies() { + lobbiesString = JSON.stringify({ + lobbies: gm + .gamesByPhase(GamePhase.Lobby) + .filter((g) => g.isPublic) + .map((g) => ({ + id: g.id, + msUntilStart: g.startTime() - Date.now(), + numClients: g.numClients(), + })) + .sort((a, b) => a.msUntilStart - b.msUntilStart), + }); } 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(); diff --git a/update-deploy.sh b/update-deploy.sh index 190316a6e..ca46381f5 100755 --- a/update-deploy.sh +++ b/update-deploy.sh @@ -18,20 +18,20 @@ fi if [[ "$ENV" == "dev" ]]; then INSTANCE_NAME="openfrontio-dev-instance" TAG="dev" - GAME_ENV="preprod" # Set game environment to preprod for dev + GAME_ENV="preprod" echo "[DEV] Deploying to openfront.dev" else INSTANCE_NAME="openfrontio-instance" TAG="latest" - GAME_ENV="prod" # Set game environment to prod for prod + GAME_ENV="prod" echo "[PROD] Deploying to openfront.io" fi # Ensure you're authenticated with Google Cloud gcloud auth configure-docker us-central1-docker.pkg.dev -# Build the new Docker image with GAME_ENV build argument -docker build --build-arg GAME_ENV=$GAME_ENV -t openfrontio . +# Build the new Docker image with platform specification and GAME_ENV build argument +docker build --platform linux/amd64 --build-arg GAME_ENV=$GAME_ENV -t openfrontio . # Tag the new image docker tag openfrontio us-central1-docker.pkg.dev/openfrontio/openfrontio/game-server:$TAG