`;
}
+ private toggleLobbyExpanded(gameID: string) {
+ const next = new Set(this.expandedLobbies);
+ if (next.has(gameID)) {
+ next.delete(gameID);
+ } else {
+ next.add(gameID);
+ }
+ this.expandedLobbies = next;
+ }
+
+ private async joinOpenLobby(gameID: string) {
+ this.startTrackingLobby(gameID);
+ try {
+ const gameExists = await this.checkActiveLobby(gameID);
+ if (this.currentLobbyId !== gameID) return;
+ if (!gameExists) {
+ this.resetTrackingState();
+ this.showMessage(translateText("private_lobby.not_found"), "red");
+ }
+ } catch {
+ if (this.currentLobbyId !== gameID) return;
+ this.resetTrackingState();
+ this.showMessage(translateText("private_lobby.error"), "red");
+ }
+ }
+
protected onOpen(args?: Record
): void {
const lobbyId = typeof args?.lobbyId === "string" ? args.lobbyId : "";
const lobbyInfo = args?.lobbyInfo as GameInfo | PublicGameInfo | undefined;
@@ -374,6 +685,7 @@ export class JoinLobbyModal extends BaseModal {
this.lobbyCreatorClientID = null;
this.isConnecting = true;
this.leaveLobbyOnClose = true;
+ this.expandedLobbies = new Set();
}
disconnectedCallback() {
@@ -410,11 +722,15 @@ export class JoinLobbyModal extends BaseModal {
if (!this.gameConfig) return html``;
const c = this.gameConfig;
- const mapName = getMapName(c.gameMap);
- const normalizedMap = normaliseMapKey(c.gameMap);
- const thumbnailUrl = assetUrl(
- `maps/${encodeURIComponent(normalizedMap)}/thumbnail.webp`,
- );
+ const isRandomMap = c.useRandomMap === true;
+ const mapName = isRandomMap
+ ? translateText("map.random")
+ : (getMapName(c.gameMap) ?? c.gameMap);
+ const thumbnailUrl = isRandomMap
+ ? this.randomMapThumbnail
+ : assetUrl(
+ `maps/${encodeURIComponent(normaliseMapKey(c.gameMap))}/thumbnail.webp`,
+ );
const isTeam = c.gameMode === GameMode.Team;
let modeSubtitle: string;
@@ -598,7 +914,9 @@ export class JoinLobbyModal extends BaseModal {
alt=${mapName ?? c.gameMap}
class="w-20 h-20 rounded-lg object-cover border border-white/10 shrink-0"
@error=${(e: Event) => {
- (e.target as HTMLImageElement).style.display = "none";
+ if (!isRandomMap) {
+ (e.target as HTMLImageElement).src = this.randomMapThumbnail;
+ }
}}
/>
diff --git a/src/client/Main.ts b/src/client/Main.ts
index dc3fb83321..1a60ac7f82 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -54,6 +54,7 @@ import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
import { TokenLoginModal } from "./TokenLoginModal";
import {
SendKickPlayerIntentEvent,
+ SendOpenToPublicIntentEvent,
SendStartGameEvent,
SendUpdateGameConfigIntentEvent,
} from "./Transport";
@@ -225,6 +226,7 @@ declare global {
userMeResponse: CustomEvent;
"leave-lobby": CustomEvent;
"update-game-config": CustomEvent;
+ "open-to-public": CustomEvent<{ publicGameType: string | null }>;
}
// Fixes the globalThis.addEventListener errors
@@ -381,6 +383,10 @@ class Client {
"update-game-config",
this.handleUpdateGameConfig.bind(this),
);
+ document.addEventListener(
+ "open-to-public",
+ this.handleOpenToPublic.bind(this),
+ );
document.addEventListener(
"open-matchmaking",
this.handleOpenMatchmaking.bind(this),
@@ -1028,6 +1034,23 @@ class Client {
}
}
+ private handleOpenToPublic(
+ event: CustomEvent<{ publicGameType: string | null }>,
+ ) {
+ if (!this.eventBus) return;
+ const raw = event.detail.publicGameType;
+ const validTypes = ["ffa", "team", "special"] as const;
+ const isValid =
+ raw === null || (validTypes as readonly string[]).includes(raw);
+ if (!isValid) {
+ console.error(`[open-to-public] invalid publicGameType: ${raw}`);
+ return;
+ }
+ this.eventBus.emit(
+ new SendOpenToPublicIntentEvent(raw as "ffa" | "team" | "special" | null),
+ );
+ }
+
private async getTurnstileToken(
lobby: JoinLobbyEvent,
): Promise {
diff --git a/src/client/Transport.ts b/src/client/Transport.ts
index fee60b966c..b99dce6a75 100644
--- a/src/client/Transport.ts
+++ b/src/client/Transport.ts
@@ -22,6 +22,7 @@ import {
ClientSendWinnerMessage,
GameConfig,
Intent,
+ PublicGameType,
ServerMessage,
ServerMessageSchema,
Winner,
@@ -176,6 +177,10 @@ export class SendUpdateGameConfigIntentEvent implements GameEvent {
export class SendStartGameEvent implements GameEvent {}
+export class SendOpenToPublicIntentEvent implements GameEvent {
+ constructor(public readonly publicGameType: PublicGameType | null) {}
+}
+
export class Transport {
private socket: WebSocket | null = null;
@@ -267,6 +272,10 @@ export class Transport {
);
this.eventBus.on(SendStartGameEvent, () => this.onSendStartGame());
+
+ this.eventBus.on(SendOpenToPublicIntentEvent, (e) =>
+ this.onSendOpenToPublicIntent(e),
+ );
}
private startPing() {
@@ -651,6 +660,13 @@ export class Transport {
this.sendIntent({ type: "start_game" });
}
+ private onSendOpenToPublicIntent(event: SendOpenToPublicIntentEvent) {
+ this.sendIntent({
+ type: "open_to_public",
+ publicGameType: event.publicGameType,
+ });
+ }
+
private sendIntent(intent: Intent) {
if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) {
const msg = {
diff --git a/src/client/Utils.ts b/src/client/Utils.ts
index 943e764a4a..f66233cc6d 100644
--- a/src/client/Utils.ts
+++ b/src/client/Utils.ts
@@ -1,6 +1,7 @@
import IntlMessageFormat from "intl-messageformat";
import {
Duos,
+ GameMapType,
GameMode,
HumansVsNations,
MessageType,
@@ -16,6 +17,10 @@ import { Platform } from "./Platform";
export const TUTORIAL_VIDEO_URL = "https://www.youtube.com/embed/EN2oOog3pSs";
export function normaliseMapKey(mapName: string): string {
+ const enumKey = Object.keys(GameMapType).find(
+ (k) => GameMapType[k as keyof typeof GameMapType] === mapName,
+ );
+ if (enumKey) return enumKey.toLowerCase();
return mapName.toLowerCase().replace(/[\s.]+/g, "");
}
diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts
index 44a19e2f54..4404a7b923 100644
--- a/src/core/ApiSchemas.ts
+++ b/src/core/ApiSchemas.ts
@@ -149,6 +149,7 @@ export const PlayerStatsTreeSchema = z.object({
Singleplayer: GameModeStatsSchema.optional(),
Public: GameModeStatsSchema.optional(),
Private: GameModeStatsSchema.optional(),
+ Custom: GameModeStatsSchema.optional(),
Ranked: z.partialRecord(z.enum(RankedType), PlayerStatsLeafSchema).optional(),
});
export type PlayerStatsTree = z.infer;
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index 4a1636e195..71351e9b52 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -51,7 +51,8 @@ export type Intent =
| KickPlayerIntent
| TogglePauseIntent
| UpdateGameConfigIntent
- | StartGameIntent;
+ | StartGameIntent
+ | OpenToPublicIntent;
export type AttackIntent = z.infer;
export type CancelAttackIntent = z.infer;
@@ -86,6 +87,7 @@ export type UpdateGameConfigIntent = z.infer<
typeof UpdateGameConfigIntentSchema
>;
export type StartGameIntent = z.infer;
+export type OpenToPublicIntent = z.infer;
export type Turn = z.infer;
export type GameConfig = z.infer;
@@ -166,6 +168,7 @@ export const GameInfoSchema = z.object({
serverTime: z.number(),
gameConfig: z.lazy(() => GameConfigSchema).optional(),
publicGameType: PublicGameTypeSchema.optional(),
+ openCustomType: PublicGameTypeSchema.nullable().optional(),
});
export const PublicGameInfoSchema = z.object({
@@ -179,6 +182,7 @@ export const PublicGameInfoSchema = z.object({
export const PublicGamesSchema = z.object({
serverTime: z.number(),
games: z.record(PublicGameTypeSchema, z.array(PublicGameInfoSchema)),
+ openLobbies: z.array(PublicGameInfoSchema).optional(),
});
export class LobbyInfoEvent implements GameEvent {
@@ -253,6 +257,7 @@ export const GameConfigSchema = z.object({
disableAlliances: z.boolean().nullable().optional(),
waterNukes: z.boolean().nullable().optional(),
randomSpawn: z.boolean(),
+ useRandomMap: z.boolean().optional(),
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).nullable().optional(), // In minutes
spawnImmunityDuration: z.number().int().min(0).nullable().optional(), // In ticks
@@ -459,6 +464,12 @@ export const StartGameIntentSchema = z.object({
type: z.literal("start_game"),
});
+export const OpenToPublicIntentSchema = z.object({
+ type: z.literal("open_to_public"),
+ // null closes the lobby to public; non-null opens it under the given category
+ publicGameType: PublicGameTypeSchema.nullable(),
+});
+
const IntentSchema = z.discriminatedUnion("type", [
AttackIntentSchema,
CancelAttackIntentSchema,
@@ -485,6 +496,7 @@ const IntentSchema = z.discriminatedUnion("type", [
TogglePauseIntentSchema,
UpdateGameConfigIntentSchema,
StartGameIntentSchema,
+ OpenToPublicIntentSchema,
]);
// StampedIntent = Intent with server-stamped clientID (used in turns and execution)
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index 0c71f45845..503517b890 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -273,6 +273,7 @@ export enum GameType {
Singleplayer = "Singleplayer",
Public = "Public",
Private = "Private",
+ Custom = "Custom",
}
export const isGameType = (value: unknown): value is GameType =>
isEnumValue(GameType, value);
diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts
index 72065a2067..786c641609 100644
--- a/src/server/GameManager.ts
+++ b/src/server/GameManager.ts
@@ -28,6 +28,12 @@ export class GameManager {
);
}
+ public openCustomLobbies(): GameServer[] {
+ return Array.from(this.games.values()).filter(
+ (g) => g.phase() === GamePhase.Lobby && g.openCustomLobbyType() !== null,
+ );
+ }
+
joinClient(
client: Client,
gameID: GameID,
diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts
index 43f26ce388..7eefd3c651 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -4,7 +4,7 @@ import WebSocket from "ws";
import { z } from "zod";
import { isAdminRole } from "../core/ApiSchemas";
import { GameEnv } from "../core/configuration/Config";
-import { GameType } from "../core/game/Game";
+import { GameMode, GameType } from "../core/game/Game";
import {
ClientID,
ClientMessageSchema,
@@ -13,6 +13,7 @@ import {
GameInfo,
GameStartInfo,
GameStartInfoSchema,
+ OpenToPublicIntent,
PlayerRecord,
PublicGameType,
ServerDesyncSchema,
@@ -93,6 +94,11 @@ export class GameServer {
private visibleAt?: number;
+ // When set, this private lobby is open for anyone to join via the custom list.
+ // Null means the lobby is closed to public. Once set to non-null at least once,
+ // gameConfig.gameType is permanently set to GameType.Custom.
+ private openCustomType: PublicGameType | null = null;
+
constructor(
public readonly id: string,
readonly log_: Logger,
@@ -118,6 +124,9 @@ export class GameServer {
if (gameConfig.gameMap !== undefined) {
this.gameConfig.gameMap = gameConfig.gameMap;
}
+ if (gameConfig.useRandomMap !== undefined) {
+ this.gameConfig.useRandomMap = gameConfig.useRandomMap;
+ }
if (gameConfig.gameMapSize !== undefined) {
this.gameConfig.gameMapSize = gameConfig.gameMapSize;
}
@@ -482,6 +491,54 @@ export class GameServer {
);
this.updateGameConfig(stampedIntent.config);
+ // Keep openCustomType in sync with gameMode when open to public
+ if (
+ this.openCustomType !== null &&
+ stampedIntent.config.gameMode !== undefined
+ ) {
+ this.openCustomType =
+ stampedIntent.config.gameMode === GameMode.Team
+ ? "team"
+ : "ffa";
+ }
+ return;
+ }
+ case "open_to_public": {
+ if (client.clientID !== this.lobbyCreatorID) {
+ this.log.warn(`Only lobby creator can open lobby to public`, {
+ clientID: client.clientID,
+ creatorID: this.lobbyCreatorID,
+ gameID: this.id,
+ });
+ return;
+ }
+ if (this.isPublic()) {
+ this.log.warn(
+ `Cannot open a system-managed public game to public`,
+ { gameID: this.id },
+ );
+ return;
+ }
+ if (this.hasStarted()) {
+ this.log.warn(
+ `Cannot change visibility after game has started`,
+ { gameID: this.id },
+ );
+ return;
+ }
+ const intent = stampedIntent as StampedIntent &
+ OpenToPublicIntent;
+ this.openCustomType = intent.publicGameType;
+ if (intent.publicGameType !== null) {
+ // Permanently mark as Custom once opened to public
+ this.gameConfig.gameType = GameType.Custom;
+ this.log.info(`Lobby opened to public`, {
+ gameID: this.id,
+ category: intent.publicGameType,
+ });
+ } else {
+ this.log.info(`Lobby closed to public`, { gameID: this.id });
+ }
return;
}
case "start_game": {
@@ -961,7 +1018,8 @@ export class GameServer {
gameConfig: this.gameConfig,
startsAt: this.startsAt,
serverTime: Date.now(),
- publicGameType: this.publicGameType,
+ publicGameType: this.publicGameType ?? this.openCustomType ?? undefined,
+ openCustomType: this.openCustomType ?? undefined,
};
}
@@ -969,6 +1027,10 @@ export class GameServer {
return this.gameConfig.gameType === GameType.Public;
}
+ public openCustomLobbyType(): PublicGameType | null {
+ return this.openCustomType;
+ }
+
public kickClient(
clientID: ClientID,
reasonKey: string = KICK_REASON_DUPLICATE_SESSION,
diff --git a/src/server/IPCBridgeSchema.ts b/src/server/IPCBridgeSchema.ts
index 48293614d9..353763c721 100644
--- a/src/server/IPCBridgeSchema.ts
+++ b/src/server/IPCBridgeSchema.ts
@@ -23,6 +23,7 @@ export type MasterMessage = z.infer;
const WorkerLobbyListSchema = z.object({
type: z.literal("lobbyList"),
lobbies: z.array(PublicGameInfoSchema),
+ openLobbies: z.array(PublicGameInfoSchema).optional(),
});
const WorkerReadySchema = z.object({
diff --git a/src/server/MasterLobbyService.ts b/src/server/MasterLobbyService.ts
index 25e4d23df2..61ffe4a820 100644
--- a/src/server/MasterLobbyService.ts
+++ b/src/server/MasterLobbyService.ts
@@ -22,6 +22,8 @@ export class MasterLobbyService {
private readonly workers = new Map();
// Worker id => the lobbies it owns.
private readonly workerLobbies = new Map();
+ // Worker id => open custom lobbies it owns.
+ private readonly workerOpenLobbies = new Map();
private readonly readyWorkers = new Set();
private started = false;
@@ -47,6 +49,7 @@ export class MasterLobbyService {
break;
case "lobbyList":
this.workerLobbies.set(workerId, msg.lobbies);
+ this.workerOpenLobbies.set(workerId, msg.openLobbies ?? []);
break;
}
});
@@ -55,6 +58,7 @@ export class MasterLobbyService {
removeWorker(workerId: number) {
this.workers.delete(workerId);
this.workerLobbies.delete(workerId);
+ this.workerOpenLobbies.delete(workerId);
this.readyWorkers.delete(workerId);
}
@@ -108,11 +112,13 @@ export class MasterLobbyService {
}
private broadcastLobbies() {
+ const openLobbies = Array.from(this.workerOpenLobbies.values()).flat();
const msg = {
type: "lobbiesBroadcast",
publicGames: {
serverTime: Date.now(),
games: this.getAllLobbies(),
+ openLobbies,
},
} satisfies MasterLobbiesBroadcast;
for (const [workerId, worker] of this.workers.entries()) {
diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts
index 3c5dab1d52..67a7d77de3 100644
--- a/src/server/WorkerLobbyService.ts
+++ b/src/server/WorkerLobbyService.ts
@@ -95,7 +95,28 @@ export class WorkerLobbyService {
publicGameType: gi.publicGameType!,
} satisfies PublicGameInfo;
});
- process.send?.({ type: "lobbyList", lobbies } satisfies WorkerLobbyList);
+
+ const openLobbies = this.gm
+ .openCustomLobbies()
+ .map((g) => g.gameInfo())
+ .filter(
+ (gi) => gi.openCustomType !== null && gi.openCustomType !== undefined,
+ )
+ .map((gi) => {
+ return {
+ gameID: gi.gameID,
+ numClients: gi.clients?.length ?? 0,
+ startsAt: gi.startsAt,
+ gameConfig: gi.gameConfig,
+ publicGameType: gi.openCustomType!,
+ } satisfies PublicGameInfo;
+ });
+
+ process.send?.({
+ type: "lobbyList",
+ lobbies,
+ openLobbies,
+ } satisfies WorkerLobbyList);
}
private setupUpgradeHandler() {