mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 11:28:10 +00:00
82d0fb385d
## Description: If the time on the local device differs from the server time, users may see the message “You did not join the lobby on time.” Resolve this by accounting for the time difference, reusing the logic in `JoinLobbyModal` that was previously in `GameModeSelector`, and centralizing it into `ServerTime.ts`. Bug reports: https://github.com/openfrontio/OpenFrontIO/issues/3428 https://discord.com/channels/1284581928254701718/1482511096597315815 https://discord.com/channels/1284581928254701718/1482382264011591781 Resolves #3428 ## 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: tryout33
838 lines
26 KiB
TypeScript
838 lines
26 KiB
TypeScript
import { html, TemplateResult } from "lit";
|
|
import { customElement, property, query, state } from "lit/decorators.js";
|
|
import {
|
|
calculateServerTimeOffset,
|
|
getActiveModifiers,
|
|
getGameModeLabel,
|
|
getMapName,
|
|
getSecondsUntilServerTimestamp,
|
|
getServerNow,
|
|
renderDuration,
|
|
renderNumber,
|
|
translateText,
|
|
} from "../client/Utils";
|
|
import { EventBus } from "../core/EventBus";
|
|
import {
|
|
ClientInfo,
|
|
GAME_ID_REGEX,
|
|
GameConfig,
|
|
GameInfo,
|
|
GameRecordSchema,
|
|
LobbyInfoEvent,
|
|
PublicGameInfo,
|
|
} from "../core/Schemas";
|
|
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
|
import { GameMode, GameType, HumansVsNations } from "../core/game/Game";
|
|
import { getApiBase } from "./Api";
|
|
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
|
import { JoinLobbyEvent } from "./Main";
|
|
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
|
import { BaseModal } from "./components/BaseModal";
|
|
import "./components/CopyButton";
|
|
import "./components/LobbyConfigItem";
|
|
import "./components/LobbyPlayerView";
|
|
import { modalHeader } from "./components/ui/ModalHeader";
|
|
import { nationsConfigToSlider } from "./utilities/GameConfigHelpers";
|
|
|
|
@customElement("join-lobby-modal")
|
|
export class JoinLobbyModal extends BaseModal {
|
|
@query("#lobbyIdInput") private lobbyIdInput!: HTMLInputElement;
|
|
|
|
@property({ attribute: false }) eventBus: EventBus | null = null;
|
|
|
|
@state() private players: ClientInfo[] = [];
|
|
@state() private playerCount: number = 0;
|
|
@state() private gameConfig: GameConfig | null = null;
|
|
@state() private currentLobbyId: string = "";
|
|
@state() private currentClientID: string = "";
|
|
@state() private nationCount: number = 0;
|
|
@state() private lobbyStartAt: number | null = null;
|
|
@state() private serverTimeOffset: number = 0;
|
|
@state() private isConnecting: boolean = true;
|
|
@state() private lobbyCreatorClientID: string | null = null;
|
|
|
|
private leaveLobbyOnClose = true;
|
|
private countdownTimerId: number | null = null;
|
|
private handledJoinTimeout = false;
|
|
|
|
private isPrivateLobby(): boolean {
|
|
return this.gameConfig?.gameType === GameType.Private;
|
|
}
|
|
|
|
private readonly handleLobbyInfo = (event: LobbyInfoEvent) => {
|
|
const lobby = event.lobby;
|
|
this.currentClientID = event.myClientID;
|
|
// Only stop showing spinner when we have player info
|
|
if (this.isConnecting && lobby.clients) {
|
|
this.isConnecting = false;
|
|
}
|
|
this.updateFromLobby({
|
|
...lobby,
|
|
startsAt: lobby.startsAt ?? undefined,
|
|
});
|
|
};
|
|
|
|
render() {
|
|
// Pre-join state: show lobby ID input form
|
|
if (!this.currentLobbyId) {
|
|
return this.renderJoinForm();
|
|
}
|
|
|
|
// Post-join state: show lobby info (identical for public & private)
|
|
const secondsRemaining =
|
|
this.lobbyStartAt !== null
|
|
? getSecondsUntilServerTimestamp(
|
|
this.lobbyStartAt,
|
|
this.serverTimeOffset,
|
|
)
|
|
: null;
|
|
const statusLabel =
|
|
secondsRemaining === null
|
|
? translateText("public_lobby.waiting_for_players")
|
|
: secondsRemaining > 0
|
|
? translateText("public_lobby.starting_in", {
|
|
time: renderDuration(secondsRemaining),
|
|
})
|
|
: translateText("public_lobby.started");
|
|
const maxPlayers = this.gameConfig?.maxPlayers ?? 0;
|
|
const playerCount = this.players?.length ?? 0;
|
|
const hostClientID = this.isPrivateLobby()
|
|
? (this.lobbyCreatorClientID ?? "")
|
|
: "";
|
|
const content = html`
|
|
<div class="${this.modalContainerClass}">
|
|
${modalHeader({
|
|
title: translateText("public_lobby.title"),
|
|
onBack: () => this.closeAndLeave(),
|
|
ariaLabel: translateText("common.close"),
|
|
rightContent:
|
|
this.currentLobbyId && this.isPrivateLobby()
|
|
? html`
|
|
<copy-button .lobbyId=${this.currentLobbyId}></copy-button>
|
|
`
|
|
: undefined,
|
|
})}
|
|
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 mr-1">
|
|
${this.isConnecting
|
|
? html`
|
|
<div
|
|
class="min-h-[240px] flex flex-col items-center justify-center gap-4"
|
|
>
|
|
<div
|
|
class="w-12 h-12 border-4 border-white/20 border-t-white rounded-full animate-spin"
|
|
></div>
|
|
<p class="text-center text-white/80 text-sm">
|
|
${translateText("public_lobby.connecting")}
|
|
</p>
|
|
</div>
|
|
`
|
|
: html`
|
|
${this.gameConfig ? this.renderGameConfig() : html``}
|
|
${this.players.length > 0
|
|
? html`
|
|
<lobby-player-view
|
|
class="mt-6"
|
|
.gameMode=${this.gameConfig?.gameMode ?? GameMode.FFA}
|
|
.clients=${this.players}
|
|
.lobbyCreatorClientID=${hostClientID}
|
|
.currentClientID=${this.currentClientID}
|
|
.teamCount=${this.gameConfig?.playerTeams ?? 2}
|
|
.isPublicGame=${this.gameConfig?.gameType ===
|
|
GameType.Public}
|
|
.nationCount=${nationsConfigToSlider(
|
|
this.gameConfig?.nations ?? "default",
|
|
this.nationCount,
|
|
)}
|
|
></lobby-player-view>
|
|
`
|
|
: ""}
|
|
`}
|
|
</div>
|
|
|
|
${this.isPrivateLobby()
|
|
? html`
|
|
<div
|
|
class="p-6 lg:p-6 border-t border-white/10 bg-black/20 shrink-0"
|
|
>
|
|
<button
|
|
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-sky-600 hover:bg-sky-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
|
|
disabled
|
|
>
|
|
${translateText("private_lobby.joined_waiting")}
|
|
</button>
|
|
</div>
|
|
`
|
|
: html`
|
|
<div
|
|
class="p-6 lg:p-6 border-t border-white/10 bg-black/20 shrink-0"
|
|
>
|
|
<div
|
|
class="w-full px-4 py-3 rounded-xl border border-white/10 bg-white/5 flex items-center justify-between gap-3"
|
|
>
|
|
<div class="flex flex-col">
|
|
<span
|
|
class="text-[10px] font-bold uppercase tracking-widest text-white/40"
|
|
>${translateText("public_lobby.status")}</span
|
|
>
|
|
<span class="text-sm font-bold text-white"
|
|
>${statusLabel}</span
|
|
>
|
|
</div>
|
|
${maxPlayers > 0
|
|
? html`
|
|
<div
|
|
class="flex items-center gap-2 text-white/80 text-xs font-bold uppercase tracking-widest"
|
|
>
|
|
<span>${playerCount}/${maxPlayers}</span>
|
|
<svg
|
|
class="w-4 h-4 text-white"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path
|
|
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.972 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
|
|
></path>
|
|
</svg>
|
|
</div>
|
|
`
|
|
: html``}
|
|
</div>
|
|
</div>
|
|
`}
|
|
</div>
|
|
`;
|
|
|
|
if (this.inline) {
|
|
return content;
|
|
}
|
|
|
|
return html`
|
|
<o-modal
|
|
?hideHeader=${true}
|
|
?hideCloseButton=${true}
|
|
?inline=${this.inline}
|
|
>
|
|
${content}
|
|
</o-modal>
|
|
`;
|
|
}
|
|
|
|
private renderJoinForm() {
|
|
const content = html`
|
|
<div class="${this.modalContainerClass}">
|
|
${modalHeader({
|
|
title: translateText("private_lobby.title"),
|
|
onBack: () => this.closeAndLeave(),
|
|
ariaLabel: translateText("common.close"),
|
|
})}
|
|
<form @submit=${this.joinLobbyFromInput} class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 mr-1">
|
|
<div class="flex flex-col gap-3">
|
|
<div class="flex gap-2">
|
|
<input
|
|
type="text"
|
|
id="lobbyIdInput"
|
|
placeholder=${translateText("private_lobby.enter_id")}
|
|
@keyup=${this.handleChange}
|
|
class="flex-1 px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all font-mono text-sm tracking-wider"
|
|
/>
|
|
<button
|
|
@click=${this.pasteFromClipboard}
|
|
class="px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/20 rounded-xl transition-all group"
|
|
title=${translateText("common.paste")}
|
|
>
|
|
<svg
|
|
class="text-white/60 group-hover:text-white transition-colors"
|
|
stroke="currentColor"
|
|
fill="currentColor"
|
|
stroke-width="0"
|
|
viewBox="0 0 32 32"
|
|
height="18px"
|
|
width="18px"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
d="M 15 3 C 13.742188 3 12.847656 3.890625 12.40625 5 L 5 5 L 5 28 L 13 28 L 13 30 L 27 30 L 27 14 L 25 14 L 25 5 L 17.59375 5 C 17.152344 3.890625 16.257813 3 15 3 Z M 15 5 C 15.554688 5 16 5.445313 16 6 L 16 7 L 19 7 L 19 9 L 11 9 L 11 7 L 14 7 L 14 6 C 14 5.445313 14.445313 5 15 5 Z M 7 7 L 9 7 L 9 11 L 21 11 L 21 7 L 23 7 L 23 14 L 13 14 L 13 26 L 7 26 Z M 15 16 L 25 16 L 25 28 L 15 28 Z"
|
|
></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<o-button
|
|
title=${translateText("private_lobby.join_lobby")}
|
|
block
|
|
submit
|
|
></o-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
if (this.inline) {
|
|
return content;
|
|
}
|
|
|
|
return html`
|
|
<o-modal
|
|
?hideHeader=${true}
|
|
?hideCloseButton=${true}
|
|
?inline=${this.inline}
|
|
>
|
|
${content}
|
|
</o-modal>
|
|
`;
|
|
}
|
|
|
|
public open(lobbyId: string = "", lobbyInfo?: GameInfo | PublicGameInfo) {
|
|
super.open();
|
|
if (lobbyId) {
|
|
this.startTrackingLobby(lobbyId, lobbyInfo);
|
|
// If opened with lobbyId but no lobbyInfo (URL join case), auto-join the lobby
|
|
if (!lobbyInfo) {
|
|
this.handleUrlJoin(lobbyId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async handleUrlJoin(lobbyId: string): Promise<void> {
|
|
try {
|
|
const gameExists = await this.checkActiveLobby(lobbyId);
|
|
if (gameExists) return;
|
|
|
|
// Active lobby not found, check if it's an archived game
|
|
switch (await this.checkArchivedGame(lobbyId)) {
|
|
case "success":
|
|
return;
|
|
case "not_found":
|
|
this.resetTrackingState();
|
|
this.showMessage(translateText("private_lobby.not_found"), "red");
|
|
return;
|
|
case "version_mismatch":
|
|
this.resetTrackingState();
|
|
this.showMessage(
|
|
translateText("private_lobby.version_mismatch"),
|
|
"red",
|
|
);
|
|
return;
|
|
case "error":
|
|
this.resetTrackingState();
|
|
this.showMessage(translateText("private_lobby.error"), "red");
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
console.error("Error checking lobby from URL:", error);
|
|
this.resetTrackingState();
|
|
this.showMessage(translateText("private_lobby.error"), "red");
|
|
}
|
|
}
|
|
|
|
private startTrackingLobby(
|
|
lobbyId: string,
|
|
lobbyInfo?: GameInfo | PublicGameInfo,
|
|
) {
|
|
this.currentLobbyId = lobbyId;
|
|
// clientID will be assigned by server via lobby_info message
|
|
this.currentClientID = "";
|
|
this.gameConfig = null;
|
|
this.players = [];
|
|
this.nationCount = 0;
|
|
this.lobbyStartAt = null;
|
|
this.serverTimeOffset = 0;
|
|
this.lobbyCreatorClientID = null;
|
|
this.isConnecting = true;
|
|
this.handledJoinTimeout = false;
|
|
this.startLobbyUpdates();
|
|
if (lobbyInfo) {
|
|
this.updateFromLobby(lobbyInfo);
|
|
// Only stop showing spinner when we have player info
|
|
if ("clients" in lobbyInfo && lobbyInfo.clients) {
|
|
this.isConnecting = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
private resetTrackingState() {
|
|
this.stopLobbyUpdates();
|
|
this.currentLobbyId = "";
|
|
this.currentClientID = "";
|
|
this.isConnecting = false;
|
|
}
|
|
|
|
private leaveLobby() {
|
|
if (!this.currentLobbyId) {
|
|
return;
|
|
}
|
|
this.dispatchEvent(
|
|
new CustomEvent("leave-lobby", {
|
|
detail: { lobby: this.currentLobbyId },
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
protected onClose(): void {
|
|
this.clearCountdownTimer();
|
|
this.stopLobbyUpdates();
|
|
|
|
if (this.leaveLobbyOnClose) {
|
|
this.leaveLobby();
|
|
this.updateHistory("/");
|
|
}
|
|
|
|
if (this.lobbyIdInput) this.lobbyIdInput.value = "";
|
|
this.gameConfig = null;
|
|
this.players = [];
|
|
this.currentLobbyId = "";
|
|
this.currentClientID = "";
|
|
this.nationCount = 0;
|
|
this.lobbyStartAt = null;
|
|
this.serverTimeOffset = 0;
|
|
this.lobbyCreatorClientID = null;
|
|
this.isConnecting = true;
|
|
this.leaveLobbyOnClose = true;
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this.clearCountdownTimer();
|
|
this.stopLobbyUpdates();
|
|
super.disconnectedCallback();
|
|
}
|
|
|
|
public closeAndLeave() {
|
|
this.leaveLobby();
|
|
try {
|
|
this.updateHistory("/");
|
|
} catch (error) {
|
|
console.warn("Failed to restore URL on leave:", error);
|
|
}
|
|
this.leaveLobbyOnClose = false;
|
|
this.close();
|
|
}
|
|
|
|
public closeWithoutLeaving() {
|
|
this.leaveLobbyOnClose = false;
|
|
this.close();
|
|
}
|
|
|
|
private updateHistory(url: string): void {
|
|
if (!crazyGamesSDK.isOnCrazyGames()) {
|
|
history.replaceState(null, "", url);
|
|
}
|
|
}
|
|
|
|
// --- Game config rendering ---
|
|
|
|
private renderGameConfig(): TemplateResult {
|
|
if (!this.gameConfig) return html``;
|
|
|
|
const c = this.gameConfig;
|
|
const mapName = getMapName(c.gameMap);
|
|
const modeName = getGameModeLabel(c);
|
|
const modifiers = getActiveModifiers(c.publicGameModifiers);
|
|
|
|
return html`
|
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
<lobby-config-item
|
|
.label=${translateText("map.map")}
|
|
.value=${mapName}
|
|
></lobby-config-item>
|
|
<lobby-config-item
|
|
.label=${translateText("host_modal.mode")}
|
|
.value=${modeName}
|
|
></lobby-config-item>
|
|
${modifiers.map(
|
|
(m) => html`
|
|
<lobby-config-item
|
|
.label=${translateText(m.labelKey)}
|
|
.value=${m.formattedValue ??
|
|
(m.value !== undefined
|
|
? renderNumber(m.value)
|
|
: translateText("common.enabled"))}
|
|
></lobby-config-item>
|
|
`,
|
|
)}
|
|
${c.gameMode !== GameMode.FFA &&
|
|
c.playerTeams &&
|
|
c.playerTeams !== HumansVsNations
|
|
? html`
|
|
<lobby-config-item
|
|
.label=${typeof c.playerTeams === "string"
|
|
? translateText("host_modal.team_type")
|
|
: translateText("host_modal.team_count")}
|
|
.value=${typeof c.playerTeams === "string"
|
|
? translateText("host_modal.teams_" + c.playerTeams)
|
|
: c.playerTeams.toString()}
|
|
></lobby-config-item>
|
|
`
|
|
: html``}
|
|
</div>
|
|
${this.renderDisabledUnits()}
|
|
`;
|
|
}
|
|
|
|
private renderDisabledUnits(): TemplateResult {
|
|
if (
|
|
!this.gameConfig ||
|
|
!this.gameConfig.disabledUnits ||
|
|
this.gameConfig.disabledUnits.length === 0
|
|
) {
|
|
return html``;
|
|
}
|
|
|
|
const unitKeys: Record<string, string> = {
|
|
City: "unit_type.city",
|
|
Port: "unit_type.port",
|
|
"Defense Post": "unit_type.defense_post",
|
|
"SAM Launcher": "unit_type.sam_launcher",
|
|
"Missile Silo": "unit_type.missile_silo",
|
|
Warship: "unit_type.warship",
|
|
Factory: "unit_type.factory",
|
|
"Atom Bomb": "unit_type.atom_bomb",
|
|
"Hydrogen Bomb": "unit_type.hydrogen_bomb",
|
|
MIRV: "unit_type.mirv",
|
|
"Trade Ship": "player_stats_table.unit.trade",
|
|
Transport: "player_stats_table.unit.trans",
|
|
"MIRV Warhead": "player_stats_table.unit.mirvw",
|
|
};
|
|
|
|
return html`
|
|
<div class="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
|
<div
|
|
class="text-xs font-bold text-red-400 uppercase tracking-widest mb-2"
|
|
>
|
|
${translateText("private_lobby.disabled_units")}
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
${this.gameConfig.disabledUnits.map((unit) => {
|
|
const key = unitKeys[unit];
|
|
const name = key ? translateText(key) : unit;
|
|
return html`
|
|
<span
|
|
class="px-2 py-1 bg-red-500/20 text-red-200 text-xs rounded font-bold border border-red-500/30"
|
|
>
|
|
${name}
|
|
</span>
|
|
`;
|
|
})}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// --- Lobby event handling ---
|
|
|
|
private updateFromLobby(lobby: GameInfo | PublicGameInfo) {
|
|
this.players = "clients" in lobby ? (lobby.clients ?? []) : [];
|
|
if ("serverTime" in lobby && typeof lobby.serverTime === "number") {
|
|
this.serverTimeOffset = calculateServerTimeOffset(lobby.serverTime);
|
|
}
|
|
this.lobbyStartAt = lobby.startsAt ?? null;
|
|
this.syncCountdownTimer();
|
|
if (lobby.gameConfig) {
|
|
const mapChanged = this.gameConfig?.gameMap !== lobby.gameConfig.gameMap;
|
|
this.gameConfig = lobby.gameConfig;
|
|
if (mapChanged) {
|
|
this.loadNationCount();
|
|
}
|
|
}
|
|
|
|
this.lobbyCreatorClientID =
|
|
"lobbyCreatorClientID" in lobby
|
|
? (lobby.lobbyCreatorClientID ?? null)
|
|
: null;
|
|
}
|
|
|
|
private startLobbyUpdates() {
|
|
this.stopLobbyUpdates();
|
|
if (!this.eventBus) {
|
|
console.warn(
|
|
"JoinLobbyModal: eventBus not set, cannot subscribe to lobby updates",
|
|
);
|
|
return;
|
|
}
|
|
this.eventBus.on(LobbyInfoEvent, this.handleLobbyInfo);
|
|
}
|
|
|
|
private stopLobbyUpdates() {
|
|
this.eventBus?.off(LobbyInfoEvent, this.handleLobbyInfo);
|
|
}
|
|
|
|
// --- Countdown timer ---
|
|
|
|
private syncCountdownTimer() {
|
|
if (this.lobbyStartAt === null) {
|
|
this.clearCountdownTimer();
|
|
return;
|
|
}
|
|
if (this.countdownTimerId !== null) {
|
|
return;
|
|
}
|
|
this.countdownTimerId = window.setInterval(() => {
|
|
this.checkForJoinTimeout();
|
|
this.requestUpdate();
|
|
}, 1000);
|
|
}
|
|
|
|
private clearCountdownTimer() {
|
|
if (this.countdownTimerId === null) {
|
|
return;
|
|
}
|
|
clearInterval(this.countdownTimerId);
|
|
this.countdownTimerId = null;
|
|
}
|
|
|
|
private checkForJoinTimeout() {
|
|
if (
|
|
this.handledJoinTimeout ||
|
|
!this.isConnecting ||
|
|
this.lobbyStartAt === null ||
|
|
!this.isModalOpen
|
|
) {
|
|
return;
|
|
}
|
|
if (getServerNow(this.serverTimeOffset) < this.lobbyStartAt) {
|
|
return;
|
|
}
|
|
this.handledJoinTimeout = true;
|
|
window.dispatchEvent(
|
|
new CustomEvent("show-message", {
|
|
detail: {
|
|
message: translateText("public_lobby.join_timeout"),
|
|
color: "red",
|
|
duration: 3500,
|
|
},
|
|
}),
|
|
);
|
|
this.closeAndLeave();
|
|
}
|
|
|
|
// --- Nation count ---
|
|
|
|
private async loadNationCount() {
|
|
if (!this.gameConfig) {
|
|
this.nationCount = 0;
|
|
return;
|
|
}
|
|
const currentMap = this.gameConfig.gameMap;
|
|
try {
|
|
const mapData = terrainMapFileLoader.getMapData(currentMap);
|
|
const manifest = await mapData.manifest();
|
|
if (this.gameConfig?.gameMap === currentMap) {
|
|
this.nationCount = manifest.nations.length;
|
|
}
|
|
} catch (error) {
|
|
console.warn("Failed to load nation count", error);
|
|
if (this.gameConfig?.gameMap === currentMap) {
|
|
this.nationCount = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Private lobby join flow (lobby ID input) ---
|
|
|
|
private isValidLobbyId(value: string): boolean {
|
|
return GAME_ID_REGEX.test(value);
|
|
}
|
|
|
|
private normalizeLobbyId(input: string): string | null {
|
|
const trimmed = input.trim();
|
|
if (!trimmed) return null;
|
|
const extracted = this.extractLobbyIdFromUrl(trimmed).trim();
|
|
if (!this.isValidLobbyId(extracted)) return null;
|
|
return extracted;
|
|
}
|
|
|
|
private sanitizeForLog(value: string): string {
|
|
return value.replace(/[\r\n]/g, "");
|
|
}
|
|
|
|
private extractLobbyIdFromUrl(input: string): string {
|
|
if (!input.startsWith("http")) {
|
|
return input;
|
|
}
|
|
|
|
try {
|
|
const url = new URL(input);
|
|
const match = url.pathname.match(/game\/([^/]+)/);
|
|
const candidate = match?.[1];
|
|
if (candidate && GAME_ID_REGEX.test(candidate)) return candidate;
|
|
|
|
return input;
|
|
} catch (error) {
|
|
console.warn("Failed to parse lobby URL", error);
|
|
return input;
|
|
}
|
|
}
|
|
|
|
private setLobbyId(id: string) {
|
|
if (this.lobbyIdInput) {
|
|
this.lobbyIdInput.value = this.extractLobbyIdFromUrl(id);
|
|
}
|
|
}
|
|
|
|
private handleChange(e: Event) {
|
|
const value = (e.target as HTMLInputElement).value.trim();
|
|
this.setLobbyId(value);
|
|
}
|
|
|
|
private async pasteFromClipboard() {
|
|
try {
|
|
const clipText = await navigator.clipboard.readText();
|
|
this.setLobbyId(clipText);
|
|
} catch (err) {
|
|
console.error("Failed to read clipboard contents: ", err);
|
|
}
|
|
}
|
|
|
|
private async joinLobbyFromInput(e: SubmitEvent): Promise<void> {
|
|
e.preventDefault();
|
|
const lobbyId = this.normalizeLobbyId(this.lobbyIdInput.value);
|
|
if (!lobbyId) {
|
|
this.showMessage(translateText("private_lobby.not_found"), "red");
|
|
return;
|
|
}
|
|
|
|
this.lobbyIdInput.value = lobbyId;
|
|
console.log(`Joining lobby with ID: ${this.sanitizeForLog(lobbyId)}`);
|
|
|
|
// Initialize tracking state before checking/joining
|
|
this.startTrackingLobby(lobbyId);
|
|
|
|
try {
|
|
const gameExists = await this.checkActiveLobby(lobbyId);
|
|
if (gameExists) return;
|
|
|
|
switch (await this.checkArchivedGame(lobbyId)) {
|
|
case "success":
|
|
return;
|
|
case "not_found":
|
|
this.resetTrackingState();
|
|
this.showMessage(translateText("private_lobby.not_found"), "red");
|
|
return;
|
|
case "version_mismatch":
|
|
this.resetTrackingState();
|
|
this.showMessage(
|
|
translateText("private_lobby.version_mismatch"),
|
|
"red",
|
|
);
|
|
return;
|
|
case "error":
|
|
this.resetTrackingState();
|
|
this.showMessage(translateText("private_lobby.error"), "red");
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
console.error("Error checking lobby existence:", error);
|
|
this.resetTrackingState();
|
|
this.showMessage(translateText("private_lobby.error"), "red");
|
|
}
|
|
}
|
|
|
|
private showMessage(message: string, color: "green" | "red" = "green") {
|
|
window.dispatchEvent(
|
|
new CustomEvent("show-message", {
|
|
detail: { message, duration: 3000, color },
|
|
}),
|
|
);
|
|
}
|
|
|
|
private async checkActiveLobby(lobbyId: string): Promise<boolean> {
|
|
const config = await getServerConfigFromClient();
|
|
const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`;
|
|
|
|
const response = await fetch(url, {
|
|
method: "GET",
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return false;
|
|
}
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
if (!contentType.includes("application/json")) {
|
|
return false;
|
|
}
|
|
|
|
let gameInfo: { exists?: boolean };
|
|
try {
|
|
gameInfo = await response.json();
|
|
} catch (error) {
|
|
console.warn("Failed to parse active lobby response", error);
|
|
return false;
|
|
}
|
|
|
|
if (gameInfo.exists) {
|
|
this.showMessage(translateText("private_lobby.joined_waiting"));
|
|
|
|
// Use the clientID that was already set by startTrackingLobby in open()
|
|
this.dispatchEvent(
|
|
new CustomEvent("join-lobby", {
|
|
detail: {
|
|
gameID: lobbyId,
|
|
source: "private",
|
|
} as JoinLobbyEvent,
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
|
|
// Event tracking is already started by open() -> startTrackingLobby()
|
|
// LobbyInfoEvents will update the UI as they arrive
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private async checkArchivedGame(
|
|
lobbyId: string,
|
|
): Promise<"success" | "not_found" | "version_mismatch" | "error"> {
|
|
const archiveResponse = await fetch(`${getApiBase()}/game/${lobbyId}`, {
|
|
method: "GET",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
if (archiveResponse.status === 404) {
|
|
return "not_found";
|
|
}
|
|
if (archiveResponse.status !== 200) {
|
|
return "error";
|
|
}
|
|
|
|
const archiveData = await archiveResponse.json();
|
|
const parsed = GameRecordSchema.safeParse(archiveData);
|
|
if (!parsed.success) {
|
|
return "version_mismatch";
|
|
}
|
|
|
|
if (
|
|
window.GIT_COMMIT !== "DEV" &&
|
|
parsed.data.gitCommit !== window.GIT_COMMIT
|
|
) {
|
|
const safeLobbyId = this.sanitizeForLog(lobbyId);
|
|
console.warn(
|
|
`Git commit hash mismatch for game ${safeLobbyId}`,
|
|
archiveData.details,
|
|
);
|
|
return "version_mismatch";
|
|
}
|
|
|
|
// If the modal closes as part of joining the replay, do not leave/reset URL
|
|
this.leaveLobbyOnClose = false;
|
|
|
|
this.dispatchEvent(
|
|
new CustomEvent("join-lobby", {
|
|
detail: {
|
|
gameID: lobbyId,
|
|
gameRecord: parsed.data,
|
|
source: "private",
|
|
} as JoinLobbyEvent,
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
return "success";
|
|
}
|
|
}
|