mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-24 13:52:45 +00:00
add81b9c04
## 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>
322 lines
9.3 KiB
TypeScript
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"));
|
|
});
|