Files
OpenFrontIO/src/server/Master.ts
T
Scott Anderson 5167f67b96 Allow up to seven teams for players (#445)
## Description:

- Allow up to seven teams for players, and one for bots.
- Add team count selection to the single player dialog.
- Select random number of teams in server rotation.

Fixes #456

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors


![image](https://github.com/user-attachments/assets/cbc1ba82-9d06-4763-896c-abdce067a161)


![image](https://github.com/user-attachments/assets/9f82a0a5-c0bb-49b6-a714-2b13286a64ca)

## Please put your Discord username so you can be contacted if a bug or
regression is found:

fake.neo

---------

Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com>
2025-04-15 19:02:58 -07:00

296 lines
8.3 KiB
TypeScript

import cluster from "cluster";
import express from "express";
import rateLimit from "express-rate-limit";
import http from "http";
import path from "path";
import { fileURLToPath } from "url";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { Difficulty, GameMode, GameType } from "../core/game/Game";
import { GameConfig, GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
import { gatekeeper, LimiterType } from "./Gatekeeper";
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
import { setupMetricsServer } from "./MasterMetrics";
const config = getServerConfigFromServer();
const playlist = new MapPlaylist();
const readyWorkers = new Set();
const app = express();
const server = http.createServer(app);
// Create a separate metrics server on port 9090
const metricsApp = express();
const metricsServer = http.createServer(metricsApp);
const log = logger.child({ comp: "m" });
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.json());
app.use(
express.static(path.join(__dirname, "../../static"), {
maxAge: "1y", // Set max-age to 1 year for all static assets
setHeaders: (res, path) => {
// You can conditionally set different cache times based on file types
if (path.endsWith(".html")) {
// Set HTML files to no-cache to ensure Express doesn't send 304s
res.setHeader(
"Cache-Control",
"no-store, no-cache, must-revalidate, proxy-revalidate",
);
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
// Prevent conditional requests
res.setHeader("ETag", "");
} else if (path.match(/\.(js|css|svg)$/)) {
// JS, CSS, SVG get long cache with immutable
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
} else if (path.match(/\.(bin|dat|exe|dll|so|dylib)$/)) {
// Binary files also get long cache with immutable
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
}
// Other file types use the default maxAge setting
},
}),
);
app.use(express.json());
app.set("trust proxy", 3);
app.use(
rateLimit({
windowMs: 1000, // 1 second
max: 20, // 20 requests per IP per second
}),
);
let publicLobbiesJsonStr = "";
const publicLobbyIDs: Set<string> = new Set();
// Start the master process
export async function startMaster() {
if (!cluster.isPrimary) {
throw new Error(
"startMaster() should only be called in the primary process",
);
}
log.info(`Primary ${process.pid} is running`);
log.info(`Setting up ${config.numWorkers()} workers...`);
// Fork workers
for (let i = 0; i < config.numWorkers(); i++) {
const worker = cluster.fork({
WORKER_ID: i,
});
log.info(`Started worker ${i} (PID: ${worker.process.pid})`);
}
cluster.on("message", (worker, message) => {
if (message.type === "WORKER_READY") {
const workerId = message.workerId;
readyWorkers.add(workerId);
log.info(
`Worker ${workerId} is ready. (${readyWorkers.size}/${config.numWorkers()} ready)`,
);
// Start scheduling when all workers are ready
if (readyWorkers.size === config.numWorkers()) {
log.info("All workers ready, starting game scheduling");
const scheduleLobbies = () => {
schedulePublicGame(playlist).catch((error) => {
log.error("Error scheduling public game:", error);
});
};
setInterval(
() =>
fetchLobbies().then((lobbies) => {
if (lobbies == 0) {
scheduleLobbies();
}
}),
100,
);
}
}
});
// Handle worker crashes
cluster.on("exit", (worker, code, signal) => {
const workerId = (worker as any).process?.env?.WORKER_ID;
if (!workerId) {
log.error(`worker crashed could not find id`);
return;
}
log.warn(
`Worker ${workerId} (PID: ${worker.process.pid}) died with code: ${code} and signal: ${signal}`,
);
log.info(`Restarting worker ${workerId}...`);
// Restart the worker with the same ID
const newWorker = cluster.fork({
WORKER_ID: workerId,
});
log.info(
`Restarted worker ${workerId} (New PID: ${newWorker.process.pid})`,
);
});
const PORT = 3000;
server.listen(PORT, () => {
log.info(`Master HTTP server listening on port ${PORT}`);
});
// Setup the metrics server
setupMetricsServer();
}
app.get(
"/api/env",
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
const envConfig = {
game_env: process.env.GAME_ENV || "prod",
};
res.json(envConfig);
}),
);
// Add lobbies endpoint to list public games for this worker
app.get(
"/api/public_lobbies",
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
res.send(publicLobbiesJsonStr);
}),
);
async function fetchLobbies(): Promise<number> {
const fetchPromises = [];
for (const gameID of publicLobbyIDs) {
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000); // 5 second timeout
const port = config.workerPort(gameID);
const promise = fetch(`http://localhost:${port}/api/game/${gameID}`, {
headers: { [config.adminHeader()]: config.adminToken() },
signal: controller.signal,
})
.then((resp) => resp.json())
.then((json) => {
return json as GameInfo;
})
.catch((error) => {
log.error(`Error fetching game ${gameID}:`, error);
// Return null or a placeholder if fetch fails
return null;
});
fetchPromises.push(promise);
}
// Wait for all promises to resolve
const results = await Promise.all(fetchPromises);
// Filter out any null results from failed fetches
const lobbyInfos: GameInfo[] = results
.filter((result) => result !== null)
.map((gi: GameInfo) => {
return {
gameID: gi.gameID,
numClients: gi?.clients?.length ?? 0,
gameConfig: gi.gameConfig,
msUntilStart: (gi.msUntilStart ?? Date.now()) - Date.now(),
} as GameInfo;
});
lobbyInfos.forEach((l) => {
if (l.msUntilStart <= 250 || l.gameConfig.maxPlayers <= l.numClients) {
publicLobbyIDs.delete(l.gameID);
}
});
// Update the JSON string
publicLobbiesJsonStr = JSON.stringify({
lobbies: lobbyInfos,
});
return publicLobbyIDs.size;
}
let lastGameMode: GameMode = GameMode.FFA;
// Function to schedule a new public game
async function schedulePublicGame(playlist: MapPlaylist) {
const gameID = generateID();
const map = playlist.getNextMap();
publicLobbyIDs.add(gameID);
if (lastGameMode == GameMode.FFA) {
lastGameMode = GameMode.Team;
} else {
lastGameMode = GameMode.FFA;
}
const gameMode = playlist.getNextGameMode();
const numPlayerTeams =
gameMode === GameMode.Team ? 2 + Math.floor(Math.random() * 5) : undefined;
// Create the default public game config (from your GameManager)
const defaultGameConfig: GameConfig = {
gameMap: map,
maxPlayers: config.lobbyMaxPlayers(map),
gameType: GameType.Public,
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNPCs: gameMode == GameMode.Team,
disableNukes: false,
gameMode,
numPlayerTeams,
bots: 400,
};
const workerPath = config.workerPath(gameID);
// Send request to the worker to start the game
try {
const response = await fetch(
`http://localhost:${config.workerPort(gameID)}/api/create_game/${gameID}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
[config.adminHeader()]: config.adminToken(),
},
body: JSON.stringify({
gameConfig: defaultGameConfig,
}),
},
);
if (!response.ok) {
throw new Error(`Failed to schedule public game: ${response.statusText}`);
}
const data = await response.json();
} catch (error) {
log.error(`Failed to schedule public game on worker ${workerPath}:`, error);
throw error;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// SPA fallback route
app.get("*", function (req, res) {
res.sendFile(path.join(__dirname, "../../static/index.html"));
});