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
This commit is contained in:
Evan
2026-02-14 11:59:35 -08:00
committed by GitHub
parent 6e557c52db
commit 2e2e686699
9 changed files with 182 additions and 126 deletions
+3 -2
View File
@@ -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;
+6 -1
View File
@@ -139,6 +139,9 @@ export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
export type GameInfo = z.infer<typeof GameInfoSchema>;
export type PublicGames = z.infer<typeof PublicGamesSchema>;
export type PublicGameInfo = z.infer<typeof PublicGameInfoSchema>;
export type PublicGameType = z.infer<typeof PublicGameTypeSchema>;
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 {
+3 -1
View File
@@ -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;
+3
View File
@@ -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,
};
}
+2
View File
@@ -3,6 +3,7 @@ import {
GameConfigSchema,
PublicGameInfoSchema,
PublicGamesSchema,
PublicGameTypeSchema,
} from "../core/Schemas";
export type WorkerLobbyList = z.infer<typeof WorkerLobbyListSchema>;
@@ -48,6 +49,7 @@ const MasterCreateGameSchema = z.object({
gameID: z.string(),
gameConfig: GameConfigSchema,
startsAt: z.number(),
publicGameType: PublicGameTypeSchema,
});
export const MasterMessageSchema = z.discriminatedUnion("type", [
+103 -82
View File
@@ -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<Record<GameMapName, number>> = {
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<PublicGameType, GameMapType[]> = {
ffa: [],
special: [],
team: [],
};
constructor(private disableTeams: boolean = false) {}
constructor() {}
public async gameConfig(): Promise<GameConfig> {
const { map, mode } = this.getNextMap();
public async gameConfig(type: PublicGameType): Promise<GameConfig> {
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;
}
}
+59 -39
View File
@@ -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<PublicGameType, PublicGameInfo[]> {
const lobbies = Array.from(this.workerLobbies.values()).flat();
const result: Record<PublicGameType, PublicGameInfo[]> = {
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}`);
}
}
+1 -1
View File
@@ -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() {
+2
View File
@@ -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);