mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-04 20:41:59 +00:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user