mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:10:42 +00:00
test
This commit is contained in:
+10
-1
@@ -27,7 +27,8 @@
|
||||
"click_to_copy": "Click to copy",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"map_default": "Map default"
|
||||
"map_default": "Map default",
|
||||
"show_less": "Show Less"
|
||||
},
|
||||
"main": {
|
||||
"title": "OpenFront (ALPHA)",
|
||||
@@ -537,6 +538,12 @@
|
||||
"error": "An error occurred. Please try again or contact support.",
|
||||
"joined_waiting": "Lobby joined! Waiting for host to start...",
|
||||
"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",
|
||||
"game_length": "Game length",
|
||||
"pvp_immunity": "PVP immunity duration",
|
||||
@@ -619,6 +626,8 @@
|
||||
"starting_gold": "Starting Gold (Millions)",
|
||||
"starting_gold_placeholder": "5",
|
||||
"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?"
|
||||
},
|
||||
"team_colors": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
GameMode,
|
||||
UnitType,
|
||||
} from "../core/game/Game";
|
||||
import { hasListedPrivateGameFlare } from "../core/ListedPrivateGame";
|
||||
import {
|
||||
ClientInfo,
|
||||
GameConfig,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
isValidGameID,
|
||||
} from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { getUserMe } from "./Api";
|
||||
import { getPlayToken } from "./Auth";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
@@ -85,6 +87,8 @@ export class HostLobbyModal extends BaseModal {
|
||||
@state() private hostCheatGoldMultiplierValue: number | undefined = undefined;
|
||||
@state() private hostCheatStartingGold: boolean = false;
|
||||
@state() private hostCheatStartingGoldValue: number | undefined = undefined;
|
||||
@state() private canListPrivateGame: boolean = false;
|
||||
@state() private listedPrivateGame: boolean = false;
|
||||
@state() private lobbyCreatorClientID: string = "";
|
||||
|
||||
@property({ attribute: false }) eventBus: EventBus | null = null;
|
||||
@@ -352,6 +356,12 @@ export class HostLobbyModal extends BaseModal {
|
||||
labelKey: "host_modal.host_cheats",
|
||||
checked: this.hostCheatsEnabled,
|
||||
},
|
||||
{
|
||||
labelKey: "host_modal.listed_private_game",
|
||||
descriptionKey: "host_modal.listed_private_game_desc",
|
||||
checked: this.listedPrivateGame,
|
||||
hidden: !this.canListPrivateGame,
|
||||
},
|
||||
],
|
||||
inputCards,
|
||||
},
|
||||
@@ -419,6 +429,7 @@ export class HostLobbyModal extends BaseModal {
|
||||
|
||||
protected onOpen(): void {
|
||||
this.startLobbyUpdates();
|
||||
void this.loadListedPrivateGameAccess();
|
||||
this.lobbyId = generateID();
|
||||
// Note: clientID will be assigned by server when we join the lobby
|
||||
// lobbyCreatorClientID stays empty until then
|
||||
@@ -537,6 +548,7 @@ export class HostLobbyModal extends BaseModal {
|
||||
this.hostCheatGoldMultiplierValue = undefined;
|
||||
this.hostCheatStartingGold = false;
|
||||
this.hostCheatStartingGoldValue = undefined;
|
||||
this.listedPrivateGame = false;
|
||||
|
||||
this.leaveLobbyOnClose = true;
|
||||
}
|
||||
@@ -625,6 +637,10 @@ export class HostLobbyModal extends BaseModal {
|
||||
this.hostCheatsEnabled = checked;
|
||||
this.putGameConfig();
|
||||
break;
|
||||
case "host_modal.listed_private_game":
|
||||
this.listedPrivateGame = this.canListPrivateGame && checked;
|
||||
this.putGameConfig();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -944,6 +960,8 @@ export class HostLobbyModal extends BaseModal {
|
||||
instantBuild: this.instantBuild,
|
||||
randomSpawn: this.randomSpawn,
|
||||
gameMode: this.gameMode,
|
||||
listedPrivateGame:
|
||||
this.canListPrivateGame && this.listedPrivateGame,
|
||||
disabledUnits: this.disabledUnits,
|
||||
spawnImmunityDuration: this.spawnImmunity
|
||||
? spawnImmunityTicks
|
||||
@@ -1030,6 +1048,16 @@ export class HostLobbyModal extends BaseModal {
|
||||
// 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> {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../client/Utils";
|
||||
import { assetUrl } from "../core/AssetUrls";
|
||||
import { EventBus } from "../core/EventBus";
|
||||
import { LISTED_PRIVATE_GAME_TYPE } from "../core/ListedPrivateGame";
|
||||
import {
|
||||
ClientInfo,
|
||||
GAME_ID_REGEX,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
GameRecordSchema,
|
||||
LobbyInfoEvent,
|
||||
PublicGameInfo,
|
||||
PublicGames,
|
||||
} from "../core/Schemas";
|
||||
import {
|
||||
Difficulty,
|
||||
@@ -55,6 +57,9 @@ export class JoinLobbyModal extends BaseModal {
|
||||
@state() private serverTimeOffset: number = 0;
|
||||
@state() private isConnecting: boolean = true;
|
||||
@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 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() {
|
||||
if (!this.currentLobbyId) {
|
||||
return modalHeader({
|
||||
@@ -217,8 +227,25 @@ export class JoinLobbyModal extends BaseModal {
|
||||
}
|
||||
|
||||
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`
|
||||
<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 gap-2">
|
||||
<input
|
||||
@@ -256,10 +283,219 @@ export class JoinLobbyModal extends BaseModal {
|
||||
></o-button>
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
|
||||
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 {
|
||||
const lobbyId = typeof args?.lobbyId === "string" ? args.lobbyId : "";
|
||||
const lobbyInfo = args?.lobbyInfo as GameInfo | PublicGameInfo | undefined;
|
||||
@@ -374,14 +610,28 @@ export class JoinLobbyModal extends BaseModal {
|
||||
this.lobbyCreatorClientID = null;
|
||||
this.isConnecting = true;
|
||||
this.leaveLobbyOnClose = true;
|
||||
this.listedPrivateGamesExpanded = false;
|
||||
this.expandedListedPrivateGameSettings = new Set();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.clearCountdownTimer();
|
||||
this.stopLobbyUpdates();
|
||||
document.removeEventListener(
|
||||
"public-lobbies-update",
|
||||
this.handlePublicLobbiesUpdate,
|
||||
);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener(
|
||||
"public-lobbies-update",
|
||||
this.handlePublicLobbiesUpdate,
|
||||
);
|
||||
}
|
||||
|
||||
public closeAndLeave() {
|
||||
this.leaveLobby();
|
||||
try {
|
||||
|
||||
+8
-2
@@ -240,7 +240,13 @@ export interface JoinLobbyEvent {
|
||||
gameStartInfo?: GameStartInfo;
|
||||
// GameRecord exists when replaying an archived game.
|
||||
gameRecord?: GameRecord;
|
||||
source?: "public" | "private" | "host" | "matchmaking" | "singleplayer";
|
||||
source?:
|
||||
| "public"
|
||||
| "listed-private"
|
||||
| "private"
|
||||
| "host"
|
||||
| "matchmaking"
|
||||
| "singleplayer";
|
||||
publicLobbyInfo?: GameInfo | PublicGameInfo;
|
||||
}
|
||||
|
||||
@@ -824,7 +830,7 @@ class Client {
|
||||
this.lobbyHandle.stop(true);
|
||||
document.body.classList.remove("in-game");
|
||||
}
|
||||
if (lobby.source === "public") {
|
||||
if (lobby.source === "public" || lobby.source === "listed-private") {
|
||||
this.joinModal?.open({
|
||||
lobbyId: lobby.gameID,
|
||||
lobbyInfo: lobby.publicLobbyInfo,
|
||||
|
||||
@@ -163,6 +163,7 @@ function renderSectionHeader(
|
||||
|
||||
export interface ToggleOptionConfig {
|
||||
labelKey: string;
|
||||
descriptionKey?: string;
|
||||
checked: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
@@ -284,6 +285,27 @@ export class GameConfigSettings extends LitElement {
|
||||
private renderOptionToggle(toggle: ToggleOptionConfig): TemplateResult {
|
||||
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(
|
||||
translateText(toggle.labelKey),
|
||||
toggle.checked,
|
||||
|
||||
@@ -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
@@ -20,6 +20,7 @@ import {
|
||||
Trios,
|
||||
UnitType,
|
||||
} from "./game/Game";
|
||||
import { LISTED_PRIVATE_GAME_TYPE } from "./ListedPrivateGame";
|
||||
import { PlayerStatsSchema } from "./StatsSchemas";
|
||||
import { flattenedEmojiTable } from "./Util";
|
||||
|
||||
@@ -139,7 +140,12 @@ 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"]);
|
||||
export const PublicGameTypeSchema = z.enum([
|
||||
"ffa",
|
||||
"team",
|
||||
"special",
|
||||
LISTED_PRIVATE_GAME_TYPE,
|
||||
] as const);
|
||||
|
||||
export const UsernameSchema = z
|
||||
.string()
|
||||
@@ -221,6 +227,7 @@ export const GameConfigSchema = z.object({
|
||||
donateTroops: z.boolean(), // Configures donations to humans only
|
||||
gameType: z.enum(GameType),
|
||||
gameMode: z.enum(GameMode),
|
||||
listedPrivateGame: z.boolean().optional(),
|
||||
rankedType: z.enum(RankedType).optional(), // Only set for ranked games.
|
||||
gameMapSize: z.enum(GameMapSize),
|
||||
publicGameModifiers: z
|
||||
|
||||
@@ -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(
|
||||
client: Client,
|
||||
gameID: GameID,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
||||
import { isAdminRole } from "../core/ApiSchemas";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import { hasListedPrivateGameFlare } from "../core/ListedPrivateGame";
|
||||
import {
|
||||
ClientID,
|
||||
ClientMessageSchema,
|
||||
@@ -177,6 +178,9 @@ export class GameServer {
|
||||
if (gameConfig.waterNukes !== undefined) {
|
||||
this.gameConfig.waterNukes = gameConfig.waterNukes ?? undefined;
|
||||
}
|
||||
if (gameConfig.listedPrivateGame !== undefined) {
|
||||
this.gameConfig.listedPrivateGame = gameConfig.listedPrivateGame;
|
||||
}
|
||||
this.gameConfig.hostCheats = gameConfig.hostCheats;
|
||||
}
|
||||
|
||||
@@ -473,6 +477,20 @@ export class GameServer {
|
||||
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(
|
||||
`Lobby creator updated game config via WebSocket`,
|
||||
{
|
||||
|
||||
@@ -189,14 +189,16 @@ const MUTUALLY_EXCLUSIVE_MODIFIERS: [ModifierKey, ModifierKey][] = [
|
||||
["isNukesDisabled", "isWaterNukes"],
|
||||
];
|
||||
|
||||
type ScheduledPublicGameType = Exclude<PublicGameType, "listed-private">;
|
||||
|
||||
export class MapPlaylist {
|
||||
private playlists: Record<PublicGameType, GameMapType[]> = {
|
||||
private playlists: Record<ScheduledPublicGameType, GameMapType[]> = {
|
||||
ffa: [],
|
||||
special: [],
|
||||
team: [],
|
||||
};
|
||||
|
||||
public async gameConfig(type: PublicGameType): Promise<GameConfig> {
|
||||
public async gameConfig(type: ScheduledPublicGameType): Promise<GameConfig> {
|
||||
if (type === "special") {
|
||||
return this.getSpecialConfig();
|
||||
}
|
||||
@@ -488,7 +490,7 @@ export class MapPlaylist {
|
||||
} satisfies GameConfig;
|
||||
}
|
||||
|
||||
private getNextMap(type: PublicGameType): GameMapType {
|
||||
private getNextMap(type: ScheduledPublicGameType): GameMapType {
|
||||
const playlist = this.playlists[type];
|
||||
if (playlist.length === 0) {
|
||||
playlist.push(...this.generateNewPlaylist(type));
|
||||
@@ -496,7 +498,7 @@ export class MapPlaylist {
|
||||
return playlist.shift()!;
|
||||
}
|
||||
|
||||
private generateNewPlaylist(type: PublicGameType): GameMapType[] {
|
||||
private generateNewPlaylist(type: ScheduledPublicGameType): GameMapType[] {
|
||||
const maps = this.buildMapsList(type);
|
||||
const rand = new PseudoRandom(Date.now());
|
||||
const playlist: GameMapType[] = [];
|
||||
@@ -545,7 +547,7 @@ export class MapPlaylist {
|
||||
return false;
|
||||
}
|
||||
|
||||
private buildMapsList(type: PublicGameType): GameMapType[] {
|
||||
private buildMapsList(type: ScheduledPublicGameType): GameMapType[] {
|
||||
const maps: GameMapType[] = [];
|
||||
(Object.keys(GameMapType) as GameMapName[]).forEach((key) => {
|
||||
const map = GameMapType[key];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Worker } from "cluster";
|
||||
import winston from "winston";
|
||||
import { LISTED_PRIVATE_GAME_TYPE } from "../core/ListedPrivateGame";
|
||||
import { PublicGameInfo, PublicGameType } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import {
|
||||
@@ -85,9 +86,13 @@ export class MasterLobbyService {
|
||||
ffa: [],
|
||||
team: [],
|
||||
special: [],
|
||||
[LISTED_PRIVATE_GAME_TYPE]: [],
|
||||
};
|
||||
|
||||
for (const lobby of lobbies) {
|
||||
if (!result[lobby.publicGameType]) {
|
||||
continue;
|
||||
}
|
||||
result[lobby.publicGameType].push(lobby);
|
||||
}
|
||||
|
||||
@@ -131,7 +136,7 @@ export class MasterLobbyService {
|
||||
private async maybeScheduleLobby() {
|
||||
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];
|
||||
|
||||
// Always ensure the next lobby has a timer, even if we already have 2+
|
||||
|
||||
+22
-2
@@ -9,6 +9,7 @@ import { WebSocket, WebSocketServer } from "ws";
|
||||
import { z } from "zod";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import { hasListedPrivateGameFlare } from "../core/ListedPrivateGame";
|
||||
import {
|
||||
ClientMessageSchema,
|
||||
GameID,
|
||||
@@ -145,10 +146,11 @@ export async function startWorker() {
|
||||
// Extract persistentID from Authorization header token
|
||||
// Never accept persistentID directly from client
|
||||
let creatorPersistentID: string | undefined;
|
||||
let creatorToken: string | undefined;
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const token = authHeader.substring("Bearer ".length);
|
||||
const result = await verifyClientToken(token);
|
||||
creatorToken = authHeader.substring("Bearer ".length);
|
||||
const result = await verifyClientToken(creatorToken);
|
||||
if (result.type === "success") {
|
||||
creatorPersistentID = result.persistentId;
|
||||
} else {
|
||||
@@ -186,6 +188,24 @@ export async function startWorker() {
|
||||
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
|
||||
const expectedWorkerId = ServerEnv.workerIndex(id);
|
||||
if (expectedWorkerId !== workerId) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import http from "http";
|
||||
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 {
|
||||
MasterMessageSchema,
|
||||
@@ -83,18 +84,27 @@ export class WorkerLobbyService {
|
||||
}
|
||||
|
||||
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()
|
||||
.map((g) => g.gameInfo())
|
||||
.map((gi) => {
|
||||
return {
|
||||
gameID: gi.gameID,
|
||||
numClients: gi.clients?.length ?? 0,
|
||||
startsAt: gi.startsAt,
|
||||
gameConfig: gi.gameConfig,
|
||||
publicGameType: gi.publicGameType!,
|
||||
} satisfies PublicGameInfo;
|
||||
});
|
||||
.map((gi) => toLobbyInfo(gi, gi.publicGameType!));
|
||||
const listedPrivateLobbies = this.gm
|
||||
.listedPrivateLobbies()
|
||||
.map((g) => g.gameInfo())
|
||||
.map((gi) => toLobbyInfo(gi, LISTED_PRIVATE_GAME_TYPE));
|
||||
const lobbies = [...publicLobbies, ...listedPrivateLobbies];
|
||||
process.send?.({ type: "lobbyList", lobbies } satisfies WorkerLobbyList);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user