From 2e2e686699154074a6ad23ac788ba47d38251870 Mon Sep 17 00:00:00 2001 From: Evan Date: Sat, 14 Feb 2026 11:59:35 -0800 Subject: [PATCH] have lobby schedule ffa, teams, & special game types (#3196) ## Description: This implements the backend for multiple lobbies in preparation for https://github.com/openfrontio/OpenFrontIO/pull/3191 The server now schedules & sends a map of game type (ffa, teams, special) => public lobbies. NOTE: this is just temporary, the lobby only shows ffa currently. Have the Master scheduler schedule ffa, teams, & special games. ## 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: evan --- src/client/PublicLobby.ts | 5 +- src/core/Schemas.ts | 7 +- src/server/GameManager.ts | 4 +- src/server/GameServer.ts | 3 + src/server/IPCBridgeSchema.ts | 2 + src/server/MapPlaylist.ts | 185 +++++++++++++++++-------------- src/server/MasterLobbyService.ts | 98 +++++++++------- src/server/Worker.ts | 2 +- src/server/WorkerLobbyService.ts | 2 + 9 files changed, 182 insertions(+), 126 deletions(-) diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index ef9729c93..a688b66f2 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -49,7 +49,8 @@ export class PublicLobby extends LitElement { if (this.publicGames) { this.serverTimeOffset = this.publicGames.serverTime - Date.now(); } - this.publicGames.games.forEach((l) => { + // TODO: thihs is just a temporary scaffolding until PR #3191 is merged. + this.publicGames.games["ffa"]?.forEach((l) => { if (!this.lobbyIDToStart.has(l.gameID)) { // Convert server's startsAt to client time by subtracting offset const startsAt = l.startsAt ?? Date.now(); @@ -77,7 +78,7 @@ export class PublicLobby extends LitElement { render() { if (!this.publicGames) return html``; - const lobby = this.publicGames.games[0]; + const lobby = this.publicGames.games["ffa"]?.[0]; if (!lobby?.gameConfig) return html``; const start = this.lobbyIDToStart.get(lobby.gameID) ?? 0; diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 0f4f2ebcb..10b97969f 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -139,6 +139,9 @@ export type GameStartInfo = z.infer; export type GameInfo = z.infer; export type PublicGames = z.infer; export type PublicGameInfo = z.infer; +export type PublicGameType = z.infer; + +export const PublicGameTypeSchema = z.enum(["ffa", "team", "special"]); const ClientInfoSchema = z.object({ clientID: z.string(), @@ -152,6 +155,7 @@ export const GameInfoSchema = z.object({ startsAt: z.number().optional(), serverTime: z.number(), gameConfig: z.lazy(() => GameConfigSchema).optional(), + publicGameType: PublicGameTypeSchema.optional(), }); export const PublicGameInfoSchema = z.object({ @@ -159,11 +163,12 @@ export const PublicGameInfoSchema = z.object({ numClients: z.number(), startsAt: z.number(), gameConfig: z.lazy(() => GameConfigSchema).optional(), + publicGameType: PublicGameTypeSchema, }); export const PublicGamesSchema = z.object({ serverTime: z.number(), - games: PublicGameInfoSchema.array(), + games: z.record(PublicGameTypeSchema, z.array(PublicGameInfoSchema)), }); export class LobbyInfoEvent implements GameEvent { diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 4569b31fb..1be464dfa 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -8,7 +8,7 @@ import { GameMode, GameType, } from "../core/game/Game"; -import { GameConfig, GameID } from "../core/Schemas"; +import { GameConfig, GameID, PublicGameType } from "../core/Schemas"; import { Client } from "./Client"; import { GamePhase, GameServer } from "./GameServer"; @@ -57,6 +57,7 @@ export class GameManager { gameConfig: GameConfig | undefined, creatorPersistentID?: string, startsAt?: number, + publicGameType?: PublicGameType, ) { const game = new GameServer( id, @@ -83,6 +84,7 @@ export class GameManager { }, creatorPersistentID, startsAt, + publicGameType, ); this.games.set(id, game); return game; diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 341ece7bb..9603a10b8 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -13,6 +13,7 @@ import { GameStartInfo, GameStartInfoSchema, PlayerRecord, + PublicGameType, ServerDesyncSchema, ServerErrorMessage, ServerLobbyInfoMessage, @@ -90,6 +91,7 @@ export class GameServer { public gameConfig: GameConfig, private creatorPersistentID?: string, private startsAt?: number, + private publicGameType?: PublicGameType, ) { this.log = log_.child({ gameID: id }); } @@ -824,6 +826,7 @@ export class GameServer { gameConfig: this.gameConfig, startsAt: this.startsAt, serverTime: Date.now(), + publicGameType: this.publicGameType, }; } diff --git a/src/server/IPCBridgeSchema.ts b/src/server/IPCBridgeSchema.ts index e6034091e..100b0cf52 100644 --- a/src/server/IPCBridgeSchema.ts +++ b/src/server/IPCBridgeSchema.ts @@ -3,6 +3,7 @@ import { GameConfigSchema, PublicGameInfoSchema, PublicGamesSchema, + PublicGameTypeSchema, } from "../core/Schemas"; export type WorkerLobbyList = z.infer; @@ -48,6 +49,7 @@ const MasterCreateGameSchema = z.object({ gameID: z.string(), gameConfig: GameConfigSchema, startsAt: z.number(), + publicGameType: PublicGameTypeSchema, }); export const MasterMessageSchema = z.discriminatedUnion("type", [ diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index fca795506..ed299aec8 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -13,7 +13,7 @@ import { Trios, } from "../core/game/Game"; import { PseudoRandom } from "../core/PseudoRandom"; -import { GameConfig, TeamCountConfig } from "../core/Schemas"; +import { GameConfig, PublicGameType, TeamCountConfig } from "../core/Schemas"; import { logger } from "./Logger"; import { getMapLandTiles } from "./MapLandTiles"; @@ -71,11 +71,6 @@ const frequency: Partial> = { Hawaii: 4, }; -interface MapWithMode { - map: GameMapType; - mode: GameMode; -} - const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [ { config: 2, weight: 10 }, { config: 3, weight: 10 }, @@ -90,12 +85,23 @@ const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [ ]; export class MapPlaylist { - private mapsPlaylist: MapWithMode[] = []; + private playlists: Record = { + ffa: [], + special: [], + team: [], + }; - constructor(private disableTeams: boolean = false) {} + constructor() {} - public async gameConfig(): Promise { - const { map, mode } = this.getNextMap(); + public async gameConfig(type: PublicGameType): Promise { + if (type === "special") { + return this.getSpecialConfig(); + } + + // TODO: consider moving modifier to special lobby. + + const mode = type === "ffa" ? GameMode.FFA : GameMode.Team; + const map = this.getNextMap(type); const playerTeams = mode === GameMode.Team ? this.getTeamCount() : undefined; @@ -166,6 +172,31 @@ export class MapPlaylist { } satisfies GameConfig; } + private getSpecialConfig(): GameConfig { + // TODO: create better special configs. + const map = this.getNextMap("special"); + return { + donateGold: true, + donateTroops: true, + gameMap: map, + maxPlayers: 2, + gameType: GameType.Public, + gameMapSize: GameMapSize.Normal, + difficulty: Difficulty.Easy, + rankedType: RankedType.OneVOne, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + disableNations: true, + gameMode: GameMode.Team, + playerTeams: HumansVsNations, + bots: 100, + spawnImmunityDuration: 5 * 10, + disabledUnits: [], + } satisfies GameConfig; + } + public get1v1Config(): GameConfig { const maps = [ GameMapType.Iceland, @@ -199,19 +230,71 @@ export class MapPlaylist { } satisfies GameConfig; } - private getNextMap(): MapWithMode { - if (this.mapsPlaylist.length === 0) { - const numAttempts = 10000; - for (let i = 0; i < numAttempts; i++) { - if (this.shuffleMapsPlaylist()) { - log.info(`Generated map playlist in ${i} attempts`); - return this.mapsPlaylist.shift()!; + private getNextMap(type: PublicGameType): GameMapType { + const playlist = this.playlists[type]; + if (playlist.length === 0) { + playlist.push(...this.generateNewPlaylist()); + } + return playlist.shift()!; + } + + private generateNewPlaylist(): GameMapType[] { + const maps = this.buildMapsList(); + const rand = new PseudoRandom(Date.now()); + const shuffledSource = rand.shuffleArray([...maps]); + const playlist: GameMapType[] = []; + + const numAttempts = 10000; + for (let attempt = 0; attempt < numAttempts; attempt++) { + playlist.length = 0; + const source = [...shuffledSource]; + + let success = true; + while (source.length > 0) { + if (!this.addNextMapNonConsecutive(playlist, source)) { + success = false; + break; } } - log.error("Failed to generate a valid map playlist"); + + if (success) { + log.info(`Generated map playlist in ${attempt} attempts`); + return playlist; + } } - // Even if it failed, playlist will be partially populated. - return this.mapsPlaylist.shift()!; + + log.warn( + `Failed to generate non-consecutive playlist after ${numAttempts} attempts, falling back to shuffle`, + ); + return rand.shuffleArray([...maps]); + } + + private addNextMapNonConsecutive( + playlist: GameMapType[], + source: GameMapType[], + ): boolean { + const nonConsecutiveNum = 5; + const lastMaps = playlist.slice(-nonConsecutiveNum); + + for (let i = 0; i < source.length; i++) { + const map = source[i]; + if (!lastMaps.includes(map)) { + source.splice(i, 1); + playlist.push(map); + return true; + } + } + return false; + } + + private buildMapsList(): GameMapType[] { + const maps: GameMapType[] = []; + (Object.keys(GameMapType) as GameMapName[]).forEach((key) => { + for (let i = 0; i < (frequency[key] ?? 0); i++) { + maps.push(GameMapType[key]); + } + }); + return maps; } private getTeamCount(): TeamCountConfig { @@ -322,66 +405,4 @@ export class MapPlaylist { roundToNearest5(limitedBase * 0.5), ]; } - - private shuffleMapsPlaylist(): boolean { - const maps: GameMapType[] = []; - (Object.keys(GameMapType) as GameMapName[]).forEach((key) => { - for (let i = 0; i < (frequency[key] ?? 0); i++) { - maps.push(GameMapType[key]); - } - }); - - const rand = new PseudoRandom(Date.now()); - - const ffa1: GameMapType[] = rand.shuffleArray([...maps]); - const team1: GameMapType[] = rand.shuffleArray([...maps]); - const ffa2: GameMapType[] = rand.shuffleArray([...maps]); - const team2: GameMapType[] = rand.shuffleArray([...maps]); - const ffa3: GameMapType[] = rand.shuffleArray([...maps]); - - this.mapsPlaylist = []; - for (let i = 0; i < maps.length; i++) { - if (!this.addNextMap(this.mapsPlaylist, ffa1, GameMode.FFA)) { - return false; - } - if (!this.disableTeams) { - if (!this.addNextMap(this.mapsPlaylist, team1, GameMode.Team)) { - return false; - } - } - if (!this.addNextMap(this.mapsPlaylist, ffa2, GameMode.FFA)) { - return false; - } - if (!this.disableTeams) { - if (!this.addNextMap(this.mapsPlaylist, team2, GameMode.Team)) { - return false; - } - } - if (!this.addNextMap(this.mapsPlaylist, ffa3, GameMode.FFA)) { - return false; - } - } - return true; - } - - private addNextMap( - playlist: MapWithMode[], - nextEls: GameMapType[], - mode: GameMode, - ): boolean { - const nonConsecutiveNum = 5; - const lastEls = playlist - .slice(playlist.length - nonConsecutiveNum) - .map((m) => m.map); - for (let i = 0; i < nextEls.length; i++) { - const next = nextEls[i]; - if (lastEls.includes(next)) { - continue; - } - nextEls.splice(i, 1); - playlist.push({ map: next, mode: mode }); - return true; - } - return false; - } } diff --git a/src/server/MasterLobbyService.ts b/src/server/MasterLobbyService.ts index c82684cd9..0dfe89285 100644 --- a/src/server/MasterLobbyService.ts +++ b/src/server/MasterLobbyService.ts @@ -1,7 +1,7 @@ import { Worker } from "cluster"; import winston from "winston"; import { ServerConfig } from "../core/configuration/Config"; -import { PublicGameInfo } from "../core/Schemas"; +import { PublicGameInfo, PublicGameType } from "../core/Schemas"; import { generateID } from "../core/Util"; import { MasterCreateGame, @@ -72,11 +72,24 @@ export class MasterLobbyService { } } - private getAllLobbies(): PublicGameInfo[] { - const lobbies = Array.from(this.workerLobbies.values()) - .flat() - .sort((a, b) => a.startsAt! - b.startsAt); - return lobbies; + private getAllLobbies(): Record { + const lobbies = Array.from(this.workerLobbies.values()).flat(); + + const result: Record = { + ffa: [], + team: [], + special: [], + }; + + for (const lobby of lobbies) { + result[lobby.publicGameType].push(lobby); + } + + for (const type of Object.keys(result) as PublicGameType[]) { + result[type].sort((a, b) => a.startsAt - b.startsAt); + } + + return result; } private broadcastLobbies() { @@ -97,39 +110,46 @@ export class MasterLobbyService { } private async maybeScheduleLobby() { - const lobbies = this.getAllLobbies(); - if (lobbies.length >= 2) { - return; + const lobbiesByType = this.getAllLobbies(); + + for (const type of Object.keys(lobbiesByType) as PublicGameType[]) { + const lobbies = lobbiesByType[type]; + if (lobbies.length >= 2) { + continue; + } + + const lastStart = lobbies.reduce( + (max, pb) => Math.max(max, pb.startsAt), + Date.now(), + ); + + const gameID = generateID(); + const workerId = this.config.workerIndex(gameID); + + const gameConfig = await this.playlist.gameConfig(type); + const worker = this.workers.get(workerId); + if (!worker) { + this.log.error(`Worker ${workerId} not found`); + continue; + } + + worker.send( + { + type: "createGame", + gameID, + gameConfig, + startsAt: lastStart + this.config.gameCreationRate(), + publicGameType: type, + } satisfies MasterCreateGame, + (e) => { + if (e) { + this.log.error("Failed to schedule lobby on worker:", e); + } + }, + ); + this.log.info( + `Scheduled public game ${gameID} (${type}) on worker ${workerId}`, + ); } - - const lastStart = lobbies.reduce( - (max, pb) => Math.max(max, pb.startsAt), - Date.now(), - ); - - const gameID = generateID(); - const workerId = this.config.workerIndex(gameID); - - const gameConfig = await this.playlist.gameConfig(); - const worker = this.workers.get(workerId); - if (!worker) { - this.log.error(`Worker ${workerId} not found`); - return; - } - - worker.send( - { - type: "createGame", - gameID, - gameConfig, - startsAt: lastStart + this.config.gameCreationRate(), - } satisfies MasterCreateGame, - (e) => { - if (e) { - this.log.error("Failed to schedule lobby on worker:", e); - } - }, - ); - this.log.info(`Scheduled public game ${gameID} on worker ${workerId}`); } } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index a5bf11bed..47a55e559 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -36,7 +36,7 @@ const config = getServerConfigFromServer(); const workerId = parseInt(process.env.WORKER_ID ?? "0"); const log = logger.child({ comp: `w_${workerId}` }); -const playlist = new MapPlaylist(true); +const playlist = new MapPlaylist(); // Worker setup export async function startWorker() { diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts index d06609294..e9d7b7709 100644 --- a/src/server/WorkerLobbyService.ts +++ b/src/server/WorkerLobbyService.ts @@ -52,6 +52,7 @@ export class WorkerLobbyService { msg.gameConfig, undefined, msg.startsAt, + msg.publicGameType, ); break; } @@ -73,6 +74,7 @@ export class WorkerLobbyService { numClients: gi.clients?.length ?? 0, startsAt: gi.startsAt!, gameConfig: gi.gameConfig, + publicGameType: gi.publicGameType!, } satisfies PublicGameInfo; }); process.send?.({ type: "lobbyList", lobbies } satisfies WorkerLobbyList);