mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 10:48:10 +00:00
2be858869c
## Description:
This refactors client configuration loading to make the environment
split explicit.
The app currently has two different env concerns:
- the browser main thread needs the live runtime env to select API /
Turnstile / JWT settings
- the worker and game-logic path need a build-time env to select game
config behavior
Before this change, both responsibilities were hidden behind the same
loader, which made the intent unclear and caused confusion around the
worker fallback behavior.
This PR separates those paths explicitly:
- main-thread browser code now uses `getRuntimeClientServerConfig()`
- game creation and worker/game-logic code now uses
`getGameLogicConfig()`
- the build-time game-logic env is represented explicitly as
`GameLogicEnv`
## What Changed
- Added `GameLogicEnv` to model the build-time game config choice
explicitly.
- Added `getRuntimeClientServerConfig()` for live runtime browser config
from `window.BOOTSTRAP_CONFIG`.
- Added `getBuildTimeGameLogicEnv()` and
`getServerConfigForGameLogicEnv()` for build-time worker/game-logic
config.
- Renamed game config loading from `getConfig()` to
`getGameLogicConfig()` to reflect what it actually does.
- Updated browser call sites to use the runtime client config loader.
- Updated worker/game creation paths to use the game-logic config
loader.
- Updated config loader tests to cover both paths.
## Behavior
This keeps the current intended behavior, but makes it explicit:
- Runtime client env:
- comes from `window.BOOTSTRAP_CONFIG`
- controls live browser integration settings such as API origin,
Turnstile, and JWT audience/issuer
- Build-time game-logic env:
- comes from bundled `process.env.GAME_ENV`
- maps:
- `dev` -> dev game config
- `staging` -> default/prod game config
- `prod` -> default/prod game config
That means preprod/staging deployments can continue using prod game
logic while still using staging API/auth settings on the main thread.
## Why
The previous setup worked, but the naming and loader boundaries were
misleading:
- the same function was used for both runtime browser config and
worker/game config
- the worker fallback looked like an implementation detail instead of an
intentional architectural split
This change makes that intent visible in code without changing the
desired deployment behavior.
## Please complete the following:
- [ ] I have added screenshots for all UI updates
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory
- [ ] 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:
DISCORD_USERNAME
1035 lines
32 KiB
TypeScript
1035 lines
32 KiB
TypeScript
import version from "resources/version.txt?raw";
|
|
import { UserMeResponse } from "../core/ApiSchemas";
|
|
import { EventBus } from "../core/EventBus";
|
|
import {
|
|
GAME_ID_REGEX,
|
|
GameInfo,
|
|
GameRecord,
|
|
GameStartInfo,
|
|
PublicGameInfo,
|
|
} from "../core/Schemas";
|
|
import { GameEnv } from "../core/configuration/Config";
|
|
import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader";
|
|
import { GameType } from "../core/game/Game";
|
|
import { UserSettings } from "../core/game/UserSettings";
|
|
import "./AccountModal";
|
|
import { getUserMe } from "./Api";
|
|
import { userAuth } from "./Auth";
|
|
import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner";
|
|
import { getPlayerCosmeticsRefs } from "./Cosmetics";
|
|
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
|
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 { GutterAds } from "./GutterAds";
|
|
import { HelpModal } from "./HelpModal";
|
|
import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal";
|
|
import { JoinLobbyModal } from "./JoinLobbyModal";
|
|
import "./LangSelector";
|
|
import { LangSelector } from "./LangSelector";
|
|
import { initLayout } from "./Layout";
|
|
import "./LeaderboardModal";
|
|
import "./Matchmaking";
|
|
import { MatchmakingModal } from "./Matchmaking";
|
|
import { initNavigation } from "./Navigation";
|
|
import "./NewsModal";
|
|
import "./PatternInput";
|
|
import "./SinglePlayerModal";
|
|
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
|
|
import { TokenLoginModal } from "./TokenLoginModal";
|
|
import {
|
|
SendKickPlayerIntentEvent,
|
|
SendUpdateGameConfigIntentEvent,
|
|
} from "./Transport";
|
|
import { UserSettingModal } from "./UserSettingModal";
|
|
import "./UsernameInput";
|
|
import { genAnonUsername, UsernameInput } from "./UsernameInput";
|
|
import {
|
|
getDiscordAvatarUrl,
|
|
incrementGamesPlayed,
|
|
isInIframe,
|
|
translateText,
|
|
} from "./Utils";
|
|
import "./components/DesktopNavBar";
|
|
import "./components/Footer";
|
|
import "./components/HomeFooterAd";
|
|
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 = () => {
|
|
// Only handle if this is the latest update
|
|
if (avatarEl._navToken !== navToken) return;
|
|
avatarEl.src = "";
|
|
// If the user is still logged in via email, show the email badge state.
|
|
const email =
|
|
userMeResponse !== false ? userMeResponse.user.email : undefined;
|
|
if (email) {
|
|
showEmailLoggedIn();
|
|
} else {
|
|
showSignIn();
|
|
}
|
|
};
|
|
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;
|
|
}
|
|
|
|
showSignIn();
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
GIT_COMMIT: string;
|
|
turnstile: any;
|
|
adsEnabled: boolean;
|
|
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;
|
|
"join-changed": CustomEvent;
|
|
"open-matchmaking": CustomEvent<undefined>;
|
|
}
|
|
}
|
|
|
|
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 patternsModal: TerritoryPatternsModal;
|
|
private tokenLoginModal: TokenLoginModal;
|
|
private matchmakingModal: MatchmakingModal;
|
|
|
|
private gutterAds: GutterAds;
|
|
private turnstileTokenPromise: Promise<{
|
|
token: string;
|
|
createdAt: number;
|
|
}> | null = null;
|
|
|
|
async initialize(): Promise<void> {
|
|
crazyGamesSDK.maybeInit();
|
|
// 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 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.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();
|
|
}
|
|
});
|
|
|
|
const gutterAds = document.querySelector("gutter-ads");
|
|
if (!(gutterAds instanceof GutterAds))
|
|
throw new Error("Missing gutter-ads");
|
|
this.gutterAds = gutterAds;
|
|
|
|
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(
|
|
"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();
|
|
}
|
|
});
|
|
});
|
|
|
|
this.patternsModal = document.getElementById(
|
|
"territory-patterns-modal",
|
|
) as TerritoryPatternsModal;
|
|
if (
|
|
!this.patternsModal ||
|
|
!(this.patternsModal instanceof TerritoryPatternsModal)
|
|
) {
|
|
console.warn("Territory patterns modal element not found");
|
|
}
|
|
|
|
// Attach listener to any pattern-input component
|
|
document.querySelectorAll("pattern-input").forEach((patternInput) => {
|
|
patternInput.addEventListener("pattern-input-click", () => {
|
|
// Open the Store page which contains the patterns UI
|
|
window.showPage?.("page-item-store");
|
|
const skinStoreModal = document.getElementById(
|
|
"page-item-store",
|
|
) as HTMLElement & { open?: (opts: any) => void };
|
|
if (skinStoreModal) {
|
|
skinStoreModal.classList.remove("hidden");
|
|
if (typeof skinStoreModal.open === "function") {
|
|
skinStoreModal.open({ showOnlyOwned: true });
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
if (isInIframe()) {
|
|
const mobilePat = document.getElementById("pattern-input-mobile");
|
|
if (mobilePat) mobilePat.style.display = "none";
|
|
}
|
|
|
|
if (
|
|
!this.patternsModal ||
|
|
!(this.patternsModal instanceof TerritoryPatternsModal)
|
|
) {
|
|
console.warn("Territory patterns 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.patternsModal.refresh();
|
|
|
|
// Listen for pattern selection to update any other listeners if needed,
|
|
// though PatternInput handles its own updates via window event.
|
|
this.patternsModal.addEventListener("pattern-selected", () => {
|
|
// PatternInput components will update themselves.
|
|
});
|
|
|
|
window.addEventListener("showPage", (e: any) => {
|
|
if (typeof e?.detail === "string" && e.detail === "page-play") {
|
|
setTimeout(() => {
|
|
this.patternsModal.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");
|
|
}
|
|
|
|
const onUserMe = async (userMeResponse: UserMeResponse | false) => {
|
|
updateAccountNavButton(userMeResponse);
|
|
const hasLinkedAccount =
|
|
!crazyGamesSDK.isOnCrazyGames() &&
|
|
((userMeResponse || null)?.player?.flares?.length ?? 0) > 0;
|
|
console.log("ads enabled: ", hasLinkedAccount);
|
|
window.adsEnabled = !hasLinkedAccount && !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.",
|
|
);
|
|
}
|
|
};
|
|
|
|
if ((await userAuth()) === false) {
|
|
// Not logged in
|
|
onUserMe(false);
|
|
} else {
|
|
// JWT appears to be valid
|
|
// TODO: Add caching
|
|
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;
|
|
}
|
|
|
|
if (this.userSettings.darkMode()) {
|
|
document.documentElement.classList.add("dark");
|
|
} else {
|
|
document.documentElement.classList.remove("dark");
|
|
}
|
|
|
|
// Attempt to join lobby
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", () => this.handleUrl());
|
|
} else {
|
|
this.handleUrl();
|
|
}
|
|
|
|
const onHashUpdate = () => {
|
|
// Reset the UI to its initial state
|
|
this.joinModal?.close();
|
|
|
|
onJoinChanged();
|
|
};
|
|
|
|
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");
|
|
|
|
const isConfirmed = confirm(
|
|
translateText("help_modal.exit_confirmation"),
|
|
);
|
|
|
|
if (!isConfirmed) {
|
|
// Rollback navigator history
|
|
history.pushState(null, "", this.currentUrl);
|
|
return;
|
|
}
|
|
}
|
|
|
|
console.info("Player is not active, leave the game immediately");
|
|
|
|
crazyGamesSDK.gameplayStop().then(() => {
|
|
// redirect to the home page
|
|
window.location.href = "/";
|
|
});
|
|
} 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 patternName = params.get("cosmetic");
|
|
if (!patternName) {
|
|
alert("Something went wrong. Please contact support.");
|
|
console.error("purchase-completed but no pattern name");
|
|
return;
|
|
}
|
|
|
|
this.userSettings.setSelectedPatternName(patternName);
|
|
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.
|
|
this.userSettings.setSelectedPatternName(patternName);
|
|
});
|
|
this.tokenLoginModal.openWithToken(token);
|
|
} else {
|
|
alertAndStrip(`purchase succeeded: ${patternName}`);
|
|
this.patternsModal.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) {
|
|
window.showPage?.("page-join-lobby");
|
|
this.joinModal.open(lobbyId);
|
|
console.log(`joining lobby ${lobbyId}`);
|
|
return;
|
|
}
|
|
if (decodedHash.startsWith("#affiliate=")) {
|
|
const affiliateCode = decodedHash.replace("#affiliate=", "");
|
|
strip();
|
|
if (affiliateCode) {
|
|
this.patternsModal?.open(affiliateCode);
|
|
}
|
|
}
|
|
if (decodedHash.startsWith("#refresh")) {
|
|
window.location.href = "/";
|
|
}
|
|
|
|
// Handle requeue parameter for ranked matchmaking
|
|
const searchParams = new URLSearchParams(window.location.search);
|
|
if (searchParams.has("requeue")) {
|
|
// Remove only the requeue parameter, preserving other params and hash
|
|
searchParams.delete("requeue");
|
|
const newUrl =
|
|
window.location.pathname +
|
|
(searchParams.toString() ? "?" + searchParams.toString() : "") +
|
|
window.location.hash;
|
|
history.replaceState(null, "", newUrl);
|
|
// Wait for matchmaking button to be defined, then trigger its click handler.
|
|
customElements.whenDefined("matchmaking-button").then(() => {
|
|
const matchmakingButton = document.querySelector(
|
|
"matchmaking-button button",
|
|
) as HTMLButtonElement | null;
|
|
if (matchmakingButton) {
|
|
matchmakingButton.click();
|
|
} else {
|
|
console.warn(
|
|
"Requeue requested, but matchmaking button not found in DOM.",
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleJoinLobby(event: CustomEvent<JoinLobbyEvent>) {
|
|
const lobby = event.detail;
|
|
if (this.usernameInput && !this.usernameInput.validateOrShowError()) {
|
|
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(lobby.gameID, lobby.publicLobbyInfo);
|
|
}
|
|
const config = await getRuntimeClientServerConfig();
|
|
// Only update URL immediately for private lobbies, not public ones
|
|
if (lobby.source !== "public") {
|
|
this.updateJoinUrlForShare(lobby.gameID, config);
|
|
}
|
|
this.lobbyHandle = joinLobby(this.eventBus, {
|
|
gameID: lobby.gameID,
|
|
serverConfig: config,
|
|
cosmetics: await getPlayerCosmeticsRefs(),
|
|
turnstileToken: await this.getTurnstileToken(lobby),
|
|
playerName: this.usernameInput?.getUsername() ?? genAnonUsername(),
|
|
playerClanTag: this.usernameInput?.getClanTag() ?? null,
|
|
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
|
|
gameRecord: lobby.gameRecord,
|
|
});
|
|
|
|
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");
|
|
this.joinModal?.closeWithoutLeaving();
|
|
[
|
|
"single-player-modal",
|
|
"host-lobby-modal",
|
|
"game-starting-modal",
|
|
"game-top-bar",
|
|
"help-modal",
|
|
"user-setting",
|
|
"troubleshooting-modal",
|
|
"territory-patterns-modal",
|
|
"language-modal",
|
|
"news-modal",
|
|
"flag-input-modal",
|
|
"account-button",
|
|
"leaderboard-button",
|
|
"token-login",
|
|
"matchmaking-modal",
|
|
"lang-selector",
|
|
"gutter-ads",
|
|
].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"
|
|
: `/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`,
|
|
);
|
|
|
|
// Store current URL for popstate confirmation
|
|
this.currentUrl = window.location.href;
|
|
});
|
|
}
|
|
|
|
private updateJoinUrlForShare(
|
|
lobbyId: string,
|
|
config: Awaited<ReturnType<typeof getRuntimeClientServerConfig>>,
|
|
) {
|
|
const lobbyIdHidden = !this.userSettings.lobbyIdVisibility();
|
|
const targetUrl = lobbyIdHidden
|
|
? "/streamer-mode"
|
|
: `/${config.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 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> {
|
|
const config = await getRuntimeClientServerConfig();
|
|
if (
|
|
config.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 = () => {
|
|
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);
|
|
};
|
|
|
|
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 config = await getRuntimeClientServerConfig();
|
|
const widgetId = window.turnstile.render("#turnstile-container", {
|
|
sitekey: config.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}`));
|
|
},
|
|
});
|
|
});
|
|
}
|