make server more efficient

This commit is contained in:
Evan
2025-02-05 17:31:57 -08:00
parent 6b701c1ddf
commit 4323888fde
8 changed files with 286 additions and 242 deletions
+1 -1
View File
@@ -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"]
+3 -2
View File
@@ -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
- game-server
+2 -2
View File
@@ -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",
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -35,7 +35,7 @@
OpenFront.io
</h1>
<h2 class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl mb-4">
(v0.14.0)
(v0.14.2)
</h2>
<div class="flex justify-center items-start">
<div class="w-full max-w-3xl p-4 space-y-4">
+105 -82
View File
@@ -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;
}
+170 -150
View File
@@ -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();
+4 -4
View File
@@ -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