This commit is contained in:
Aotumuri
2026-05-16 22:01:31 +08:00
parent b8137927a6
commit 72336bcacb
13 changed files with 417 additions and 24 deletions
+10 -1
View File
@@ -27,7 +27,8 @@
"click_to_copy": "Click to copy", "click_to_copy": "Click to copy",
"enabled": "Enabled", "enabled": "Enabled",
"disabled": "Disabled", "disabled": "Disabled",
"map_default": "Map default" "map_default": "Map default",
"show_less": "Show Less"
}, },
"main": { "main": {
"title": "OpenFront (ALPHA)", "title": "OpenFront (ALPHA)",
@@ -537,6 +538,12 @@
"error": "An error occurred. Please try again or contact support.", "error": "An error occurred. Please try again or contact support.",
"joined_waiting": "Lobby joined! Waiting for host to start...", "joined_waiting": "Lobby joined! Waiting for host to start...",
"version_mismatch": "This game was created with a different version. Cannot join.", "version_mismatch": "This game was created with a different version. Cannot join.",
"listed_private_games": "Listed Private Games",
"no_listed_private_games": "No listed private games available.",
"show_all_listed_private_games": "Show More Lobbies (+{count})",
"show_all_settings": "Show All Settings (+{count})",
"show_fewer_settings": "Show Fewer Settings",
"join_by_lobby_id": "Join by Lobby ID",
"disabled_units": "Disabled Units", "disabled_units": "Disabled Units",
"game_length": "Game length", "game_length": "Game length",
"pvp_immunity": "PVP immunity duration", "pvp_immunity": "PVP immunity duration",
@@ -619,6 +626,8 @@
"starting_gold": "Starting Gold (Millions)", "starting_gold": "Starting Gold (Millions)",
"starting_gold_placeholder": "5", "starting_gold_placeholder": "5",
"host_cheats": "Host Cheats", "host_cheats": "Host Cheats",
"listed_private_game": "Listed Private Game",
"listed_private_game_desc": "Show this private lobby in the Join Lobby menu.",
"leave_confirmation": "Are you sure you want to leave the lobby?" "leave_confirmation": "Are you sure you want to leave the lobby?"
}, },
"team_colors": { "team_colors": {
+28
View File
@@ -10,6 +10,7 @@ import {
GameMode, GameMode,
UnitType, UnitType,
} from "../core/game/Game"; } from "../core/game/Game";
import { hasListedPrivateGameFlare } from "../core/ListedPrivateGame";
import { import {
ClientInfo, ClientInfo,
GameConfig, GameConfig,
@@ -19,6 +20,7 @@ import {
isValidGameID, isValidGameID,
} from "../core/Schemas"; } from "../core/Schemas";
import { generateID } from "../core/Util"; import { generateID } from "../core/Util";
import { getUserMe } from "./Api";
import { getPlayToken } from "./Auth"; import { getPlayToken } from "./Auth";
import "./components/baseComponents/Modal"; import "./components/baseComponents/Modal";
import { BaseModal } from "./components/BaseModal"; import { BaseModal } from "./components/BaseModal";
@@ -85,6 +87,8 @@ export class HostLobbyModal extends BaseModal {
@state() private hostCheatGoldMultiplierValue: number | undefined = undefined; @state() private hostCheatGoldMultiplierValue: number | undefined = undefined;
@state() private hostCheatStartingGold: boolean = false; @state() private hostCheatStartingGold: boolean = false;
@state() private hostCheatStartingGoldValue: number | undefined = undefined; @state() private hostCheatStartingGoldValue: number | undefined = undefined;
@state() private canListPrivateGame: boolean = false;
@state() private listedPrivateGame: boolean = false;
@state() private lobbyCreatorClientID: string = ""; @state() private lobbyCreatorClientID: string = "";
@property({ attribute: false }) eventBus: EventBus | null = null; @property({ attribute: false }) eventBus: EventBus | null = null;
@@ -352,6 +356,12 @@ export class HostLobbyModal extends BaseModal {
labelKey: "host_modal.host_cheats", labelKey: "host_modal.host_cheats",
checked: this.hostCheatsEnabled, checked: this.hostCheatsEnabled,
}, },
{
labelKey: "host_modal.listed_private_game",
descriptionKey: "host_modal.listed_private_game_desc",
checked: this.listedPrivateGame,
hidden: !this.canListPrivateGame,
},
], ],
inputCards, inputCards,
}, },
@@ -419,6 +429,7 @@ export class HostLobbyModal extends BaseModal {
protected onOpen(): void { protected onOpen(): void {
this.startLobbyUpdates(); this.startLobbyUpdates();
void this.loadListedPrivateGameAccess();
this.lobbyId = generateID(); this.lobbyId = generateID();
// Note: clientID will be assigned by server when we join the lobby // Note: clientID will be assigned by server when we join the lobby
// lobbyCreatorClientID stays empty until then // lobbyCreatorClientID stays empty until then
@@ -537,6 +548,7 @@ export class HostLobbyModal extends BaseModal {
this.hostCheatGoldMultiplierValue = undefined; this.hostCheatGoldMultiplierValue = undefined;
this.hostCheatStartingGold = false; this.hostCheatStartingGold = false;
this.hostCheatStartingGoldValue = undefined; this.hostCheatStartingGoldValue = undefined;
this.listedPrivateGame = false;
this.leaveLobbyOnClose = true; this.leaveLobbyOnClose = true;
} }
@@ -625,6 +637,10 @@ export class HostLobbyModal extends BaseModal {
this.hostCheatsEnabled = checked; this.hostCheatsEnabled = checked;
this.putGameConfig(); this.putGameConfig();
break; break;
case "host_modal.listed_private_game":
this.listedPrivateGame = this.canListPrivateGame && checked;
this.putGameConfig();
break;
default: default:
break; break;
} }
@@ -944,6 +960,8 @@ export class HostLobbyModal extends BaseModal {
instantBuild: this.instantBuild, instantBuild: this.instantBuild,
randomSpawn: this.randomSpawn, randomSpawn: this.randomSpawn,
gameMode: this.gameMode, gameMode: this.gameMode,
listedPrivateGame:
this.canListPrivateGame && this.listedPrivateGame,
disabledUnits: this.disabledUnits, disabledUnits: this.disabledUnits,
spawnImmunityDuration: this.spawnImmunity spawnImmunityDuration: this.spawnImmunity
? spawnImmunityTicks ? spawnImmunityTicks
@@ -1030,6 +1048,16 @@ export class HostLobbyModal extends BaseModal {
// Leave existing values unchanged so the UI stays consistent // Leave existing values unchanged so the UI stays consistent
} }
} }
private async loadListedPrivateGameAccess() {
const userMe = await getUserMe();
const flares = userMe === false ? [] : (userMe.player.flares ?? []);
this.canListPrivateGame = hasListedPrivateGameFlare(flares);
if (!this.canListPrivateGame && this.listedPrivateGame) {
this.listedPrivateGame = false;
this.putGameConfig();
}
}
} }
async function createLobby(gameID: string): Promise<GameInfo> { async function createLobby(gameID: string): Promise<GameInfo> {
+251 -1
View File
@@ -11,6 +11,7 @@ import {
} from "../client/Utils"; } from "../client/Utils";
import { assetUrl } from "../core/AssetUrls"; import { assetUrl } from "../core/AssetUrls";
import { EventBus } from "../core/EventBus"; import { EventBus } from "../core/EventBus";
import { LISTED_PRIVATE_GAME_TYPE } from "../core/ListedPrivateGame";
import { import {
ClientInfo, ClientInfo,
GAME_ID_REGEX, GAME_ID_REGEX,
@@ -19,6 +20,7 @@ import {
GameRecordSchema, GameRecordSchema,
LobbyInfoEvent, LobbyInfoEvent,
PublicGameInfo, PublicGameInfo,
PublicGames,
} from "../core/Schemas"; } from "../core/Schemas";
import { import {
Difficulty, Difficulty,
@@ -55,6 +57,9 @@ export class JoinLobbyModal extends BaseModal {
@state() private serverTimeOffset: number = 0; @state() private serverTimeOffset: number = 0;
@state() private isConnecting: boolean = true; @state() private isConnecting: boolean = true;
@state() private lobbyCreatorClientID: string | null = null; @state() private lobbyCreatorClientID: string | null = null;
@state() private publicGames: PublicGames | null = null;
@state() private listedPrivateGamesExpanded: boolean = false;
@state() private expandedListedPrivateGameSettings: Set<string> = new Set();
private leaveLobbyOnClose = true; private leaveLobbyOnClose = true;
private countdownTimerId: number | null = null; private countdownTimerId: number | null = null;
@@ -77,6 +82,11 @@ export class JoinLobbyModal extends BaseModal {
}); });
}; };
private readonly handlePublicLobbiesUpdate = (event: Event) => {
const customEvent = event as CustomEvent<{ payload: PublicGames }>;
this.publicGames = customEvent.detail.payload;
};
protected renderHeaderSlot() { protected renderHeaderSlot() {
if (!this.currentLobbyId) { if (!this.currentLobbyId) {
return modalHeader({ return modalHeader({
@@ -217,8 +227,25 @@ export class JoinLobbyModal extends BaseModal {
} }
private renderJoinForm() { private renderJoinForm() {
const listedPrivateGames =
this.publicGames?.games?.[LISTED_PRIVATE_GAME_TYPE] ?? [];
const visibleListedPrivateGames = this.listedPrivateGamesExpanded
? listedPrivateGames
: listedPrivateGames.slice(0, 3);
const hiddenListedPrivateGameCount =
listedPrivateGames.length - visibleListedPrivateGames.length;
return html` return html`
<form @submit=${this.joinLobbyFromInput} class="custom-scrollbar p-6 space-y-4 mr-1"> <form
@submit=${this.joinLobbyFromInput}
class="custom-scrollbar p-6 space-y-4 mr-1"
>
<div class="pt-2 border-t border-white/10">
<h3
class="mb-3 text-sm font-bold text-white uppercase tracking-wider"
>
${translateText("private_lobby.join_by_lobby_id")}
</h3>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
@@ -256,10 +283,219 @@ export class JoinLobbyModal extends BaseModal {
></o-button> ></o-button>
</div> </div>
</div> </div>
<div class="space-y-3 pt-2 border-t border-white/10">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-bold text-white uppercase tracking-wider">
${translateText("private_lobby.listed_private_games")}
</h3>
${listedPrivateGames.length > 3
? html`
<button
type="button"
class="text-xs font-bold uppercase tracking-wider text-malibu-blue hover:text-aquarius transition-colors"
@click=${this.toggleListedPrivateGamesExpanded}
>
${this.listedPrivateGamesExpanded
? translateText("common.show_less")
: translateText(
"private_lobby.show_all_listed_private_games",
{
count: String(hiddenListedPrivateGameCount),
},
)}
</button>
`
: ""}
</div>
${listedPrivateGames.length === 0
? html`
<div
class="px-4 py-3 rounded-xl border border-white/10 bg-white/5 text-sm text-white/50"
>
${translateText("private_lobby.no_listed_private_games")}
</div>
`
: html`
<div class="grid grid-cols-1 gap-2">
${visibleListedPrivateGames.map((lobby) =>
this.renderListedPrivateGame(lobby),
)}
</div>
`}
</div>
</form> </form>
`; `;
} }
private renderListedPrivateGame(lobby: PublicGameInfo): TemplateResult {
const mapName = getMapName(lobby.gameConfig?.gameMap);
const settings = this.listedPrivateGameSettings(lobby.gameConfig);
const settingsExpanded = this.expandedListedPrivateGameSettings.has(
lobby.gameID,
);
const visibleSettings = settingsExpanded ? settings : settings.slice(0, 3);
const hiddenSettingsCount = settings.length - visibleSettings.length;
const mode =
lobby.gameConfig?.gameMode === GameMode.Team
? translateText("game_mode.teams")
: translateText("game_mode.ffa");
return html`
<div
class="w-full min-w-0 px-3 py-3 rounded-xl border border-white/10 bg-white/5 text-left"
>
<div class="flex flex-col gap-3">
<div class="min-w-0 space-y-1">
<div class="text-sm font-bold text-white truncate">
${mapName ?? lobby.gameConfig?.gameMap ?? lobby.gameID}
</div>
<div class="text-xs text-white/55 uppercase tracking-wider">
${mode}
</div>
</div>
${settings.length > 0
? html`
<div class="grid grid-cols-1 gap-1">
${visibleSettings.map(
(setting) => html`
<div
class="w-full min-w-0 rounded bg-black/25 border border-white/10 px-2 py-1 text-[11px] font-bold uppercase tracking-wider text-white/70 truncate"
>
${setting}
</div>
`,
)}
</div>
${settings.length > 3
? html`
<button
type="button"
class="self-start text-[11px] font-bold uppercase tracking-wider text-malibu-blue hover:text-aquarius transition-colors"
@click=${() =>
this.toggleListedPrivateGameSettings(lobby.gameID)}
>
${settingsExpanded
? translateText("private_lobby.show_fewer_settings")
: translateText("private_lobby.show_all_settings", {
count: String(hiddenSettingsCount),
})}
</button>
`
: ""}
`
: ""}
<div class="flex items-center justify-between gap-2">
<div class="shrink-0 text-xs font-bold text-white/80">
${lobby.numClients}/${lobby.gameConfig?.maxPlayers ?? "-"}
</div>
<button
type="button"
class="px-3 py-2 rounded-lg bg-malibu-blue hover:bg-aquarius text-white text-xs font-bold uppercase tracking-wider transition-colors"
@click=${() => this.joinListedPrivateGame(lobby)}
>
${translateText("private_lobby.join_lobby")}
</button>
</div>
</div>
</div>
`;
}
private listedPrivateGameSettings(config: GameConfig | undefined): string[] {
if (!config) return [];
const settings: string[] = [];
const isTeam = config.gameMode === GameMode.Team;
const isCompact =
config.gameMapSize === GameMapSize.Compact ||
config.publicGameModifiers?.isCompact;
if (isCompact) settings.push(translateText("host_modal.compact_map"));
if (config.difficulty !== Difficulty.Easy) {
settings.push(
translateText(`difficulty.${config.difficulty.toLowerCase()}`),
);
}
if (config.infiniteTroops)
settings.push(translateText("host_modal.infinite_troops"));
if (config.infiniteGold)
settings.push(translateText("host_modal.infinite_gold"));
if (config.instantBuild)
settings.push(translateText("host_modal.instant_build"));
if (config.randomSpawn)
settings.push(translateText("host_modal.random_spawn"));
if (config.maxTimerValue)
settings.push(
`${translateText("private_lobby.game_length")}: ${config.maxTimerValue} min`,
);
if (config.spawnImmunityDuration) {
const seconds = Math.round(config.spawnImmunityDuration / 10);
settings.push(
`${translateText("private_lobby.pvp_immunity")}: ${renderDuration(seconds)}`,
);
}
if (config.startingGold) {
const millions = parseFloat(
(config.startingGold / 1_000_000).toPrecision(12),
);
settings.push(
`${translateText("private_lobby.starting_gold")}: ${millions}M`,
);
}
if (config.goldMultiplier) {
settings.push(
`${translateText("host_modal.gold_multiplier")}: x${config.goldMultiplier}`,
);
}
if (config.disableAlliances) {
settings.push(translateText("host_modal.disable_alliances"));
}
if (config.waterNukes) {
settings.push(translateText("host_modal.water_nukes"));
}
if ((isTeam && !config.donateGold) || (!isTeam && config.donateGold)) {
settings.push(translateText("host_modal.donate_gold"));
}
if ((isTeam && !config.donateTroops) || (!isTeam && config.donateTroops)) {
settings.push(translateText("host_modal.donate_troops"));
}
if (config.disabledUnits && config.disabledUnits.length > 0) {
settings.push(translateText("private_lobby.disabled_units"));
}
if (config.hostCheats) {
settings.push(translateText("private_lobby.host_cheats"));
}
return settings;
}
private toggleListedPrivateGameSettings(gameID: string) {
const expanded = new Set(this.expandedListedPrivateGameSettings);
if (expanded.has(gameID)) {
expanded.delete(gameID);
} else {
expanded.add(gameID);
}
this.expandedListedPrivateGameSettings = expanded;
}
private toggleListedPrivateGamesExpanded = () => {
this.listedPrivateGamesExpanded = !this.listedPrivateGamesExpanded;
};
private joinListedPrivateGame(lobby: PublicGameInfo) {
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameID: lobby.gameID,
source: "listed-private",
publicLobbyInfo: lobby,
} as JoinLobbyEvent,
bubbles: true,
composed: true,
}),
);
}
protected onOpen(args?: Record<string, unknown>): void { protected onOpen(args?: Record<string, unknown>): void {
const lobbyId = typeof args?.lobbyId === "string" ? args.lobbyId : ""; const lobbyId = typeof args?.lobbyId === "string" ? args.lobbyId : "";
const lobbyInfo = args?.lobbyInfo as GameInfo | PublicGameInfo | undefined; const lobbyInfo = args?.lobbyInfo as GameInfo | PublicGameInfo | undefined;
@@ -374,14 +610,28 @@ export class JoinLobbyModal extends BaseModal {
this.lobbyCreatorClientID = null; this.lobbyCreatorClientID = null;
this.isConnecting = true; this.isConnecting = true;
this.leaveLobbyOnClose = true; this.leaveLobbyOnClose = true;
this.listedPrivateGamesExpanded = false;
this.expandedListedPrivateGameSettings = new Set();
} }
disconnectedCallback() { disconnectedCallback() {
this.clearCountdownTimer(); this.clearCountdownTimer();
this.stopLobbyUpdates(); this.stopLobbyUpdates();
document.removeEventListener(
"public-lobbies-update",
this.handlePublicLobbiesUpdate,
);
super.disconnectedCallback(); super.disconnectedCallback();
} }
connectedCallback() {
super.connectedCallback();
document.addEventListener(
"public-lobbies-update",
this.handlePublicLobbiesUpdate,
);
}
public closeAndLeave() { public closeAndLeave() {
this.leaveLobby(); this.leaveLobby();
try { try {
+8 -2
View File
@@ -240,7 +240,13 @@ export interface JoinLobbyEvent {
gameStartInfo?: GameStartInfo; gameStartInfo?: GameStartInfo;
// GameRecord exists when replaying an archived game. // GameRecord exists when replaying an archived game.
gameRecord?: GameRecord; gameRecord?: GameRecord;
source?: "public" | "private" | "host" | "matchmaking" | "singleplayer"; source?:
| "public"
| "listed-private"
| "private"
| "host"
| "matchmaking"
| "singleplayer";
publicLobbyInfo?: GameInfo | PublicGameInfo; publicLobbyInfo?: GameInfo | PublicGameInfo;
} }
@@ -824,7 +830,7 @@ class Client {
this.lobbyHandle.stop(true); this.lobbyHandle.stop(true);
document.body.classList.remove("in-game"); document.body.classList.remove("in-game");
} }
if (lobby.source === "public") { if (lobby.source === "public" || lobby.source === "listed-private") {
this.joinModal?.open({ this.joinModal?.open({
lobbyId: lobby.gameID, lobbyId: lobby.gameID,
lobbyInfo: lobby.publicLobbyInfo, lobbyInfo: lobby.publicLobbyInfo,
@@ -163,6 +163,7 @@ function renderSectionHeader(
export interface ToggleOptionConfig { export interface ToggleOptionConfig {
labelKey: string; labelKey: string;
descriptionKey?: string;
checked: boolean; checked: boolean;
hidden?: boolean; hidden?: boolean;
} }
@@ -284,6 +285,27 @@ export class GameConfigSettings extends LitElement {
private renderOptionToggle(toggle: ToggleOptionConfig): TemplateResult { private renderOptionToggle(toggle: ToggleOptionConfig): TemplateResult {
if (toggle.hidden) return html``; if (toggle.hidden) return html``;
if (toggle.descriptionKey) {
return html`
<button
class="${cardClass(toggle.checked, "p-4 text-center")}"
@click=${() => this.handleOptionToggle(toggle)}
aria-pressed=${toggle.checked}
>
<span class="${CARD_LABEL_CLASS} ${stateTextClass(toggle.checked)}">
${translateText(toggle.labelKey)}
</span>
<span
class="block mt-2 text-[11px] normal-case font-medium tracking-normal leading-snug ${toggle.checked
? "text-white/80"
: "text-white/45"}"
>
${translateText(toggle.descriptionKey)}
</span>
</button>
`;
}
return renderTextCardButton( return renderTextCardButton(
translateText(toggle.labelKey), translateText(toggle.labelKey),
toggle.checked, toggle.checked,
+7
View File
@@ -0,0 +1,7 @@
export const LISTED_PRIVATE_GAME_FLARE = "game:*";
export const LISTED_PRIVATE_GAME_TYPE = "listed-private";
export function hasListedPrivateGameFlare(flares: readonly string[]): boolean {
return true;
// return flares.includes(LISTED_PRIVATE_GAME_FLARE);
}
+8 -1
View File
@@ -20,6 +20,7 @@ import {
Trios, Trios,
UnitType, UnitType,
} from "./game/Game"; } from "./game/Game";
import { LISTED_PRIVATE_GAME_TYPE } from "./ListedPrivateGame";
import { PlayerStatsSchema } from "./StatsSchemas"; import { PlayerStatsSchema } from "./StatsSchemas";
import { flattenedEmojiTable } from "./Util"; import { flattenedEmojiTable } from "./Util";
@@ -139,7 +140,12 @@ export type PublicGames = z.infer<typeof PublicGamesSchema>;
export type PublicGameInfo = z.infer<typeof PublicGameInfoSchema>; export type PublicGameInfo = z.infer<typeof PublicGameInfoSchema>;
export type PublicGameType = z.infer<typeof PublicGameTypeSchema>; export type PublicGameType = z.infer<typeof PublicGameTypeSchema>;
export const PublicGameTypeSchema = z.enum(["ffa", "team", "special"]); export const PublicGameTypeSchema = z.enum([
"ffa",
"team",
"special",
LISTED_PRIVATE_GAME_TYPE,
] as const);
export const UsernameSchema = z export const UsernameSchema = z
.string() .string()
@@ -221,6 +227,7 @@ export const GameConfigSchema = z.object({
donateTroops: z.boolean(), // Configures donations to humans only donateTroops: z.boolean(), // Configures donations to humans only
gameType: z.enum(GameType), gameType: z.enum(GameType),
gameMode: z.enum(GameMode), gameMode: z.enum(GameMode),
listedPrivateGame: z.boolean().optional(),
rankedType: z.enum(RankedType).optional(), // Only set for ranked games. rankedType: z.enum(RankedType).optional(), // Only set for ranked games.
gameMapSize: z.enum(GameMapSize), gameMapSize: z.enum(GameMapSize),
publicGameModifiers: z publicGameModifiers: z
+9
View File
@@ -28,6 +28,15 @@ export class GameManager {
); );
} }
public listedPrivateLobbies(): GameServer[] {
return Array.from(this.games.values()).filter(
(g) =>
g.phase() === GamePhase.Lobby &&
!g.isPublic() &&
g.gameConfig.listedPrivateGame === true,
);
}
joinClient( joinClient(
client: Client, client: Client,
gameID: GameID, gameID: GameID,
+18
View File
@@ -5,6 +5,7 @@ import { z } from "zod";
import { isAdminRole } from "../core/ApiSchemas"; import { isAdminRole } from "../core/ApiSchemas";
import { GameEnv } from "../core/configuration/Config"; import { GameEnv } from "../core/configuration/Config";
import { GameType } from "../core/game/Game"; import { GameType } from "../core/game/Game";
import { hasListedPrivateGameFlare } from "../core/ListedPrivateGame";
import { import {
ClientID, ClientID,
ClientMessageSchema, ClientMessageSchema,
@@ -177,6 +178,9 @@ export class GameServer {
if (gameConfig.waterNukes !== undefined) { if (gameConfig.waterNukes !== undefined) {
this.gameConfig.waterNukes = gameConfig.waterNukes ?? undefined; this.gameConfig.waterNukes = gameConfig.waterNukes ?? undefined;
} }
if (gameConfig.listedPrivateGame !== undefined) {
this.gameConfig.listedPrivateGame = gameConfig.listedPrivateGame;
}
this.gameConfig.hostCheats = gameConfig.hostCheats; this.gameConfig.hostCheats = gameConfig.hostCheats;
} }
@@ -473,6 +477,20 @@ export class GameServer {
return; return;
} }
if (
stampedIntent.config.listedPrivateGame === true &&
!hasListedPrivateGameFlare(client.flares ?? [])
) {
this.log.warn(
`Client without required flare attempted to list private game`,
{
gameID: this.id,
clientID: client.clientID,
},
);
return;
}
this.log.info( this.log.info(
`Lobby creator updated game config via WebSocket`, `Lobby creator updated game config via WebSocket`,
{ {
+7 -5
View File
@@ -189,14 +189,16 @@ const MUTUALLY_EXCLUSIVE_MODIFIERS: [ModifierKey, ModifierKey][] = [
["isNukesDisabled", "isWaterNukes"], ["isNukesDisabled", "isWaterNukes"],
]; ];
type ScheduledPublicGameType = Exclude<PublicGameType, "listed-private">;
export class MapPlaylist { export class MapPlaylist {
private playlists: Record<PublicGameType, GameMapType[]> = { private playlists: Record<ScheduledPublicGameType, GameMapType[]> = {
ffa: [], ffa: [],
special: [], special: [],
team: [], team: [],
}; };
public async gameConfig(type: PublicGameType): Promise<GameConfig> { public async gameConfig(type: ScheduledPublicGameType): Promise<GameConfig> {
if (type === "special") { if (type === "special") {
return this.getSpecialConfig(); return this.getSpecialConfig();
} }
@@ -488,7 +490,7 @@ export class MapPlaylist {
} satisfies GameConfig; } satisfies GameConfig;
} }
private getNextMap(type: PublicGameType): GameMapType { private getNextMap(type: ScheduledPublicGameType): GameMapType {
const playlist = this.playlists[type]; const playlist = this.playlists[type];
if (playlist.length === 0) { if (playlist.length === 0) {
playlist.push(...this.generateNewPlaylist(type)); playlist.push(...this.generateNewPlaylist(type));
@@ -496,7 +498,7 @@ export class MapPlaylist {
return playlist.shift()!; return playlist.shift()!;
} }
private generateNewPlaylist(type: PublicGameType): GameMapType[] { private generateNewPlaylist(type: ScheduledPublicGameType): GameMapType[] {
const maps = this.buildMapsList(type); const maps = this.buildMapsList(type);
const rand = new PseudoRandom(Date.now()); const rand = new PseudoRandom(Date.now());
const playlist: GameMapType[] = []; const playlist: GameMapType[] = [];
@@ -545,7 +547,7 @@ export class MapPlaylist {
return false; return false;
} }
private buildMapsList(type: PublicGameType): GameMapType[] { private buildMapsList(type: ScheduledPublicGameType): GameMapType[] {
const maps: GameMapType[] = []; const maps: GameMapType[] = [];
(Object.keys(GameMapType) as GameMapName[]).forEach((key) => { (Object.keys(GameMapType) as GameMapName[]).forEach((key) => {
const map = GameMapType[key]; const map = GameMapType[key];
+6 -1
View File
@@ -1,5 +1,6 @@
import { Worker } from "cluster"; import { Worker } from "cluster";
import winston from "winston"; import winston from "winston";
import { LISTED_PRIVATE_GAME_TYPE } from "../core/ListedPrivateGame";
import { PublicGameInfo, PublicGameType } from "../core/Schemas"; import { PublicGameInfo, PublicGameType } from "../core/Schemas";
import { generateID } from "../core/Util"; import { generateID } from "../core/Util";
import { import {
@@ -85,9 +86,13 @@ export class MasterLobbyService {
ffa: [], ffa: [],
team: [], team: [],
special: [], special: [],
[LISTED_PRIVATE_GAME_TYPE]: [],
}; };
for (const lobby of lobbies) { for (const lobby of lobbies) {
if (!result[lobby.publicGameType]) {
continue;
}
result[lobby.publicGameType].push(lobby); result[lobby.publicGameType].push(lobby);
} }
@@ -131,7 +136,7 @@ export class MasterLobbyService {
private async maybeScheduleLobby() { private async maybeScheduleLobby() {
const lobbiesByType = this.getAllLobbies(); const lobbiesByType = this.getAllLobbies();
for (const type of Object.keys(lobbiesByType) as PublicGameType[]) { for (const type of ["ffa", "team", "special"] as const) {
const lobbies = lobbiesByType[type]; const lobbies = lobbiesByType[type];
// Always ensure the next lobby has a timer, even if we already have 2+ // Always ensure the next lobby has a timer, even if we already have 2+
+22 -2
View File
@@ -9,6 +9,7 @@ import { WebSocket, WebSocketServer } from "ws";
import { z } from "zod"; import { z } from "zod";
import { GameEnv } from "../core/configuration/Config"; import { GameEnv } from "../core/configuration/Config";
import { GameType } from "../core/game/Game"; import { GameType } from "../core/game/Game";
import { hasListedPrivateGameFlare } from "../core/ListedPrivateGame";
import { import {
ClientMessageSchema, ClientMessageSchema,
GameID, GameID,
@@ -145,10 +146,11 @@ export async function startWorker() {
// Extract persistentID from Authorization header token // Extract persistentID from Authorization header token
// Never accept persistentID directly from client // Never accept persistentID directly from client
let creatorPersistentID: string | undefined; let creatorPersistentID: string | undefined;
let creatorToken: string | undefined;
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) { if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.substring("Bearer ".length); creatorToken = authHeader.substring("Bearer ".length);
const result = await verifyClientToken(token); const result = await verifyClientToken(creatorToken);
if (result.type === "success") { if (result.type === "success") {
creatorPersistentID = result.persistentId; creatorPersistentID = result.persistentId;
} else { } else {
@@ -186,6 +188,24 @@ export async function startWorker() {
return res.status(401).send("Unauthorized"); return res.status(401).send("Unauthorized");
} }
if (gc?.listedPrivateGame === true) {
if (creatorToken === undefined) {
return res.status(401).json({ error: "Unauthorized" });
}
const userMe = await getUserMe(creatorToken);
if (userMe.type === "error") {
log.warn(`cannot verify listed private game flare: ${userMe.message}`);
return res.status(401).json({ error: "Unauthorized" });
}
const creatorFlares = userMe.response.player.flares ?? [];
if (!hasListedPrivateGameFlare(creatorFlares)) {
log.warn(
`Forbidden: player without game:* flare attempted to create listed private game`,
);
return res.status(403).json({ error: "Forbidden" });
}
}
// Double-check this worker should host this game // Double-check this worker should host this game
const expectedWorkerId = ServerEnv.workerIndex(id); const expectedWorkerId = ServerEnv.workerIndex(id);
if (expectedWorkerId !== workerId) { if (expectedWorkerId !== workerId) {
+21 -11
View File
@@ -1,6 +1,7 @@
import http from "http"; import http from "http";
import { WebSocket, WebSocketServer } from "ws"; import { WebSocket, WebSocketServer } from "ws";
import { PublicGameInfo, PublicGames } from "../core/Schemas"; import { LISTED_PRIVATE_GAME_TYPE } from "../core/ListedPrivateGame";
import { GameInfo, PublicGameInfo, PublicGames } from "../core/Schemas";
import { GameManager } from "./GameManager"; import { GameManager } from "./GameManager";
import { import {
MasterMessageSchema, MasterMessageSchema,
@@ -83,18 +84,27 @@ export class WorkerLobbyService {
} }
private sendMyLobbiesToMaster() { private sendMyLobbiesToMaster() {
const lobbies = this.gm const toLobbyInfo = (
gi: GameInfo,
publicGameType: PublicGameInfo["publicGameType"],
) => {
return {
gameID: gi.gameID,
numClients: gi.clients?.length ?? 0,
startsAt: gi.startsAt,
gameConfig: gi.gameConfig,
publicGameType,
} satisfies PublicGameInfo;
};
const publicLobbies = this.gm
.publicLobbies() .publicLobbies()
.map((g) => g.gameInfo()) .map((g) => g.gameInfo())
.map((gi) => { .map((gi) => toLobbyInfo(gi, gi.publicGameType!));
return { const listedPrivateLobbies = this.gm
gameID: gi.gameID, .listedPrivateLobbies()
numClients: gi.clients?.length ?? 0, .map((g) => g.gameInfo())
startsAt: gi.startsAt, .map((gi) => toLobbyInfo(gi, LISTED_PRIVATE_GAME_TYPE));
gameConfig: gi.gameConfig, const lobbies = [...publicLobbies, ...listedPrivateLobbies];
publicGameType: gi.publicGameType!,
} satisfies PublicGameInfo;
});
process.send?.({ type: "lobbyList", lobbies } satisfies WorkerLobbyList); process.send?.({ type: "lobbyList", lobbies } satisfies WorkerLobbyList);
} }