Files
OpenFrontIO/src/server/Master.ts
T
Danny Asmussen add81b9c04 Reloading the page during a game should rejoin with the same clientID (#1836)
## Description:

This PR will fix #1204

Reloading the page during a game will rejoin with the same clientID, so
the player can resume, even if they have to catch up from the start. It
will use the localStorage to remember the clientID.

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

WoodyDRN

---------

Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com>
2025-08-23 02:24:37 +00:00

322 lines
9.3 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { ApiEnvResponse, ApiPublicLobbiesResponse } from "../core/ExpressSchemas";
import { LimiterType, gatekeeper } from "./Gatekeeper";
import { GameInfo } from "../core/Schemas";
import { ID } from "../core/BaseSchemas";
import { MapPlaylist } from "./MapPlaylist";
import cluster from "cluster";
import express from "express";
import { fileURLToPath } from "url";
import { generateID } from "../core/Util";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import http from "http";
import { logger } from "./Logger";
import path from "path";
import rateLimit from "express-rate-limit";
const config = getServerConfigFromServer();
const playlist = new MapPlaylist();
const readyWorkers = new Set();
const app = express();
const server = http.createServer(app);
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({
max: 20, // 20 requests per IP per second
windowMs: 1000, // 1 second
}),
);
let publicLobbiesJsonStr = JSON.stringify({
lobbies: [],
} satisfies ApiPublicLobbiesResponse);
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") {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { workerId } = message;
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) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
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({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
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}`);
});
}
app.get(
"/api/env",
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
const envConfig: ApiEnvResponse = {
game_env: process.env.GAME_ENV ?? "",
};
if (!envConfig.game_env) return res.sendStatus(500);
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);
}),
);
app.post(
"/api/kick_player/:gameID/:clientID",
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
if (req.headers[config.adminHeader()] !== config.adminToken()) {
res.status(401).send("Unauthorized");
return;
}
const { gameID, clientID } = req.params;
if (!ID.safeParse(gameID).success || !ID.safeParse(clientID).success) {
res.sendStatus(400);
return;
}
try {
const response = await fetch(
`http://localhost:${config.workerPort(gameID)}/api/kick_player/${gameID}/${clientID}`,
{
headers: {
[config.adminHeader()]: config.adminToken(),
},
method: "POST",
},
);
if (!response.ok) {
throw new Error(`Failed to kick player: ${response.statusText}`);
}
res.status(200).send("Player kicked successfully");
} catch (error) {
log.error(`Error kicking player from game ${gameID}:`, error);
res.status(500).send("Failed to kick player");
}
}),
);
async function fetchLobbies(): Promise<number> {
const fetchPromises: Promise<GameInfo | null>[] = [];
for (const gameID of new Set(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
publicLobbyIDs.delete(gameID);
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 {
gameConfig: gi.gameConfig,
gameID: gi.gameID,
msUntilStart: (gi.msUntilStart ?? Date.now()) - Date.now(),
numClients: gi?.clients?.length ?? 0,
} as GameInfo;
});
lobbyInfos.forEach((l) => {
if (
"msUntilStart" in l &&
l.msUntilStart !== undefined &&
l.msUntilStart <= 250
) {
publicLobbyIDs.delete(l.gameID);
return;
}
if (
"gameConfig" in l &&
l.gameConfig !== undefined &&
"maxPlayers" in l.gameConfig &&
l.gameConfig.maxPlayers !== undefined &&
"numClients" in l &&
l.numClients !== undefined &&
l.gameConfig.maxPlayers <= l.numClients
) {
publicLobbyIDs.delete(l.gameID);
return;
}
});
// Update the JSON string
publicLobbiesJsonStr = JSON.stringify({
lobbies: lobbyInfos,
} satisfies ApiPublicLobbiesResponse);
return publicLobbyIDs.size;
}
// Function to schedule a new public game
async function schedulePublicGame(playlist: MapPlaylist) {
const gameID = generateID();
publicLobbyIDs.add(gameID);
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}`,
{
body: JSON.stringify(playlist.gameConfig()),
headers: {
"Content-Type": "application/json",
[config.adminHeader()]: config.adminToken(),
},
method: "POST",
},
);
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"));
});