mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-12 07:43:55 +00:00
d76691372c
**Add approved & assigned issue number here:** Resolves #4476 ## Description: Lets a private-lobby host reuse the same group for **back-to-back games without re-sharing the invite link**. **Flow:** in a private game, the host clicks a **"New lobby"** button in the top-right bar (next to pause). The game's **server** creates a fresh private lobby (same creator, default settings) and broadcasts its id to everyone still connected. Non-hosts get a one-click **"Join"** banner at the top of the screen; the host is taken straight back to the host view for the new lobby. The chain can repeat indefinitely. ### Key design decision: the server creates and broadcasts the successor lobby The successor lobby is minted by the **finished game's server**, not the host's browser. The old game's server is the only thing still connected to every player, so it has to be what announces the new lobby; because it also *creates* that lobby, the id everyone is redirected to is **authoritative**. A real lobby the server just made for the authenticated creator, not an id a client handed it to trust and fan out. The request is **creator-only** and **idempotent** per game, and every successor is wired the same way, so the group can keep playing game after game. ### How it works 1. Host clicks "New lobby" (host + private only) → confirm dialog → the client sends a `create_next_lobby` message. 2. The game server verifies the sender is the lobby creator, mints a successor private lobby on the same worker, stores it (idempotent), and broadcasts a `new_lobby` message with the new id to all connected clients. 3. Each client reacts: the host is navigated to the new lobby's host view (`/…/game/<id>?host`); everyone else sees a dismissible "Host started a new lobby — Join" banner that navigates to the join URL in one click. Two new Zod wire messages in `src/core/Schemas.ts` (`create_next_lobby`, `new_lobby`) carry the request and the broadcast. ### Screenshots <table> <tr> <td width="50%" align="center" valign="top"> <img width="220" alt="In-game New lobby button" src="https://github.com/user-attachments/assets/9a4d4425-a7f6-4b3a-9c4f-9205300b1e5b" /><br /> <sub><b>1.</b> In-game <b>New lobby</b> button (top-right, next to pause) — shown only to the host of a private lobby</sub> </td> <td width="50%" align="center" valign="top"> <img width="320" alt="Confirmation dialog" src="https://github.com/user-attachments/assets/fa023f5a-58e8-439b-8899-5150235a1e8c" /><br /> <sub><b>2.</b> Confirmation so a stray click doesn't pull everyone into a new lobby</sub> </td> </tr> <tr> <td colspan="2" align="center"> <img width="100%" alt="Join banner for non-hosts" src="https://github.com/user-attachments/assets/7e5ef490-aa7e-4449-add9-e857fe273bde" /><br /> <sub><b>3.</b> Everyone else gets a one-click <b>Join</b> banner at the top of the screen</sub> </td> </tr> <tr> <td colspan="2" align="center"> <img width="330" alt="Host view for the new lobby" src="https://github.com/user-attachments/assets/9920b070-4ed3-41f8-9345-78778b4648a7" /><br /> <sub><b>4.</b> The host lands back in the host view for the brand-new lobby</sub> </td> </tr> </table> ### Design Decisions **A. The server creates & broadcasts the successor, not the client.** The finished game's server mints the successor and broadcasts its id. Why this and not "host's browser calls `POST /api/create_game`, then asks the server to relay the id"? - The broadcast id is **authoritative/verified**: it's a real lobby the server just created for the **authenticated** lobby creator (creator identity comes from the JWT the game already holds), not an arbitrary id a client hands the server to fan out to everyone. - The **old game server is the only thing still connected to all the players**, so it must be the one to broadcast. Having it also create the lobby keeps it to one authoritative round-trip instead of "client creates, then client asks server to trust an id it didn't make." - The server can **authorise** (only the creator) and stay **idempotent**. **B. The successor starts with default settings (not a copy of the old game).** A deliberate scope choice. The host lands in the normal host view and reconfigures. Copying the exact config would mean reverse-mapping every `GameConfig` field back into the host-modal controls, which I thought would be out of scope for this PR. Same **creator** is preserved; same **settings** intentionally is not. **C. It's a brand-new lobby, not the same game resurrected.** This directly follows @evanpelle's guidance on the issue: *"A 'lobby' is really just a game that hasn't started yet. So making a persistent lobby isn't really possible. I think instead having a simple way to transfer players to a new lobby is probably the way to go."* A `GameServer` runs exactly one game (start → end → archive), so rather than reworking that lifecycle to resurrect the old game, the server spins up a fresh successor lobby and transfers the group into it, which is also why the feature is framed as "reuse the group," not "reuse the game object." **D. The host returns via a `?host` URL flag + full reload ("attach mode").** Navigating to a normal join URL (`/game/<id>`) always lands you in the **join** view, which has no Start button. So the host can't just use the join URL. The `?host` flag routes the creator to the **host view** instead (`Main.handleUrl` → `HostLobbyModal` in "attach" mode, which binds to the existing lobby id and skips creating a new one). A full reload is used because it cleanly tears down the finished game and mirrors the existing win-screen "Requeue" button's `window.location.href` pattern. **E. Each successor can spawn its own successor (recursive factory).** The first version only chained **one** generation. A spawned lobby had no factory of its own, so the *second* "New lobby" click did nothing (button just greyed out). Fixed by `wireSuccessorLobby` (a small dependency-injected helper) that wires every successor the same way. This is the whole point of the issue ("back-to-back games"), so it has its own regression test. **F. Private-only.** The successor factory is installed **only** on the private `POST /api/create_game` path in `Worker.ts`. Public games (scheduled by the master) and singleplayer never get a factory, so `handleCreateNextLobby` is a no-op for them. The client button is also gated on `isLobbyCreator && isPrivateLobby`. **G. In-game button + confirm; the win-modal button was removed.** An earlier version put the "New lobby" button on the win screen. I moved it to the in-game bar so the host can reuse the lobby **at any time** (without dying or waiting for the game to end), and added a **confirm** (matching the adjacent Exit button) so a stray click next to pause/exit doesn't yank everyone into a new lobby. The win-screen button became redundant and was removed. ### Testing - **Unit tests:** wire-message schema round-trips (`tests/NewLobbyMessages.test.ts`); the server handler — authorisation, broadcast, idempotency — against a real `GameServer` (`tests/server/CreateNextLobby.test.ts`); and successor **chaining** across multiple generations (`tests/server/SuccessorLobby.test.ts`). - **Full suite:** `npm test` passes — **1782 tests across 154 files**. - **Manual:** created a private lobby with multiple clients and played consecutive games via the button; verified non-hosts get the Join banner, the host lands back in the host view, and the chain works for 3+ games in a row. ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: MushroomLamp --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1208 lines
38 KiB
TypeScript
1208 lines
38 KiB
TypeScript
import version from "resources/version.txt?raw";
|
|
import { ClientEnv } from "src/client/ClientEnv";
|
|
import { UserMeResponse } from "../core/ApiSchemas";
|
|
import { assetUrl } from "../core/AssetUrls";
|
|
import { EventBus } from "../core/EventBus";
|
|
import {
|
|
GAME_ID_REGEX,
|
|
GameInfo,
|
|
GameRecord,
|
|
GameStartInfo,
|
|
PublicGameInfo,
|
|
} from "../core/Schemas";
|
|
import { GameEnv } from "../core/configuration/Config";
|
|
import { GameType } from "../core/game/Game";
|
|
import { UserSettings } from "../core/game/UserSettings";
|
|
import "./AccountModal";
|
|
import { getUserMe, invalidateUserMe } from "./Api";
|
|
import { reauthAfterCrazyGamesChange, userAuth } from "./Auth";
|
|
import "./ClanModal";
|
|
import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner";
|
|
import { getPlayerCosmeticsRefs } from "./Cosmetics";
|
|
import { updateCrazyGamesNavButton } from "./CrazyGamesAccountButton";
|
|
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
|
import "./EffectsInput";
|
|
import "./EffectsModal";
|
|
import { EffectsModal } from "./EffectsModal";
|
|
import "./FlagInput";
|
|
import { FlagInput } from "./FlagInput";
|
|
import "./FlagInputModal";
|
|
import { FlagInputModal } from "./FlagInputModal";
|
|
import { GameInfoModal } from "./GameInfoModal";
|
|
import "./GameModeSelector";
|
|
import { GameModeSelector } from "./GameModeSelector";
|
|
import { GameStartingModal } from "./GameStartingModal";
|
|
import "./GoogleAdElement";
|
|
import { HelpModal } from "./HelpModal";
|
|
import "./HomepagePromos";
|
|
import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal";
|
|
import { showInGameConfirm } from "./InGameModal";
|
|
import { JoinLobbyModal } from "./JoinLobbyModal";
|
|
import "./LangSelector";
|
|
import { LangSelector } from "./LangSelector";
|
|
import { initLayout } from "./Layout";
|
|
import "./LeaderboardModal";
|
|
import "./Matchmaking";
|
|
import { MatchmakingModal } from "./Matchmaking";
|
|
import { modalRouter } from "./ModalRouter";
|
|
import { initNavigation } from "./Navigation";
|
|
import "./NewsModal";
|
|
import "./PatternInput";
|
|
import { RewardsModal } from "./RewardsModal";
|
|
import "./SinglePlayerModal";
|
|
import { StoreModal } from "./Store";
|
|
import "./TerritoryPatternsModal";
|
|
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
|
|
import { TokenLoginModal } from "./TokenLoginModal";
|
|
import {
|
|
SendKickPlayerIntentEvent,
|
|
SendToggleGameStartTimer,
|
|
SendUpdateGameConfigIntentEvent,
|
|
} from "./Transport";
|
|
import { UserSettingModal } from "./UserSettingModal";
|
|
import "./UsernameInput";
|
|
import { genAnonUsername, UsernameInput } from "./UsernameInput";
|
|
import {
|
|
getDiscordAvatarUrl,
|
|
incrementGamesPlayed,
|
|
isInIframe,
|
|
translateText,
|
|
} from "./Utils";
|
|
import "./components/MarketingConsentToast";
|
|
import { installSafariPinchZoomBlocker } from "./utilities/DisableSafariPinchZoom";
|
|
|
|
import "./components/DesktopNavBar";
|
|
import "./components/Footer";
|
|
import "./components/MainLayout";
|
|
import "./components/MobileNavBar";
|
|
import "./components/PlayPage";
|
|
import "./components/RankedModal";
|
|
import "./components/baseComponents/Button";
|
|
import "./components/baseComponents/Modal";
|
|
import "./styles.css";
|
|
import "./styles/core/typography.css";
|
|
import "./styles/core/variables.css";
|
|
import "./styles/layout/container.css";
|
|
import "./styles/layout/header.css";
|
|
import "./styles/modal/chat.css";
|
|
|
|
function updateAccountNavButton(userMeResponse: UserMeResponse | false) {
|
|
const button = document.getElementById("nav-account-button");
|
|
if (!button) return;
|
|
|
|
const avatarEl = document.getElementById("nav-account-avatar") as
|
|
| (HTMLImageElement & { _navToken?: symbol })
|
|
| null;
|
|
const personIconEl = document.getElementById(
|
|
"nav-account-person-icon",
|
|
) as SVGElement | null;
|
|
const emailBadgeEl = document.getElementById(
|
|
"nav-account-email-badge",
|
|
) as HTMLElement | null;
|
|
const signInTextEl = document.getElementById(
|
|
"nav-account-signin-text",
|
|
) as HTMLSpanElement | null;
|
|
|
|
// Unique token for this update call
|
|
const navToken = Symbol();
|
|
if (avatarEl) avatarEl._navToken = navToken;
|
|
|
|
const showAvatar = (src: string, alt?: string) => {
|
|
if (avatarEl) {
|
|
avatarEl.alt = alt ?? translateText("main.discord_avatar_alt");
|
|
// If the avatar fails to load (bad URL / CDN issue / offline), fall back
|
|
// to the default sign-in UI instead of leaving a broken image.
|
|
avatarEl.onerror = () => {
|
|
if (avatarEl._navToken !== navToken) return;
|
|
avatarEl.onerror = null;
|
|
avatarEl.src = "https://cdn.discordapp.com/embed/avatars/0.png";
|
|
};
|
|
avatarEl.onload = () => {
|
|
// Only handle if this is the latest update
|
|
if (avatarEl._navToken !== navToken) return;
|
|
// Clear error handler after a successful load.
|
|
avatarEl.onerror = null;
|
|
};
|
|
avatarEl.src = src;
|
|
avatarEl.classList.remove("hidden");
|
|
}
|
|
personIconEl?.classList.add("hidden");
|
|
emailBadgeEl?.classList.add("hidden");
|
|
signInTextEl?.classList.add("hidden");
|
|
button?.classList.remove("border", "border-white/20");
|
|
};
|
|
|
|
const showSignIn = () => {
|
|
avatarEl?.classList.add("hidden");
|
|
personIconEl?.classList.remove("hidden");
|
|
emailBadgeEl?.classList.add("hidden");
|
|
signInTextEl?.classList.remove("hidden");
|
|
// Restore border when showing signin state
|
|
button?.classList.add("border", "border-white/20");
|
|
};
|
|
|
|
const showEmailLoggedIn = () => {
|
|
avatarEl?.classList.add("hidden");
|
|
personIconEl?.classList.remove("hidden");
|
|
emailBadgeEl?.classList.remove("hidden");
|
|
signInTextEl?.classList.add("hidden");
|
|
button?.classList.add("border", "border-white/20");
|
|
};
|
|
|
|
const discord =
|
|
userMeResponse !== false ? userMeResponse.user.discord : undefined;
|
|
if (discord && avatarEl) {
|
|
const avatarAlt = translateText("main.user_avatar_alt", {
|
|
username: discord.username,
|
|
});
|
|
const url = getDiscordAvatarUrl(discord);
|
|
if (url) {
|
|
showAvatar(url, avatarAlt);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const email =
|
|
userMeResponse !== false ? userMeResponse.user.email : undefined;
|
|
if (email) {
|
|
showEmailLoggedIn();
|
|
return;
|
|
}
|
|
|
|
// Google logins have no avatar; show the same person/email badge as magic-link.
|
|
const google =
|
|
userMeResponse !== false ? userMeResponse.user.google : undefined;
|
|
if (google) {
|
|
showEmailLoggedIn();
|
|
return;
|
|
}
|
|
|
|
showSignIn();
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
turnstile: any;
|
|
adsEnabled: boolean;
|
|
gtag?: (...args: any[]) => void;
|
|
PageOS: {
|
|
session: {
|
|
newPageView: () => void;
|
|
};
|
|
};
|
|
ramp: {
|
|
que: Array<() => void>;
|
|
passiveMode: boolean;
|
|
spaAddAds: (ads: Array<{ type: string; selectorId?: string }>) => void;
|
|
destroyUnits: (adType: string | string[]) => Promise<void>;
|
|
settings?: {
|
|
slots?: any;
|
|
};
|
|
spaNewPage: (url?: string) => void;
|
|
spaAds: (config?: {
|
|
ads?: Array<{ type: string; selectorId?: string }>;
|
|
countPageview?: boolean;
|
|
path?: string;
|
|
}) => void;
|
|
// Video ad methods
|
|
onPlayerReady: (() => void) | null;
|
|
addUnits: (units: Array<{ type: string }>) => Promise<void>;
|
|
displayUnits: () => void;
|
|
};
|
|
Bolt: {
|
|
on: (unitType: string, event: string, callback: () => void) => void;
|
|
BOLT_AD_REQUEST_START: string;
|
|
BOLT_AD_IMPRESSION: string;
|
|
BOLT_AD_STARTED: string;
|
|
BOLT_FIRST_QUARTILE: string;
|
|
BOLT_MIDPOINT: string;
|
|
BOLT_THIRD_QUARTILE: string;
|
|
BOLT_AD_COMPLETE: string;
|
|
BOLT_AD_ERROR: string;
|
|
BOLT_AD_PAUSED: string;
|
|
BOLT_AD_CLICKED: string;
|
|
SHOW_HIDDEN_CONTAINER: string;
|
|
};
|
|
currentPageId?: string;
|
|
showPage?: (pageId: string) => void;
|
|
}
|
|
|
|
// Extend the global interfaces to include your custom events
|
|
interface DocumentEventMap {
|
|
"join-lobby": CustomEvent<JoinLobbyEvent>;
|
|
"kick-player": CustomEvent;
|
|
toggle_game_start_timer: CustomEvent;
|
|
"join-changed": CustomEvent;
|
|
"open-matchmaking": CustomEvent<undefined>;
|
|
userMeResponse: CustomEvent<UserMeResponse | false>;
|
|
"leave-lobby": CustomEvent;
|
|
"update-game-config": CustomEvent;
|
|
}
|
|
}
|
|
|
|
export interface JoinLobbyEvent {
|
|
// Multiplayer games only have gameID, gameConfig is not known until game starts.
|
|
gameID: string;
|
|
// GameConfig only exists when playing a singleplayer game.
|
|
gameStartInfo?: GameStartInfo;
|
|
// GameRecord exists when replaying an archived game.
|
|
gameRecord?: GameRecord;
|
|
source?: "public" | "private" | "host" | "matchmaking" | "singleplayer";
|
|
publicLobbyInfo?: GameInfo | PublicGameInfo;
|
|
}
|
|
|
|
class Client {
|
|
private lobbyHandle: JoinLobbyResult | null = null;
|
|
private eventBus: EventBus = new EventBus();
|
|
|
|
private currentUrl: string | null = null;
|
|
|
|
private usernameInput: UsernameInput | null = null;
|
|
private flagInput: FlagInput | null = null;
|
|
|
|
private hostModal: HostPrivateLobbyModal;
|
|
private joinModal: JoinLobbyModal;
|
|
private gameModeSelector: GameModeSelector;
|
|
private userSettings: UserSettings = new UserSettings();
|
|
private storeModal: StoreModal;
|
|
private tokenLoginModal: TokenLoginModal;
|
|
private matchmakingModal: MatchmakingModal;
|
|
private rewardsModal: RewardsModal;
|
|
private mostRecentJoinEvent: number;
|
|
|
|
private turnstileTokenPromise: Promise<{
|
|
token: string;
|
|
createdAt: number;
|
|
}> | null = null;
|
|
|
|
async initialize(): Promise<void> {
|
|
crazyGamesSDK.maybeInit();
|
|
|
|
// Register modals with the URL router. Lobby modals (join/host) and
|
|
// matchmaking are intentionally omitted — they own their own URL state
|
|
// (path-based) or none at all.
|
|
modalRouter.register("store", {
|
|
tag: "store-modal",
|
|
pageId: "page-item-store",
|
|
});
|
|
modalRouter.register("settings", {
|
|
tag: "user-setting",
|
|
pageId: "page-settings",
|
|
});
|
|
modalRouter.register("leaderboard", {
|
|
tag: "leaderboard-modal",
|
|
pageId: "page-leaderboard",
|
|
});
|
|
modalRouter.register("clan", { tag: "clan-modal", pageId: "page-clan" });
|
|
modalRouter.register("account", {
|
|
tag: "account-modal",
|
|
pageId: "page-account",
|
|
});
|
|
modalRouter.register("help", { tag: "help-modal", pageId: "page-help" });
|
|
modalRouter.register("news", { tag: "news-modal", pageId: "page-news" });
|
|
modalRouter.register("language", {
|
|
tag: "language-modal",
|
|
pageId: "page-language",
|
|
});
|
|
modalRouter.register("single-player", {
|
|
tag: "single-player-modal",
|
|
pageId: "page-single-player",
|
|
});
|
|
modalRouter.register("ranked", {
|
|
tag: "ranked-modal",
|
|
pageId: "page-ranked",
|
|
});
|
|
modalRouter.register("troubleshooting", {
|
|
tag: "troubleshooting-modal",
|
|
pageId: "page-troubleshooting",
|
|
});
|
|
modalRouter.register("territory-patterns", {
|
|
tag: "territory-patterns-modal",
|
|
});
|
|
modalRouter.register("flag-input", { tag: "flag-input-modal" });
|
|
modalRouter.register("effects", { tag: "effects-modal" });
|
|
|
|
// Prefetch turnstile token so it is available when
|
|
// the user joins a lobby.
|
|
this.turnstileTokenPromise = getTurnstileToken();
|
|
|
|
// Wait for components to render before setting version
|
|
await customElements.whenDefined("mobile-nav-bar");
|
|
await customElements.whenDefined("desktop-nav-bar");
|
|
|
|
const openFrontFont = new FontFace(
|
|
"OpenFront",
|
|
`url(${assetUrl("fonts/OpenFront.ttf")})`,
|
|
);
|
|
document.fonts.add(openFrontFont);
|
|
openFrontFont.load().catch(() => {});
|
|
|
|
const versionElements = document.querySelectorAll(
|
|
"#game-version, .game-version-display",
|
|
);
|
|
if (versionElements.length === 0) {
|
|
console.warn("Game version element not found");
|
|
} else {
|
|
const trimmed = version.trim();
|
|
const displayVersion = trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
|
|
versionElements.forEach((el) => {
|
|
(el as HTMLElement).style.fontFamily = '"OpenFront", Inter, sans-serif';
|
|
el.textContent = displayVersion;
|
|
});
|
|
}
|
|
|
|
const langSelector = document.querySelector(
|
|
"lang-selector",
|
|
) as LangSelector;
|
|
if (!langSelector) {
|
|
console.warn("Lang selector element not found");
|
|
}
|
|
|
|
this.flagInput = document.querySelector("flag-input") as FlagInput;
|
|
if (!this.flagInput) {
|
|
console.warn("Flag input element not found");
|
|
}
|
|
|
|
this.usernameInput = document.querySelector(
|
|
"username-input",
|
|
) as UsernameInput;
|
|
if (!this.usernameInput) {
|
|
console.warn("Username input element not found");
|
|
}
|
|
|
|
this.gameModeSelector = document.querySelector(
|
|
"game-mode-selector",
|
|
) as GameModeSelector;
|
|
|
|
window.addEventListener("beforeunload", async () => {
|
|
console.log("Browser is closing");
|
|
if (this.lobbyHandle !== null) {
|
|
this.lobbyHandle.stop(true);
|
|
await crazyGamesSDK.gameplayStop();
|
|
}
|
|
});
|
|
|
|
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
|
|
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
|
|
document.addEventListener("kick-player", this.handleKickPlayer.bind(this));
|
|
document.addEventListener(
|
|
"toggle_game_start_timer",
|
|
this.handleToggleGameStartTimer.bind(this),
|
|
);
|
|
document.addEventListener(
|
|
"update-game-config",
|
|
this.handleUpdateGameConfig.bind(this),
|
|
);
|
|
document.addEventListener(
|
|
"open-matchmaking",
|
|
this.handleOpenMatchmaking.bind(this),
|
|
);
|
|
|
|
const hlpModal = document.querySelector("help-modal") as HelpModal;
|
|
if (!hlpModal || !(hlpModal instanceof HelpModal)) {
|
|
console.warn("Help modal element not found");
|
|
}
|
|
const giModal = document.querySelector("game-info-modal") as GameInfoModal;
|
|
if (!giModal || !(giModal instanceof GameInfoModal)) {
|
|
console.warn("Game info modal element not found");
|
|
}
|
|
const helpButton = document.getElementById("help-button");
|
|
if (helpButton) {
|
|
helpButton.addEventListener("click", () => {
|
|
if (hlpModal && hlpModal instanceof HelpModal) {
|
|
hlpModal.open();
|
|
}
|
|
});
|
|
}
|
|
|
|
const flagInputModal = document.querySelector(
|
|
"flag-input-modal",
|
|
) as FlagInputModal;
|
|
if (!flagInputModal || !(flagInputModal instanceof FlagInputModal)) {
|
|
console.warn("Flag input modal element not found");
|
|
}
|
|
|
|
// Attach listener to any flag-input component (desktop or potentially others)
|
|
document.querySelectorAll("flag-input").forEach((flagInput) => {
|
|
flagInput.addEventListener("flag-input-click", () => {
|
|
if (flagInputModal && flagInputModal instanceof FlagInputModal) {
|
|
flagInputModal.open();
|
|
}
|
|
});
|
|
});
|
|
|
|
const effectsModal = document.querySelector(
|
|
"effects-modal",
|
|
) as EffectsModal;
|
|
if (!effectsModal || !(effectsModal instanceof EffectsModal)) {
|
|
console.warn("Effects modal element not found");
|
|
}
|
|
document.querySelectorAll("effects-input").forEach((effectsInput) => {
|
|
effectsInput.addEventListener("effects-input-click", () => {
|
|
if (effectsModal && effectsModal instanceof EffectsModal) {
|
|
effectsModal.open();
|
|
}
|
|
});
|
|
});
|
|
|
|
this.storeModal = document.getElementById("page-item-store") as StoreModal;
|
|
if (!this.storeModal || !(this.storeModal instanceof StoreModal)) {
|
|
console.warn("Store modal element not found");
|
|
}
|
|
|
|
const patternsModal = document.getElementById(
|
|
"territory-patterns-modal",
|
|
) as TerritoryPatternsModal;
|
|
if (!patternsModal || !(patternsModal instanceof TerritoryPatternsModal)) {
|
|
console.warn("Patterns modal element not found");
|
|
}
|
|
|
|
// Attach listener to any pattern-input component
|
|
document.querySelectorAll("pattern-input").forEach((patternInput) => {
|
|
patternInput.addEventListener("pattern-input-click", () => {
|
|
patternsModal.open();
|
|
});
|
|
});
|
|
|
|
if (isInIframe()) {
|
|
const mobilePat = document.getElementById("pattern-input-mobile");
|
|
if (mobilePat) mobilePat.style.display = "none";
|
|
}
|
|
|
|
if (!this.storeModal || !(this.storeModal instanceof StoreModal)) {
|
|
console.warn("Store modal element not found");
|
|
}
|
|
|
|
// We no longer need to manually manage the preview button as PatternInput handles it component-side.
|
|
// However, we still want to ensure the modal can be opened.
|
|
// The setupPatternInput above handles the click event for the new buttons.
|
|
|
|
this.storeModal.refresh();
|
|
|
|
window.addEventListener("showPage", (e: any) => {
|
|
if (typeof e?.detail === "string" && e.detail === "page-play") {
|
|
setTimeout(() => {
|
|
this.storeModal.refresh();
|
|
}, 50);
|
|
}
|
|
});
|
|
|
|
this.tokenLoginModal = document.querySelector(
|
|
"token-login",
|
|
) as TokenLoginModal;
|
|
if (
|
|
!this.tokenLoginModal ||
|
|
!(this.tokenLoginModal instanceof TokenLoginModal)
|
|
) {
|
|
console.warn("Token login modal element not found");
|
|
}
|
|
|
|
this.matchmakingModal = document.querySelector(
|
|
"matchmaking-modal",
|
|
) as MatchmakingModal;
|
|
if (
|
|
!this.matchmakingModal ||
|
|
!(this.matchmakingModal instanceof MatchmakingModal)
|
|
) {
|
|
console.warn("Matchmaking modal element not found");
|
|
}
|
|
|
|
this.rewardsModal = document.querySelector("rewards-modal") as RewardsModal;
|
|
if (!this.rewardsModal || !(this.rewardsModal instanceof RewardsModal)) {
|
|
console.warn("Rewards modal element not found");
|
|
}
|
|
|
|
const onUserMe = async (userMeResponse: UserMeResponse | false) => {
|
|
if (crazyGamesSDK.isOnCrazyGames()) {
|
|
void updateCrazyGamesNavButton();
|
|
} else {
|
|
updateAccountNavButton(userMeResponse);
|
|
}
|
|
const isAdFree =
|
|
userMeResponse !== false && userMeResponse.player?.adfree === true;
|
|
window.adsEnabled = !isAdFree && !crazyGamesSDK.isOnCrazyGames();
|
|
document.dispatchEvent(
|
|
new CustomEvent("userMeResponse", {
|
|
detail: userMeResponse,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}),
|
|
);
|
|
|
|
if (userMeResponse !== false) {
|
|
// Authorized
|
|
console.log(
|
|
`Your player ID is ${userMeResponse.player.publicId}\n` +
|
|
"Sharing this ID will allow others to view your game history and stats.",
|
|
);
|
|
|
|
// Unclaimed-rewards popup — only on a clean homepage load, never over
|
|
// a deep link (join URL, #modal=..., #purchase-completed, ...).
|
|
const rewards = userMeResponse.player.rewards ?? [];
|
|
if (
|
|
rewards.length > 0 &&
|
|
window.location.pathname === "/" &&
|
|
window.location.hash === ""
|
|
) {
|
|
this.rewardsModal?.openWithRewards(rewards);
|
|
}
|
|
}
|
|
};
|
|
|
|
if ((await userAuth()) === false) {
|
|
// Not logged in
|
|
onUserMe(false);
|
|
} else {
|
|
// JWT appears to be valid
|
|
// TODO: Add caching
|
|
getUserMe().then(onUserMe);
|
|
}
|
|
|
|
// Re-run auth when the player signs into CrazyGames mid-session. Logout
|
|
// reloads the page, so only login needs handling here.
|
|
crazyGamesSDK.addAuthListener(() => {
|
|
invalidateUserMe();
|
|
reauthAfterCrazyGamesChange().then((result) =>
|
|
result === false ? onUserMe(false) : getUserMe().then(onUserMe),
|
|
);
|
|
});
|
|
|
|
const settingsModal = document.querySelector(
|
|
"user-setting",
|
|
) as UserSettingModal;
|
|
if (!settingsModal || !(settingsModal instanceof UserSettingModal)) {
|
|
console.warn("User settings modal element not found");
|
|
}
|
|
document
|
|
.getElementById("settings-button")
|
|
?.addEventListener("click", () => {
|
|
if (settingsModal && settingsModal instanceof UserSettingModal) {
|
|
settingsModal.open();
|
|
}
|
|
});
|
|
|
|
this.hostModal = document.querySelector(
|
|
"host-lobby-modal",
|
|
) as HostPrivateLobbyModal;
|
|
if (!this.hostModal || !(this.hostModal instanceof HostPrivateLobbyModal)) {
|
|
console.warn("Host private lobby modal element not found");
|
|
} else {
|
|
this.hostModal.eventBus = this.eventBus;
|
|
}
|
|
|
|
this.joinModal = document.querySelector(
|
|
"join-lobby-modal",
|
|
) as JoinLobbyModal;
|
|
if (!this.joinModal || !(this.joinModal instanceof JoinLobbyModal)) {
|
|
console.warn("Join lobby modal element not found");
|
|
} else {
|
|
this.joinModal.eventBus = this.eventBus;
|
|
}
|
|
|
|
// Attempt to join lobby
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", () => this.handleUrl());
|
|
} else {
|
|
this.handleUrl();
|
|
}
|
|
|
|
const onHashUpdate = () => {
|
|
// Router-managed hash changes (#modal=...) are handled by the router
|
|
// syncing in/out; we don't need to tear down the lobby state for them.
|
|
if (modalRouter.isHashRouted()) {
|
|
modalRouter.routeFromHash();
|
|
return;
|
|
}
|
|
|
|
// Reset the UI to its initial state
|
|
this.joinModal?.close();
|
|
|
|
onJoinChanged();
|
|
};
|
|
|
|
const leaveGame = () => {
|
|
crazyGamesSDK.gameplayStop().then(() => {
|
|
// redirect to the home page
|
|
window.location.href = "/";
|
|
});
|
|
};
|
|
|
|
const onPopState = () => {
|
|
if (this.currentUrl !== null && this.lobbyHandle !== null) {
|
|
console.info("Game is active");
|
|
|
|
if (!this.lobbyHandle.stop()) {
|
|
console.info("Player is active, ask before leaving game");
|
|
|
|
// We can't block navigation on an async confirmation, so restore the
|
|
// history entry immediately and only leave once the player confirms.
|
|
history.pushState(null, "", this.currentUrl);
|
|
showInGameConfirm(translateText("help_modal.exit_confirmation")).then(
|
|
(isConfirmed) => {
|
|
if (isConfirmed) leaveGame();
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.info("Player is not active, leave the game immediately");
|
|
|
|
leaveGame();
|
|
} else {
|
|
console.info("Game not active, handle hash update");
|
|
|
|
onHashUpdate();
|
|
}
|
|
};
|
|
|
|
const onJoinChanged = () => {
|
|
if (this.lobbyHandle !== null) {
|
|
this.handleLeaveLobby();
|
|
}
|
|
|
|
// Attempt to join lobby
|
|
this.handleUrl();
|
|
};
|
|
|
|
// Handle browser navigation & manual hash edits
|
|
window.addEventListener("popstate", onPopState);
|
|
window.addEventListener("hashchange", onHashUpdate);
|
|
window.addEventListener("join-changed", onJoinChanged);
|
|
|
|
function updateSliderProgress(slider: HTMLInputElement) {
|
|
const percent =
|
|
((Number(slider.value) - Number(slider.min)) /
|
|
(Number(slider.max) - Number(slider.min))) *
|
|
100;
|
|
slider.style.setProperty("--progress", `${percent}%`);
|
|
}
|
|
|
|
document
|
|
.querySelectorAll<HTMLInputElement>(
|
|
"#bots-count, #private-lobby-bots-count",
|
|
)
|
|
.forEach((slider) => {
|
|
updateSliderProgress(slider);
|
|
slider.addEventListener("input", () => updateSliderProgress(slider));
|
|
});
|
|
}
|
|
|
|
private async handleUrl() {
|
|
// Wait for modal custom elements to be defined
|
|
await Promise.all([
|
|
customElements.whenDefined("join-lobby-modal"),
|
|
customElements.whenDefined("host-lobby-modal"),
|
|
]);
|
|
|
|
// Check if CrazyGames SDK is enabled first (no hash needed in CrazyGames)
|
|
if (crazyGamesSDK.isOnCrazyGames()) {
|
|
const lobbyId = await crazyGamesSDK.getInviteGameId();
|
|
console.log("got game id", lobbyId);
|
|
if (lobbyId && GAME_ID_REGEX.test(lobbyId)) {
|
|
console.log("game parsed successfully");
|
|
// Wait 2 seconds to ensure all elements are actually loaded,
|
|
// On low end-chromebooks the join modal was not registered in time.
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
window.showPage?.("page-join-lobby");
|
|
this.joinModal?.open({ lobbyId });
|
|
console.log(`CrazyGames: joining lobby ${lobbyId} from invite param`);
|
|
return;
|
|
}
|
|
}
|
|
crazyGamesSDK.isInstantMultiplayer().then((isInstant) => {
|
|
if (isInstant) {
|
|
console.log(
|
|
`CrazyGames: joining instant multiplayer lobby from CrazyGames`,
|
|
);
|
|
this.hostModal.open();
|
|
}
|
|
});
|
|
|
|
const strip = () =>
|
|
history.replaceState(
|
|
null,
|
|
"",
|
|
window.location.pathname + window.location.search,
|
|
);
|
|
|
|
const alertAndStrip = (message: string) => {
|
|
alert(message);
|
|
strip();
|
|
};
|
|
|
|
const hash = window.location.hash;
|
|
|
|
// Decode the hash first to handle encoded characters
|
|
const decodedHash = decodeURIComponent(hash);
|
|
const params = new URLSearchParams(decodedHash.split("?")[1] || "");
|
|
|
|
// Handle different hash sections
|
|
if (decodedHash.startsWith("#purchase-completed")) {
|
|
// Parse params after the ?
|
|
const status = params.get("status");
|
|
|
|
if (status !== "true") {
|
|
alertAndStrip("purchase failed");
|
|
return;
|
|
}
|
|
|
|
const type = params.get("type");
|
|
if (type === "currency_pack") {
|
|
alertAndStrip(translateText("store.currency_pack_purchase_success"));
|
|
return;
|
|
}
|
|
|
|
if (type === "custom_currency") {
|
|
// Plutonium is credited asynchronously by the Stripe webhook; the
|
|
// balance refreshes from /users/@me on the next load.
|
|
alertAndStrip(translateText("store.custom_currency_purchase_success"));
|
|
return;
|
|
}
|
|
|
|
if (type === "subscription_tier") {
|
|
alert(translateText("store.subscription_purchase_success"));
|
|
strip();
|
|
invalidateUserMe();
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
|
|
const cosmeticName = params.get("cosmetic");
|
|
if (!cosmeticName) {
|
|
alert("Something went wrong. Please contact support.");
|
|
console.error("purchase-completed but no pattern name");
|
|
return;
|
|
}
|
|
|
|
const setCosmetic = () => {
|
|
if (cosmeticName.startsWith("pattern:")) {
|
|
this.userSettings.setSelectedPatternName(cosmeticName);
|
|
} else if (cosmeticName.startsWith("flag:")) {
|
|
this.userSettings.setFlag(cosmeticName);
|
|
}
|
|
};
|
|
const token = params.get("login-token");
|
|
|
|
if (token) {
|
|
strip();
|
|
window.addEventListener("beforeunload", () => {
|
|
// The page reloads after token login, so we need to save the pattern name
|
|
// in case it is unset during reload.
|
|
setCosmetic();
|
|
});
|
|
this.tokenLoginModal.openWithToken(token);
|
|
} else {
|
|
alertAndStrip(`purchase succeeded: ${cosmeticName}`);
|
|
setCosmetic();
|
|
this.storeModal.refresh();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (decodedHash.startsWith("#token-login")) {
|
|
const token = params.get("token-login");
|
|
|
|
if (!token) {
|
|
alertAndStrip(
|
|
`login failed! Please try again later or contact support.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
strip();
|
|
this.tokenLoginModal.openWithToken(token);
|
|
return;
|
|
}
|
|
|
|
const pathMatch = window.location.pathname.match(
|
|
/^\/(?:w\d+\/)?game\/([^/]+)/,
|
|
);
|
|
const lobbyId =
|
|
pathMatch && GAME_ID_REGEX.test(pathMatch[1]) ? pathMatch[1] : null;
|
|
if (lobbyId) {
|
|
// ?host means the lobby creator is returning to a successor lobby they
|
|
// reused from the win screen: reopen the host view bound to the existing
|
|
// lobby instead of the join flow. Non-creators who hit this URL still get
|
|
// treated as normal joiners by the server.
|
|
const returningAsHost = new URLSearchParams(window.location.search).has(
|
|
"host",
|
|
);
|
|
if (returningAsHost) {
|
|
// open() reveals the inline page itself (it calls showPage internally).
|
|
// Calling showPage first would open the modal once with no args and
|
|
// spuriously create a lobby before this attach call runs.
|
|
this.hostModal.open({ existingLobbyId: lobbyId });
|
|
console.log(`reopening host lobby ${lobbyId}`);
|
|
return;
|
|
}
|
|
window.showPage?.("page-join-lobby");
|
|
this.joinModal.open({ lobbyId });
|
|
console.log(`joining lobby ${lobbyId}`);
|
|
return;
|
|
}
|
|
if (modalRouter.routeFromHash()) {
|
|
return;
|
|
}
|
|
if (decodedHash.startsWith("#affiliate=")) {
|
|
const affiliateCode = decodedHash.replace("#affiliate=", "");
|
|
strip();
|
|
if (affiliateCode) {
|
|
this.storeModal?.open({ affiliateCode });
|
|
}
|
|
}
|
|
if (decodedHash.startsWith("#refresh")) {
|
|
window.location.href = "/";
|
|
}
|
|
|
|
if (this.consumeRequeueUrl()) {
|
|
document.dispatchEvent(new CustomEvent("open-matchmaking"));
|
|
}
|
|
}
|
|
|
|
private consumeRequeueUrl(): boolean {
|
|
const searchParams = new URLSearchParams(window.location.search);
|
|
if (!searchParams.has("requeue")) {
|
|
return false;
|
|
}
|
|
|
|
searchParams.delete("requeue");
|
|
const newUrl =
|
|
window.location.pathname +
|
|
(searchParams.toString() ? `?${searchParams.toString()}` : "") +
|
|
window.location.hash;
|
|
history.replaceState(null, "", newUrl);
|
|
return true;
|
|
}
|
|
|
|
private async handleJoinLobby(event: CustomEvent<JoinLobbyEvent>) {
|
|
const lobby = event.detail;
|
|
this.mostRecentJoinEvent = event.timeStamp;
|
|
if (this.usernameInput && !this.usernameInput.canPlay()) {
|
|
return;
|
|
}
|
|
|
|
console.log(`joining lobby ${lobby.gameID}`);
|
|
if (this.lobbyHandle !== null) {
|
|
console.log("joining lobby, stopping existing game");
|
|
this.lobbyHandle.stop(true);
|
|
document.body.classList.remove("in-game");
|
|
}
|
|
if (lobby.source === "public") {
|
|
this.joinModal?.open({
|
|
lobbyId: lobby.gameID,
|
|
lobbyInfo: lobby.publicLobbyInfo,
|
|
});
|
|
}
|
|
// Only update URL immediately for private lobbies, not public ones
|
|
if (lobby.source !== "public") {
|
|
this.updateJoinUrlForShare(lobby.gameID);
|
|
}
|
|
const auth = await userAuth();
|
|
const playerRole = auth !== false ? (auth.claims.role ?? null) : null;
|
|
const newLobbyHandle = joinLobby(this.eventBus, {
|
|
gameID: lobby.gameID,
|
|
cosmetics: await getPlayerCosmeticsRefs(),
|
|
turnstileToken: await this.getTurnstileToken(lobby),
|
|
playerName: this.usernameInput?.getUsername() ?? genAnonUsername(),
|
|
playerClanTag: this.usernameInput?.getClanTag() ?? null,
|
|
clanTagCheck: this.usernameInput?.getClanCheck(),
|
|
playerRole,
|
|
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
|
|
gameRecord: lobby.gameRecord,
|
|
});
|
|
|
|
if (this.mostRecentJoinEvent !== event.timeStamp) {
|
|
newLobbyHandle.stop(true);
|
|
console.warn("Join requested, but was superseded");
|
|
return;
|
|
}
|
|
|
|
this.lobbyHandle = newLobbyHandle;
|
|
|
|
this.lobbyHandle.prestart.then(() => {
|
|
console.log("Closing modals");
|
|
document.getElementById("settings-button")?.classList.add("hidden");
|
|
if (this.usernameInput) {
|
|
// fix edge case where username-validation-error is re-rendered and hidden tag removed
|
|
this.usernameInput.validationError = "";
|
|
}
|
|
document
|
|
.getElementById("username-validation-error")
|
|
?.classList.add("hidden");
|
|
// Disarm BOTH lobby modals before closing either: closing any
|
|
// page-modal navigates via showPage, which force-closes the currently
|
|
// visible page — the other lobby modal. If that one is still armed,
|
|
// its onClose leaves the lobby and disconnects the player mid
|
|
// game-start (host or joiner, depending on close order).
|
|
this.hostModal?.disarmLeaveOnClose();
|
|
this.joinModal?.disarmLeaveOnClose();
|
|
this.hostModal?.closeWithoutLeaving();
|
|
this.joinModal?.closeWithoutLeaving();
|
|
[
|
|
"single-player-modal",
|
|
"game-starting-modal",
|
|
"game-top-bar",
|
|
"help-modal",
|
|
"user-setting",
|
|
"troubleshooting-modal",
|
|
"territory-patterns-modal",
|
|
"store-modal",
|
|
"language-modal",
|
|
"news-modal",
|
|
"flag-input-modal",
|
|
"effects-modal",
|
|
"account-button",
|
|
"leaderboard-button",
|
|
"token-login",
|
|
"matchmaking-modal",
|
|
"clan-modal",
|
|
"lang-selector",
|
|
"homepage-promos",
|
|
].forEach((tag) => {
|
|
const modal = document.querySelector(tag) as HTMLElement & {
|
|
close?: () => void;
|
|
isModalOpen?: boolean;
|
|
};
|
|
if (modal?.close) {
|
|
modal.close();
|
|
} else if (modal && "isModalOpen" in modal) {
|
|
modal.isModalOpen = false;
|
|
}
|
|
});
|
|
this.gameModeSelector.stop();
|
|
document.querySelectorAll(".ad").forEach((ad) => {
|
|
(ad as HTMLElement).style.display = "none";
|
|
});
|
|
|
|
crazyGamesSDK.loadingStart();
|
|
|
|
// show when the game loads
|
|
const startingModal = document.querySelector(
|
|
"game-starting-modal",
|
|
) as GameStartingModal;
|
|
if (startingModal && startingModal instanceof GameStartingModal) {
|
|
startingModal.show();
|
|
}
|
|
});
|
|
|
|
this.lobbyHandle.join.then(() => {
|
|
this.joinModal?.closeWithoutLeaving();
|
|
this.gameModeSelector.stop();
|
|
incrementGamesPlayed();
|
|
|
|
document.querySelectorAll(".ad").forEach((ad) => {
|
|
(ad as HTMLElement).style.display = "none";
|
|
});
|
|
|
|
if (window.PageOS?.session?.newPageView) {
|
|
window.PageOS.session.newPageView();
|
|
}
|
|
crazyGamesSDK.loadingStop();
|
|
crazyGamesSDK.gameplayStart();
|
|
document.body.classList.add("in-game");
|
|
|
|
// Ensure there's a homepage entry in history before adding the lobby entry
|
|
if (window.location.hash === "" || window.location.hash === "#") {
|
|
history.replaceState(null, "", window.location.origin + "#refresh");
|
|
}
|
|
const lobbyIdHidden = !this.userSettings.lobbyIdVisibility();
|
|
history.pushState(
|
|
null,
|
|
"",
|
|
lobbyIdHidden
|
|
? "/streamer-mode"
|
|
: `/${ClientEnv.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`,
|
|
);
|
|
|
|
// Store current URL for popstate confirmation
|
|
this.currentUrl = window.location.href;
|
|
});
|
|
}
|
|
|
|
private updateJoinUrlForShare(lobbyId: string) {
|
|
const lobbyIdHidden = !this.userSettings.lobbyIdVisibility();
|
|
const targetUrl = lobbyIdHidden
|
|
? "/streamer-mode"
|
|
: `/${ClientEnv.workerPath(lobbyId)}/game/${lobbyId}`;
|
|
const currentUrl = window.location.pathname;
|
|
|
|
if (currentUrl !== targetUrl) {
|
|
history.replaceState(null, "", targetUrl);
|
|
}
|
|
}
|
|
|
|
private async handleLeaveLobby(event?: CustomEvent) {
|
|
if (this.lobbyHandle === null) {
|
|
return;
|
|
}
|
|
console.log("leaving lobby, cancelling game");
|
|
this.lobbyHandle.stop(true);
|
|
this.lobbyHandle = null;
|
|
this.currentUrl = null;
|
|
|
|
try {
|
|
history.replaceState(null, "", "/");
|
|
} catch (e) {
|
|
console.warn("Failed to restore URL on leave:", e);
|
|
}
|
|
|
|
document.body.classList.remove("in-game");
|
|
|
|
if (this.joinModal.isOpen()) {
|
|
this.joinModal.close();
|
|
if (event?.detail.cause === "full-lobby") {
|
|
window.dispatchEvent(
|
|
new CustomEvent("show-message", {
|
|
detail: {
|
|
message: translateText("public_lobby.join_timeout"),
|
|
color: "red",
|
|
duration: 3500,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
crazyGamesSDK.gameplayStop();
|
|
}
|
|
|
|
private handleOpenMatchmaking(_event: CustomEvent<undefined>) {
|
|
this.matchmakingModal?.open();
|
|
}
|
|
|
|
private handleKickPlayer(event: CustomEvent) {
|
|
const { target } = event.detail;
|
|
|
|
// Forward to eventBus if available
|
|
if (this.eventBus) {
|
|
this.eventBus.emit(new SendKickPlayerIntentEvent(target));
|
|
}
|
|
}
|
|
|
|
private handleToggleGameStartTimer() {
|
|
if (this.eventBus) {
|
|
this.eventBus.emit(new SendToggleGameStartTimer());
|
|
}
|
|
}
|
|
|
|
private handleUpdateGameConfig(event: CustomEvent) {
|
|
const { config } = event.detail;
|
|
|
|
// Forward to eventBus if available
|
|
if (this.eventBus) {
|
|
this.eventBus.emit(new SendUpdateGameConfigIntentEvent(config));
|
|
}
|
|
}
|
|
|
|
private async getTurnstileToken(
|
|
lobby: JoinLobbyEvent,
|
|
): Promise<string | null> {
|
|
if (
|
|
ClientEnv.env() === GameEnv.Dev ||
|
|
lobby.gameStartInfo?.config.gameType === GameType.Singleplayer
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// Always request a new token on crazygames.
|
|
if (this.turnstileTokenPromise === null || crazyGamesSDK.isOnCrazyGames()) {
|
|
console.log("No prefetched turnstile token, getting new token");
|
|
return (await getTurnstileToken())?.token ?? null;
|
|
}
|
|
|
|
const token = await this.turnstileTokenPromise;
|
|
// Clear promise so a new token is fetched next time
|
|
this.turnstileTokenPromise = null;
|
|
if (!token) {
|
|
console.log("No turnstile token");
|
|
return null;
|
|
}
|
|
|
|
const tokenTTL = 3 * 60 * 1000;
|
|
if (Date.now() < token.createdAt + tokenTTL) {
|
|
console.log("Prefetched turnstile token is valid");
|
|
|
|
return token.token;
|
|
} else {
|
|
console.log("Turnstile token expired, getting new token");
|
|
return (await getTurnstileToken())?.token ?? null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hide elements with no-crazygames class if on CrazyGames
|
|
const hideCrazyGamesElements = () => {
|
|
if (crazyGamesSDK.isOnCrazyGames()) {
|
|
document.querySelectorAll(".no-crazygames").forEach((el) => {
|
|
(el as HTMLElement).style.display = "none";
|
|
});
|
|
}
|
|
};
|
|
|
|
// Initialize the client when the DOM is loaded
|
|
const bootstrap = () => {
|
|
// Prevent Safari's page-level pinch-zoom, which ignores `user-scalable=no`
|
|
// on iOS and can softlock the HUD. See issue #2330.
|
|
installSafariPinchZoomBlocker();
|
|
|
|
initLayout();
|
|
new Client().initialize();
|
|
initNavigation();
|
|
|
|
// Hide elements immediately
|
|
hideCrazyGamesElements();
|
|
|
|
// Also hide elements after a short delay to catch late-rendered components
|
|
setTimeout(hideCrazyGamesElements, 100);
|
|
setTimeout(hideCrazyGamesElements, 500);
|
|
|
|
// Populate the CrazyGames account buttons once the nav/top-bar have rendered
|
|
// (onUserMe also refreshes them after auth and on mid-session sign-in).
|
|
setTimeout(() => void updateCrazyGamesNavButton(), 500);
|
|
};
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", bootstrap);
|
|
} else {
|
|
bootstrap();
|
|
}
|
|
|
|
async function getTurnstileToken(): Promise<{
|
|
token: string;
|
|
createdAt: number;
|
|
}> {
|
|
// Wait for Turnstile script to load (handles slow connections)
|
|
let attempts = 0;
|
|
while (typeof window.turnstile === "undefined" && attempts < 100) {
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
attempts++;
|
|
}
|
|
|
|
if (typeof window.turnstile === "undefined") {
|
|
throw new Error("Failed to load Turnstile script");
|
|
}
|
|
|
|
const widgetId = window.turnstile.render("#turnstile-container", {
|
|
sitekey: ClientEnv.turnstileSiteKey(),
|
|
size: "normal",
|
|
appearance: "interaction-only",
|
|
theme: "light",
|
|
});
|
|
|
|
return new Promise((resolve, reject) => {
|
|
window.turnstile.execute(widgetId, {
|
|
callback: (token: string) => {
|
|
window.turnstile.remove(widgetId);
|
|
console.log(`Turnstile token received: ${token}`);
|
|
resolve({ token, createdAt: Date.now() });
|
|
},
|
|
"error-callback": (errorCode: string) => {
|
|
window.turnstile.remove(widgetId);
|
|
console.error(`Turnstile error: ${errorCode}`);
|
|
alert(`Turnstile error: ${errorCode}. Please refresh and try again.`);
|
|
reject(new Error(`Turnstile failed: ${errorCode}`));
|
|
},
|
|
});
|
|
});
|
|
}
|