diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index 30ed02455..24928c946 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -101,81 +101,91 @@ export class PublicLobby extends LitElement { render() { if (this.lobbies.length === 0) return html``; - const lobby = this.lobbies[0]; - if (!lobby?.gameConfig) { - return; - } - const start = this.lobbyIDToStart.get(lobby.gameID) ?? 0; - const timeRemaining = Math.max(0, Math.floor((start - Date.now()) / 1000)); + const elements = this.lobbies.map((lobby) => { + if (!lobby?.gameConfig) { + return; + } + const start = this.lobbyIDToStart.get(lobby.gameID) ?? 0; + const timeRemaining = Math.max( + 0, + Math.floor((start - Date.now()) / 1000), + ); - // Format time to show minutes and seconds - const minutes = Math.floor(timeRemaining / 60); - const seconds = timeRemaining % 60; - const timeDisplay = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; + // Format time to show minutes and seconds + const minutes = Math.floor(timeRemaining / 60); + const seconds = timeRemaining % 60; + const timeDisplay = + minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; - const teamCount = - lobby.gameConfig.gameMode === GameMode.Team - ? lobby.gameConfig.playerTeams || 0 - : null; + const teamCount = + lobby.gameConfig.gameMode === GameMode.Team + ? lobby.gameConfig.playerTeams || 0 + : null; - const mapImageSrc = this.mapImages.get(lobby.gameID); + const mapImageSrc = this.mapImages.get(lobby.gameID); - return html` - - `; + + `; + }); + return elements; } leaveLobby() { diff --git a/src/client/index.html b/src/client/index.html index 77c4ece35..9a8ee7255 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -196,10 +196,7 @@ class="w-[20%] md:w-[15%] component-hideable" > -
-
- -
+
undefined), ); +export const CreateGameInputSchema = z.object({ + config: CreateGameConfigSchema, + startTime: z.number().optional(), +}); +export type CreateGameInput = z.infer; + export const GameInputSchema = GameConfigSchema.partial(); diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 23da54850..e70ee84b4 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -28,8 +28,12 @@ export class GameManager { return false; } - createGame(id: GameID, gameConfig: GameConfig | undefined) { - const game = new GameServer(id, this.log, Date.now(), this.config, { + createGame( + id: GameID, + gameConfig: GameConfig | undefined, + startTime: number, + ) { + const game = new GameServer(id, this.log, startTime, this.config, { gameMap: GameMapType.World, gameType: GameType.Private, difficulty: Difficulty.Medium, diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index eda377740..b29e97fc2 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -44,7 +44,7 @@ export class GameServer { // Used for record record keeping private allClients: Map = new Map(); private _hasStarted = false; - private _startTime: number | null = null; + private readonly createdAt = Date.now(); private endTurnIntervalID; @@ -64,7 +64,7 @@ export class GameServer { constructor( public readonly id: string, readonly log_: Logger, - public readonly createdAt: number, + private _startTime: number, private config: ServerConfig, public gameConfig: GameConfig, ) { @@ -278,12 +278,7 @@ export class GameServer { } public startTime(): number { - if (this._startTime !== null && this._startTime > 0) { - return this._startTime; - } else { - //game hasn't started yet, only works for public games - return this.createdAt + this.config.gameCreationRate(); - } + return this._startTime; } public prestart() { @@ -478,36 +473,16 @@ export class GameServer { const noRecentPings = now > this.lastPingUpdate + 20 * 1000; const noActive = this.activeClients.length === 0; - if (this.gameConfig.gameType !== GameType.Public) { - if (this._hasStarted) { - if (noActive && noRecentPings) { - this.log.info("private game complete", { - gameID: this.id, - }); - return GamePhase.Finished; - } else { - return GamePhase.Active; - } - } else { - return GamePhase.Lobby; - } - } - - const msSinceCreation = now - this.createdAt; - const lessThanLifetime = msSinceCreation < this.config.gameCreationRate(); - const notEnoughPlayers = - this.gameConfig.gameType === GameType.Public && - this.gameConfig.maxPlayers && - this.activeClients.length < this.gameConfig.maxPlayers; - if (lessThanLifetime && notEnoughPlayers) { + if (!this._hasStarted) { return GamePhase.Lobby; } - const warmupOver = - now > this.createdAt + this.config.gameCreationRate() + 30 * 1000; - if (noActive && warmupOver && noRecentPings) { + if (noActive && noRecentPings) { + this.log.info("game complete", { + type: this.gameConfig.gameType, + gameID: this.id, + }); return GamePhase.Finished; } - return GamePhase.Active; } diff --git a/src/server/Master.ts b/src/server/Master.ts index 8d970f6f6..24806ead2 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "url"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameInfo, PublicLobbies } from "../core/Schemas"; import { generateID } from "../core/Util"; +import { CreateGameInput } from "../core/WorkerSchemas"; import { gatekeeper, LimiterType } from "./Gatekeeper"; import { logger } from "./Logger"; import { MapPlaylist } from "./MapPlaylist"; @@ -61,8 +62,6 @@ app.use( let publicLobbiesJsonStr = ""; -const publicLobbyIDs: Set = new Set(); - // Start the master process export async function startMaster() { if (!cluster.isPrimary) { @@ -103,7 +102,7 @@ export async function startMaster() { setInterval( () => fetchLobbies().then((lobbies) => { - if (lobbies === 0) { + if (lobbies < 3) { scheduleLobbies(); } }), @@ -193,10 +192,12 @@ app.post( }), ); +const publicLobbies: Map = new Map(); + async function fetchLobbies(): Promise { const fetchPromises: Promise[] = []; - for (const gameID of new Set(publicLobbyIDs)) { + for (const [gameID] of publicLobbies) { const controller = new AbortController(); setTimeout(() => controller.abort(), 5000); // 5 second timeout const port = config.workerPort(gameID); @@ -211,7 +212,7 @@ async function fetchLobbies(): Promise { .catch((error) => { log.error(`Error fetching game ${gameID}:`, error); // Return null or a placeholder if fetch fails - publicLobbyIDs.delete(gameID); + publicLobbies.delete(gameID); return null; }); @@ -239,7 +240,7 @@ async function fetchLobbies(): Promise { l.msUntilStart !== undefined && l.msUntilStart <= 250 ) { - publicLobbyIDs.delete(l.gameID); + publicLobbies.delete(l.gameID); return; } @@ -252,7 +253,7 @@ async function fetchLobbies(): Promise { l.numClients !== undefined && l.gameConfig.maxPlayers <= l.numClients ) { - publicLobbyIDs.delete(l.gameID); + publicLobbies.delete(l.gameID); return; } }); @@ -262,16 +263,22 @@ async function fetchLobbies(): Promise { lobbies: lobbyInfos, } satisfies PublicLobbies); - return publicLobbyIDs.size; + return publicLobbies.size; } // Function to schedule a new public game async function schedulePublicGame(playlist: MapPlaylist) { const gameID = generateID(); - publicLobbyIDs.add(gameID); const workerPath = config.workerPath(gameID); + let lastGameCreatedTime = Date.now() - config.gameCreationRate(); + for (const value of publicLobbies.values()) { + lastGameCreatedTime = Math.max(value, lastGameCreatedTime); + } + const createdAt = lastGameCreatedTime + config.gameCreationRate(); + publicLobbies.set(gameID, createdAt); + // Send request to the worker to start the game try { const response = await fetch( @@ -282,7 +289,10 @@ async function schedulePublicGame(playlist: MapPlaylist) { "Content-Type": "application/json", [config.adminHeader()]: config.adminToken(), }, - body: JSON.stringify(playlist.gameConfig()), + body: JSON.stringify({ + config: playlist.gameConfig(), + createdAt, + } satisfies CreateGameInput), }, ); diff --git a/src/server/Worker.ts b/src/server/Worker.ts index aedea05c7..1e499af54 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -100,7 +100,7 @@ export function startWorker() { return res.status(400).json({ error }); } - const gc = result.data; + const gc = result.data.config; if ( gc?.gameType === GameType.Public && req.headers[config.adminHeader()] !== config.adminToken() @@ -120,7 +120,9 @@ export function startWorker() { return res.status(400).json({ error: "Worker, game id mismatch" }); } - const game = gm.createGame(id, gc); + const startTime = + result.data.startTime ?? Date.now() + config.gameCreationRate(); + const game = gm.createGame(id, gc, startTime); log.info( `Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating game ${game.isPublic() ? "Public" : "Private"} with id ${id}`,