mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:00:43 +00:00
Merge branch 'v30'
This commit is contained in:
+2
-3
@@ -284,7 +284,7 @@
|
||||
class="w-full pointer-events-auto order-1 sm:order-none"
|
||||
></attacks-display>
|
||||
<div
|
||||
class="pointer-events-auto bg-gray-800/70 backdrop-blur-xs sm:rounded-tr-lg lg:rounded-t-lg min-[1200px]:rounded-lg shadow-lg order-3 sm:order-none"
|
||||
class="pointer-events-auto bg-gray-800/92 backdrop-blur-sm sm:rounded-tr-lg lg:rounded-t-lg min-[1200px]:rounded-lg shadow-lg order-3 sm:order-none"
|
||||
>
|
||||
<control-panel class="w-full"></control-panel>
|
||||
<unit-display class="hidden lg:block w-full"></unit-display>
|
||||
@@ -319,8 +319,7 @@
|
||||
<player-panel></player-panel>
|
||||
<spawn-timer></spawn-timer>
|
||||
<immunity-timer></immunity-timer>
|
||||
<in-game-header-ad></in-game-header-ad>
|
||||
<spawn-video-ad></spawn-video-ad>
|
||||
<in-game-promo></in-game-promo>
|
||||
<game-info-modal></game-info-modal>
|
||||
<alert-frame></alert-frame>
|
||||
<chat-modal></chat-modal>
|
||||
|
||||
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 334 KiB |
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 1202,
|
||||
"num_land_tiles": 1258353,
|
||||
"height": 1200,
|
||||
"num_land_tiles": 1255327,
|
||||
"width": 1800
|
||||
},
|
||||
"map16x": {
|
||||
"height": 300,
|
||||
"num_land_tiles": 77228,
|
||||
"num_land_tiles": 77229,
|
||||
"width": 450
|
||||
},
|
||||
"map4x": {
|
||||
"height": 601,
|
||||
"num_land_tiles": 313011,
|
||||
"height": 600,
|
||||
"num_land_tiles": 312219,
|
||||
"width": 900
|
||||
},
|
||||
"name": "straitofhormuz",
|
||||
"name": "Strait of Hormuz",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [837, 356],
|
||||
@@ -99,7 +99,7 @@
|
||||
{
|
||||
"coordinates": [159, 756],
|
||||
"flag": "",
|
||||
"name": "Ar Rayy\u0101n"
|
||||
"name": "Ar Rayyān"
|
||||
},
|
||||
{
|
||||
"coordinates": [1103, 647],
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 6.3 KiB |
@@ -65,15 +65,24 @@ export interface LobbyConfig {
|
||||
gameRecord?: GameRecord;
|
||||
}
|
||||
|
||||
export interface JoinLobbyResult {
|
||||
stop: (force?: boolean) => boolean;
|
||||
prestart: Promise<void>;
|
||||
join: Promise<void>;
|
||||
}
|
||||
|
||||
export function joinLobby(
|
||||
eventBus: EventBus,
|
||||
lobbyConfig: LobbyConfig,
|
||||
onPrestart: () => void,
|
||||
onJoin: () => void,
|
||||
): (force?: boolean) => boolean {
|
||||
): JoinLobbyResult {
|
||||
// Mutable clientID state — assigned by server (multiplayer) or derived from gameStartInfo (singleplayer)
|
||||
let clientID: ClientID | undefined;
|
||||
|
||||
let resolvePrestart: () => void;
|
||||
let resolveJoin: () => void;
|
||||
const prestartPromise = new Promise<void>((r) => (resolvePrestart = r));
|
||||
const joinPromise = new Promise<void>((r) => (resolveJoin = r));
|
||||
|
||||
console.log(`joining lobby: gameID: ${lobbyConfig.gameID}`);
|
||||
|
||||
const userSettings: UserSettings = new UserSettings();
|
||||
@@ -106,17 +115,17 @@ export function joinLobby(
|
||||
message.gameMapSize,
|
||||
terrainMapFileLoader,
|
||||
);
|
||||
onPrestart();
|
||||
resolvePrestart();
|
||||
}
|
||||
if (message.type === "start") {
|
||||
// Trigger prestart for singleplayer games
|
||||
onPrestart();
|
||||
resolvePrestart();
|
||||
console.log(
|
||||
`lobby: game started: ${JSON.stringify(message, replacer, 2)}`,
|
||||
);
|
||||
// Server tells us our assigned clientID (also sent on start for late joins)
|
||||
clientID = message.myClientID;
|
||||
onJoin();
|
||||
resolveJoin();
|
||||
// For multiplayer games, GameStartInfo is not known until game starts.
|
||||
lobbyConfig.gameStartInfo = message.gameStartInfo;
|
||||
createClientGame(
|
||||
@@ -158,7 +167,7 @@ export function joinLobby(
|
||||
if (message.error === "full-lobby") {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("leave-lobby", {
|
||||
detail: { lobby: lobbyConfig.gameID },
|
||||
detail: { lobby: lobbyConfig.gameID, cause: "full-lobby" },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
@@ -177,19 +186,19 @@ export function joinLobby(
|
||||
}
|
||||
};
|
||||
transport.connect(onconnect, onmessage);
|
||||
return (force: boolean = false) => {
|
||||
if (!force && currentGameRunner?.shouldPreventWindowClose()) {
|
||||
console.log("Player is active, prevent leaving game");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("leaving game");
|
||||
|
||||
currentGameRunner = null;
|
||||
transport.leaveGame();
|
||||
|
||||
return true;
|
||||
return {
|
||||
stop: (force: boolean = false) => {
|
||||
if (!force && currentGameRunner?.shouldPreventWindowClose()) {
|
||||
console.log("Player is active, prevent leaving game");
|
||||
return false;
|
||||
}
|
||||
console.log("leaving game");
|
||||
currentGameRunner = null;
|
||||
transport.leaveGame();
|
||||
return true;
|
||||
},
|
||||
prestart: prestartPromise,
|
||||
join: joinPromise,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+128
-109
@@ -15,7 +15,7 @@ import { UserSettings } from "../core/game/UserSettings";
|
||||
import "./AccountModal";
|
||||
import { getUserMe } from "./Api";
|
||||
import { userAuth } from "./Auth";
|
||||
import { joinLobby } from "./ClientGameRunner";
|
||||
import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner";
|
||||
import { getPlayerCosmeticsRefs } from "./Cosmetics";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import "./FlagInput";
|
||||
@@ -180,12 +180,17 @@ declare global {
|
||||
ramp: {
|
||||
que: Array<() => void>;
|
||||
passiveMode: boolean;
|
||||
spaAddAds: (ads: Array<{ type: string; selectorId: string }>) => void;
|
||||
destroyUnits: (adType: string) => void;
|
||||
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>;
|
||||
@@ -230,7 +235,7 @@ export interface JoinLobbyEvent {
|
||||
}
|
||||
|
||||
class Client {
|
||||
private gameStop: ((force?: boolean) => boolean) | null = null;
|
||||
private lobbyHandle: JoinLobbyResult | null = null;
|
||||
private eventBus: EventBus = new EventBus();
|
||||
|
||||
private currentUrl: string | null = null;
|
||||
@@ -300,8 +305,8 @@ class Client {
|
||||
|
||||
window.addEventListener("beforeunload", async () => {
|
||||
console.log("Browser is closing");
|
||||
if (this.gameStop !== null) {
|
||||
this.gameStop(true);
|
||||
if (this.lobbyHandle !== null) {
|
||||
this.lobbyHandle.stop(true);
|
||||
await crazyGamesSDK.gameplayStop();
|
||||
}
|
||||
});
|
||||
@@ -521,10 +526,10 @@ class Client {
|
||||
};
|
||||
|
||||
const onPopState = () => {
|
||||
if (this.currentUrl !== null && this.gameStop !== null) {
|
||||
if (this.currentUrl !== null && this.lobbyHandle !== null) {
|
||||
console.info("Game is active");
|
||||
|
||||
if (!this.gameStop()) {
|
||||
if (!this.lobbyHandle.stop()) {
|
||||
console.info("Player is active, ask before leaving game");
|
||||
|
||||
const isConfirmed = confirm(
|
||||
@@ -552,7 +557,7 @@ class Client {
|
||||
};
|
||||
|
||||
const onJoinChanged = () => {
|
||||
if (this.gameStop !== null) {
|
||||
if (this.lobbyHandle !== null) {
|
||||
this.handleLeaveLobby();
|
||||
}
|
||||
|
||||
@@ -642,7 +647,7 @@ class Client {
|
||||
return;
|
||||
}
|
||||
|
||||
const patternName = params.get("pattern");
|
||||
const patternName = params.get("cosmetic");
|
||||
if (!patternName) {
|
||||
alert("Something went wrong. Please contact support.");
|
||||
console.error("purchase-completed but no pattern name");
|
||||
@@ -737,9 +742,9 @@ class Client {
|
||||
}
|
||||
|
||||
console.log(`joining lobby ${lobby.gameID}`);
|
||||
if (this.gameStop !== null) {
|
||||
if (this.lobbyHandle !== null) {
|
||||
console.log("joining lobby, stopping existing game");
|
||||
this.gameStop(true);
|
||||
this.lobbyHandle.stop(true);
|
||||
document.body.classList.remove("in-game");
|
||||
}
|
||||
if (lobby.source === "public") {
|
||||
@@ -750,106 +755,105 @@ class Client {
|
||||
if (lobby.source !== "public") {
|
||||
this.updateJoinUrlForShare(lobby.gameID, config);
|
||||
}
|
||||
this.gameStop = 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,
|
||||
},
|
||||
() => {
|
||||
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 = "";
|
||||
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;
|
||||
}
|
||||
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";
|
||||
});
|
||||
});
|
||||
this.gameModeSelector.stop();
|
||||
document.querySelectorAll(".ad").forEach((ad) => {
|
||||
(ad as HTMLElement).style.display = "none";
|
||||
});
|
||||
|
||||
crazyGamesSDK.loadingStart();
|
||||
crazyGamesSDK.loadingStart();
|
||||
|
||||
// show when the game loads
|
||||
const startingModal = document.querySelector(
|
||||
"game-starting-modal",
|
||||
) as GameStartingModal;
|
||||
if (startingModal && startingModal instanceof GameStartingModal) {
|
||||
startingModal.show();
|
||||
}
|
||||
},
|
||||
() => {
|
||||
this.joinModal?.closeWithoutLeaving();
|
||||
this.gameModeSelector.stop();
|
||||
incrementGamesPlayed();
|
||||
// show when the game loads
|
||||
const startingModal = document.querySelector(
|
||||
"game-starting-modal",
|
||||
) as GameStartingModal;
|
||||
if (startingModal && startingModal instanceof GameStartingModal) {
|
||||
startingModal.show();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll(".ad").forEach((ad) => {
|
||||
(ad as HTMLElement).style.display = "none";
|
||||
});
|
||||
this.lobbyHandle.join.then(() => {
|
||||
this.joinModal?.closeWithoutLeaving();
|
||||
this.gameModeSelector.stop();
|
||||
incrementGamesPlayed();
|
||||
|
||||
if (window.PageOS?.session?.newPageView) {
|
||||
window.PageOS.session.newPageView();
|
||||
}
|
||||
crazyGamesSDK.loadingStop();
|
||||
crazyGamesSDK.gameplayStart();
|
||||
document.body.classList.add("in-game");
|
||||
document.querySelectorAll(".ad").forEach((ad) => {
|
||||
(ad as HTMLElement).style.display = "none";
|
||||
});
|
||||
|
||||
// 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`,
|
||||
);
|
||||
if (window.PageOS?.session?.newPageView) {
|
||||
window.PageOS.session.newPageView();
|
||||
}
|
||||
crazyGamesSDK.loadingStop();
|
||||
crazyGamesSDK.gameplayStart();
|
||||
document.body.classList.add("in-game");
|
||||
|
||||
// Store current URL for popstate confirmation
|
||||
this.currentUrl = window.location.href;
|
||||
},
|
||||
);
|
||||
// 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(
|
||||
@@ -867,13 +871,13 @@ class Client {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleLeaveLobby(/* event: CustomEvent */) {
|
||||
if (this.gameStop === null) {
|
||||
private async handleLeaveLobby(event?: CustomEvent) {
|
||||
if (this.lobbyHandle === null) {
|
||||
return;
|
||||
}
|
||||
console.log("leaving lobby, cancelling game");
|
||||
this.gameStop(true);
|
||||
this.gameStop = null;
|
||||
this.lobbyHandle.stop(true);
|
||||
this.lobbyHandle = null;
|
||||
this.currentUrl = null;
|
||||
|
||||
try {
|
||||
@@ -884,6 +888,21 @@ class Client {
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ export abstract class BaseModal extends LitElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
public isOpen(): boolean {
|
||||
return this.isModalOpen;
|
||||
}
|
||||
|
||||
protected firstUpdated(): void {
|
||||
if (this.modalEl) {
|
||||
this.modalEl.onClose = () => {
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
|
||||
const VIDEO_AD_UNIT_TYPE = "precontent_ad_video";
|
||||
|
||||
@customElement("video-ad")
|
||||
export class VideoAd extends LitElement {
|
||||
@state()
|
||||
private isVisible: boolean = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
onComplete?: () => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
onMidpoint?: () => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
onAdBlocked?: () => void;
|
||||
|
||||
private adLoadTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private rampCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private rampWaitTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private adStarted = false;
|
||||
|
||||
// How long to wait for ad to start before assuming it's blocked
|
||||
private static readonly AD_LOAD_TIMEOUT_MS = 8000;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Set dimensions on the custom element itself (required by Playwire)
|
||||
// Playwire requires explicit pixel dimensions, use max-width for responsiveness
|
||||
this.style.display = "block";
|
||||
this.style.width = "100%";
|
||||
this.style.maxWidth = "800px";
|
||||
this.style.aspectRatio = "16/9";
|
||||
this.showVideoAd();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
// Clean up timeout if component is removed
|
||||
if (this.adLoadTimeout) {
|
||||
clearTimeout(this.adLoadTimeout);
|
||||
this.adLoadTimeout = null;
|
||||
}
|
||||
if (this.rampCheckInterval) {
|
||||
clearInterval(this.rampCheckInterval);
|
||||
this.rampCheckInterval = null;
|
||||
}
|
||||
if (this.rampWaitTimeout) {
|
||||
clearTimeout(this.rampWaitTimeout);
|
||||
this.rampWaitTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
public showVideoAd(): void {
|
||||
if (!window.ramp) {
|
||||
// Wait for ramp to be available, but give up after timeout
|
||||
this.rampCheckInterval = setInterval(() => {
|
||||
if (window.ramp && window.ramp.que) {
|
||||
if (this.rampCheckInterval) {
|
||||
clearInterval(this.rampCheckInterval);
|
||||
this.rampCheckInterval = null;
|
||||
}
|
||||
if (this.rampWaitTimeout) {
|
||||
clearTimeout(this.rampWaitTimeout);
|
||||
this.rampWaitTimeout = null;
|
||||
}
|
||||
this.loadVideoAd();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Stop polling after timeout (e.g. adblocker preventing ramp from loading)
|
||||
this.rampWaitTimeout = setTimeout(() => {
|
||||
if (this.rampCheckInterval) {
|
||||
clearInterval(this.rampCheckInterval);
|
||||
this.rampCheckInterval = null;
|
||||
}
|
||||
console.log("[VideoAd] Ramp SDK never loaded - possible adblocker");
|
||||
this.handleAdBlocked();
|
||||
}, VideoAd.AD_LOAD_TIMEOUT_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadVideoAd();
|
||||
}
|
||||
|
||||
private loadVideoAd(): void {
|
||||
// Start timeout to detect if ad doesn't load (e.g., due to adblocker)
|
||||
this.adLoadTimeout = setTimeout(() => {
|
||||
if (!this.adStarted) {
|
||||
console.log("[VideoAd] Ad load timeout - possible adblocker detected");
|
||||
this.handleAdBlocked();
|
||||
}
|
||||
}, VideoAd.AD_LOAD_TIMEOUT_MS);
|
||||
|
||||
// Set up event listeners when player is ready, chaining any existing handler
|
||||
const prevOnPlayerReady = window.ramp.onPlayerReady;
|
||||
window.ramp.onPlayerReady = () => {
|
||||
if (prevOnPlayerReady) prevOnPlayerReady();
|
||||
if (window.Bolt) {
|
||||
// Listen for ad start to know ad is loading successfully
|
||||
window.Bolt.on(
|
||||
VIDEO_AD_UNIT_TYPE,
|
||||
window.Bolt.BOLT_AD_STARTED ?? "boltAdStarted",
|
||||
() => {
|
||||
console.log("[VideoAd] Ad started");
|
||||
this.adStarted = true;
|
||||
// Clear the timeout since ad is playing
|
||||
if (this.adLoadTimeout) {
|
||||
clearTimeout(this.adLoadTimeout);
|
||||
this.adLoadTimeout = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_AD_COMPLETE, () => {
|
||||
console.log("[VideoAd] Ad completed");
|
||||
this.hideElement();
|
||||
});
|
||||
|
||||
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_AD_ERROR, () => {
|
||||
console.log("[VideoAd] Ad error/no fill");
|
||||
this.handleAdBlocked();
|
||||
});
|
||||
|
||||
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_MIDPOINT, () => {
|
||||
console.log("[VideoAd] Ad midpoint");
|
||||
if (this.onMidpoint) {
|
||||
this.onMidpoint();
|
||||
}
|
||||
});
|
||||
|
||||
window.Bolt.on(
|
||||
VIDEO_AD_UNIT_TYPE,
|
||||
window.Bolt.SHOW_HIDDEN_CONTAINER ?? "showHiddenContainer",
|
||||
() => {
|
||||
console.log("[VideoAd] Ad finished");
|
||||
this.hideElement();
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Queue the video ad initialization
|
||||
window.ramp.que.push(() => {
|
||||
const pwUnits = [{ type: VIDEO_AD_UNIT_TYPE }];
|
||||
|
||||
window.ramp
|
||||
.addUnits(pwUnits)
|
||||
.then(() => {
|
||||
window.ramp.displayUnits();
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
console.error("[VideoAd] Error adding units:", e);
|
||||
window.ramp.displayUnits();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private handleAdBlocked(): void {
|
||||
// Clear timeout if still pending
|
||||
if (this.adLoadTimeout) {
|
||||
clearTimeout(this.adLoadTimeout);
|
||||
this.adLoadTimeout = null;
|
||||
}
|
||||
|
||||
// Call the callback if provided
|
||||
if (this.onAdBlocked) {
|
||||
this.onAdBlocked();
|
||||
}
|
||||
}
|
||||
|
||||
private hideElement(): void {
|
||||
this.style.display = "none";
|
||||
this.isVisible = false;
|
||||
// Call the callback if provided
|
||||
if (this.onComplete) {
|
||||
this.onComplete();
|
||||
}
|
||||
// Also dispatch event for backwards compatibility
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("ad-complete", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
// Provide a container for the Playwire video player to render into
|
||||
// Structure matches Playwire example: wrapper > game-video-ad > precontent-video-location
|
||||
return html`
|
||||
<div
|
||||
class="game-video-ad"
|
||||
style="width: 100%; height: 100%; overflow: hidden;"
|
||||
>
|
||||
<div
|
||||
id="precontent-video-location"
|
||||
style="width: 100%; height: 100%;"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { GameLeftSidebar } from "./layers/GameLeftSidebar";
|
||||
import { GameRightSidebar } from "./layers/GameRightSidebar";
|
||||
import { HeadsUpMessage } from "./layers/HeadsUpMessage";
|
||||
import { ImmunityTimer } from "./layers/ImmunityTimer";
|
||||
import { InGameHeaderAd } from "./layers/InGameHeaderAd";
|
||||
import { InGamePromo } from "./layers/InGamePromo";
|
||||
import { Layer } from "./layers/Layer";
|
||||
import { Leaderboard } from "./layers/Leaderboard";
|
||||
import { MainRadialMenu } from "./layers/MainRadialMenu";
|
||||
@@ -36,7 +36,6 @@ import { ReplayPanel } from "./layers/ReplayPanel";
|
||||
import { SAMRadiusLayer } from "./layers/SAMRadiusLayer";
|
||||
import { SettingsModal } from "./layers/SettingsModal";
|
||||
import { SpawnTimer } from "./layers/SpawnTimer";
|
||||
import { SpawnVideoAd } from "./layers/SpawnVideoReward";
|
||||
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
|
||||
import { StructureLayer } from "./layers/StructureLayer";
|
||||
import { TeamStats } from "./layers/TeamStats";
|
||||
@@ -262,19 +261,11 @@ export function createRenderer(
|
||||
immunityTimer.game = game;
|
||||
immunityTimer.eventBus = eventBus;
|
||||
|
||||
const inGameHeaderAd = document.querySelector(
|
||||
"in-game-header-ad",
|
||||
) as InGameHeaderAd;
|
||||
if (!(inGameHeaderAd instanceof InGameHeaderAd)) {
|
||||
console.error("in-game header ad not found");
|
||||
const inGamePromo = document.querySelector("in-game-promo") as InGamePromo;
|
||||
if (!(inGamePromo instanceof InGamePromo)) {
|
||||
console.error("in-game promo not found");
|
||||
}
|
||||
inGameHeaderAd.game = game;
|
||||
|
||||
const spawnVideoAd = document.querySelector("spawn-video-ad") as SpawnVideoAd;
|
||||
if (!(spawnVideoAd instanceof SpawnVideoAd)) {
|
||||
console.error("spawn video ad not found");
|
||||
}
|
||||
spawnVideoAd.game = game;
|
||||
inGamePromo.game = game;
|
||||
|
||||
// When updating these layers please be mindful of the order.
|
||||
// Try to group layers by the return value of shouldTransform.
|
||||
@@ -321,8 +312,7 @@ export function createRenderer(
|
||||
playerPanel,
|
||||
headsUpMessage,
|
||||
multiTabModal,
|
||||
inGameHeaderAd,
|
||||
spawnVideoAd,
|
||||
inGamePromo,
|
||||
alertFrame,
|
||||
performanceOverlay,
|
||||
];
|
||||
|
||||
@@ -222,7 +222,7 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
return this.incomingAttacks.map(
|
||||
(attack) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<span class="inline-flex items-center"
|
||||
@@ -269,7 +269,7 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
return this.outgoingAttacks.map(
|
||||
(attack) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<span class="inline-flex items-center"
|
||||
@@ -310,7 +310,7 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
return this.outgoingLandAttacks.map(
|
||||
(landAttack) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<span class="inline-flex items-center"
|
||||
@@ -365,7 +365,7 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
return this.outgoingBoats.map(
|
||||
(boat) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`${this.renderBoatIcon(boat)}
|
||||
@@ -401,7 +401,7 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
return this.incomingBoats.map(
|
||||
(boat) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`${this.renderBoatIcon(boat)}
|
||||
|
||||
@@ -809,7 +809,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
`,
|
||||
onClick: this.toggleHidden,
|
||||
className:
|
||||
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg sm:rounded-tl-lg bg-gray-800/70 backdrop-blur-xs",
|
||||
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg sm:rounded-tl-lg bg-gray-800/92 backdrop-blur-sm",
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
@@ -820,7 +820,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
>
|
||||
<!-- Button Bar -->
|
||||
<div
|
||||
class="w-full p-2 lg:p-3 bg-gray-800/70 sm:rounded-tl-lg min-[1200px]:rounded-t-lg"
|
||||
class="w-full p-2 lg:p-3 bg-gray-800/92 backdrop-blur-sm sm:rounded-tl-lg min-[1200px]:rounded-t-lg"
|
||||
>
|
||||
<div class="flex justify-between items-center gap-3">
|
||||
<div class="flex gap-4">
|
||||
@@ -864,7 +864,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
|
||||
<!-- Content Area -->
|
||||
<div
|
||||
class="bg-gray-800/70 max-h-[15vh] lg:max-h-[30vh] overflow-y-auto w-full h-full min-[1200px]:rounded-b-xl events-container"
|
||||
class="bg-gray-800/92 backdrop-blur-sm max-h-[15vh] lg:max-h-[30vh] overflow-y-auto w-full h-full min-[1200px]:rounded-b-xl events-container"
|
||||
>
|
||||
<div>
|
||||
<table
|
||||
|
||||
@@ -102,7 +102,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
|
||||
render() {
|
||||
return html`
|
||||
<aside
|
||||
class=${`fixed top-0 min-[1200px]:top-4 left-0 min-[1200px]:left-4 z-900 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-br-lg ${this.isLeaderboardShow || this.isTeamLeaderboardShow ? "max-[400px]:w-full max-[400px]:rounded-none" : ""} transition-all duration-300 ease-out transform ${
|
||||
class=${`fixed top-0 min-[1200px]:top-4 left-0 min-[1200px]:left-4 z-900 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-br-lg ${this.isLeaderboardShow || this.isTeamLeaderboardShow ? "max-[400px]:w-full max-[400px]:rounded-none" : ""} transition-all duration-300 ease-out transform ${
|
||||
this.isVisible ? "translate-x-0" : "hidden"
|
||||
}`}
|
||||
style="margin-top: ${this.barOffset}px;"
|
||||
|
||||
@@ -184,7 +184,7 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<aside
|
||||
class=${`w-fit flex flex-row items-center gap-3 py-2 px-3 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-bl-lg transition-transform duration-300 ease-out transform text-white ${
|
||||
class=${`w-fit flex flex-row items-center gap-3 py-2 px-3 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-bl-lg transition-transform duration-300 ease-out transform text-white ${
|
||||
this._isVisible ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
@contextmenu=${(e: Event) => e.preventDefault()}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
const AD_SHOW_TICKS = 10 * 60 * 10; // 2 minutes
|
||||
const HEADER_AD_TYPE = "standard_iab_head1";
|
||||
const HEADER_AD_CONTAINER_ID = "header-ad-container";
|
||||
const TWO_XL_BREAKPOINT = 1536;
|
||||
|
||||
@customElement("in-game-header-ad")
|
||||
export class InGameHeaderAd extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
|
||||
private isHidden: boolean = false;
|
||||
private adLoaded: boolean = false;
|
||||
private shouldShow: boolean = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
init() {
|
||||
// TODO: move ad and re-enable.
|
||||
// this.showHeaderAd();
|
||||
}
|
||||
|
||||
private showHeaderAd(): void {
|
||||
// Don't show header ad on screens smaller than 2xl
|
||||
if (window.innerWidth < TWO_XL_BREAKPOINT) {
|
||||
return;
|
||||
}
|
||||
if (!window.adsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldShow = true;
|
||||
this.requestUpdate();
|
||||
|
||||
// Wait for the element to render before loading the ad
|
||||
this.updateComplete.then(() => {
|
||||
this.loadAd();
|
||||
});
|
||||
}
|
||||
|
||||
private loadAd(): void {
|
||||
if (!window.ramp) {
|
||||
console.warn("Playwire RAMP not available for header ad");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.ramp.que.push(() => {
|
||||
try {
|
||||
window.ramp.spaAddAds([
|
||||
{
|
||||
type: HEADER_AD_TYPE,
|
||||
selectorId: HEADER_AD_CONTAINER_ID,
|
||||
},
|
||||
]);
|
||||
this.adLoaded = true;
|
||||
console.log("Header ad loaded:", HEADER_AD_TYPE);
|
||||
} catch (e) {
|
||||
console.error("Failed to add header ad:", e);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load header ad:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private hideHeaderAd(): void {
|
||||
this.shouldShow = false;
|
||||
this.adLoaded = false;
|
||||
try {
|
||||
window.ramp.destroyUnits(HEADER_AD_TYPE);
|
||||
console.log("successfully destroyed in game header ad");
|
||||
} catch (e) {
|
||||
console.error("error destroying in game header ad", e);
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public tick() {
|
||||
if (this.isHidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gameTicks =
|
||||
this.game.ticks() - this.game.config().numSpawnPhaseTurns();
|
||||
if (gameTicks > AD_SHOW_TICKS) {
|
||||
console.log("destroying header ad and refreshing PageOS");
|
||||
this.hideHeaderAd();
|
||||
this.isHidden = true;
|
||||
|
||||
if (window.PageOS?.session?.newPageView) {
|
||||
window.PageOS.session.newPageView();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.shouldShow) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
id="${HEADER_AD_CONTAINER_ID}"
|
||||
class="hidden 2xl:flex fixed top-0 left-1/2 -translate-x-1/2 z-[100] justify-center items-center pointer-events-auto p-0 -mt-[20px]"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
const AD_TYPE = "standard_iab_left1";
|
||||
const AD_CONTAINER_ID = "in-game-bottom-left-ad";
|
||||
const BOTTOM_RAIL_TYPE = "bottom_rail";
|
||||
|
||||
@customElement("in-game-promo")
|
||||
export class InGamePromo extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
|
||||
private shouldShow: boolean = false;
|
||||
private bottomRailActive: boolean = false;
|
||||
private cornerAdShown: boolean = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.showBottomRail();
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (!this.game.inSpawnPhase()) {
|
||||
if (this.bottomRailActive) {
|
||||
this.destroyBottomRail();
|
||||
}
|
||||
if (!this.cornerAdShown) {
|
||||
this.cornerAdShown = true;
|
||||
this.showAd();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private showBottomRail(): void {
|
||||
if (!window.adsEnabled) return;
|
||||
if (!this.game.inSpawnPhase()) return;
|
||||
if (!window.ramp) {
|
||||
console.warn("Playwire RAMP not available for bottom_rail ad");
|
||||
return;
|
||||
}
|
||||
|
||||
this.bottomRailActive = true;
|
||||
try {
|
||||
window.ramp.que.push(() => {
|
||||
try {
|
||||
window.ramp.spaAddAds([{ type: BOTTOM_RAIL_TYPE }]);
|
||||
console.log("Bottom rail ad loaded during spawn phase");
|
||||
} catch (e) {
|
||||
console.error("Failed to add bottom_rail ad:", e);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load bottom_rail ad:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private destroyBottomRail(): void {
|
||||
if (!this.bottomRailActive) return;
|
||||
this.bottomRailActive = false;
|
||||
|
||||
if (!window.ramp) return;
|
||||
|
||||
try {
|
||||
window.ramp.spaAds({ ads: [], countPageview: false });
|
||||
console.log("Bottom rail ad destroyed via spaAds after spawn phase");
|
||||
} catch (e) {
|
||||
console.error("Error destroying bottom_rail ad:", e);
|
||||
}
|
||||
}
|
||||
|
||||
private showAd(): void {
|
||||
if (!window.adsEnabled) return;
|
||||
if (window.innerWidth < 1100) return;
|
||||
if (window.innerHeight < 750) return;
|
||||
|
||||
this.shouldShow = true;
|
||||
this.requestUpdate();
|
||||
|
||||
this.updateComplete.then(() => {
|
||||
this.loadAd();
|
||||
});
|
||||
}
|
||||
|
||||
private loadAd(): void {
|
||||
if (!window.ramp) {
|
||||
console.warn("Playwire RAMP not available for in-game ad");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.ramp.que.push(() => {
|
||||
try {
|
||||
window.ramp.spaAddAds([
|
||||
{
|
||||
type: AD_TYPE,
|
||||
selectorId: AD_CONTAINER_ID,
|
||||
},
|
||||
]);
|
||||
console.log("In-game bottom-left ad loaded:", AD_TYPE);
|
||||
} catch (e) {
|
||||
console.error("Failed to add in-game ad:", e);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load in-game ad:", error);
|
||||
}
|
||||
}
|
||||
|
||||
public hideAd(): void {
|
||||
this.destroyBottomRail();
|
||||
if (!window.ramp) {
|
||||
console.warn("Playwire RAMP not available for in-game ad");
|
||||
return;
|
||||
}
|
||||
this.shouldShow = false;
|
||||
try {
|
||||
window.ramp.destroyUnits(AD_TYPE);
|
||||
console.log("successfully destroyed in-game bottom-left ad");
|
||||
} catch (e) {
|
||||
console.error("error destroying in-game ad:", e);
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.shouldShow) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
id="${AD_CONTAINER_ID}"
|
||||
class="fixed left-0 z-[100] pointer-events-auto"
|
||||
style="bottom: -0.7cm"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -524,7 +524,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
class="bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg text-white text-lg lg:text-base w-full sm:w-[500px] overflow-hidden ${containerClasses}"
|
||||
class="bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg text-white text-lg lg:text-base w-full sm:w-[500px] overflow-hidden ${containerClasses}"
|
||||
>
|
||||
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
|
||||
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
|
||||
|
||||
@@ -75,7 +75,7 @@ export class ReplayPanel extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="p-2 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-l-lg"
|
||||
class="p-2 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-l-lg"
|
||||
@contextmenu=${(e: Event) => e.preventDefault()}
|
||||
>
|
||||
<label class="block mb-2 text-white" translate="no">
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { crazyGamesSDK } from "src/client/CrazyGamesSDK";
|
||||
import { Platform } from "src/client/Platform";
|
||||
import { getGamesPlayed } from "src/client/Utils";
|
||||
import { GameType } from "src/core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import "../../components/VideoPromo";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@customElement("spawn-video-ad")
|
||||
export class SpawnVideoAd extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
|
||||
@state() private shouldShow = false;
|
||||
@state() private adComplete = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (
|
||||
!window.adsEnabled ||
|
||||
Platform.isMobileWidth ||
|
||||
crazyGamesSDK.isOnCrazyGames() ||
|
||||
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
|
||||
getGamesPlayed() < 3 // Don't show to new players
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.shouldShow = true;
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (this.adComplete) return;
|
||||
// Hide when spawn phase ends
|
||||
if (this.shouldShow && !this.game.inSpawnPhase()) {
|
||||
this.shouldShow = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private handleComplete = () => {
|
||||
this.adComplete = true;
|
||||
this.shouldShow = false;
|
||||
};
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.shouldShow || this.adComplete) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="fixed bottom-0 left-0 z-[9999] pointer-events-auto">
|
||||
<video-ad
|
||||
style="width: 400px; max-width: 400px; height: 225px; aspect-ratio: auto;"
|
||||
.onComplete="${this.handleComplete}"
|
||||
></video-ad>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,14 @@ import { ClientID } from "../core/Schemas";
|
||||
|
||||
const INTENTS_PER_SECOND = 10;
|
||||
const INTENTS_PER_MINUTE = 150;
|
||||
const MAX_BYTES_PER_MINUTE = 25 * 1024; // 25KB/min per client
|
||||
const MAX_INTENT_BYTES = 500; // intents are stored in turns, keep them small
|
||||
const MAX_INTENT_SIZE = 500;
|
||||
const TOTAL_BYTES = 2 * 1024 * 1024; // 2MB per client
|
||||
export type RateLimitResult = "ok" | "limit" | "kick";
|
||||
|
||||
// Allow 3 winner messages per client since a player can rejoin and resend.
|
||||
const MAX_WINNER_MSGS = 3;
|
||||
|
||||
interface ClientBucket {
|
||||
perSecond: RateLimiter;
|
||||
perMinute: RateLimiter;
|
||||
bytesPerMinute: RateLimiter;
|
||||
winnerMsgCount: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export class ClientMsgRateLimiter {
|
||||
@@ -22,27 +18,27 @@ export class ClientMsgRateLimiter {
|
||||
|
||||
check(clientID: ClientID, type: string, bytes: number): RateLimitResult {
|
||||
const bucket = this.getOrCreate(clientID);
|
||||
bucket.totalBytes += bytes;
|
||||
|
||||
// Winner message contains stats for all players and can be large (100s of KB).
|
||||
// It bypasses the byte rate limit but is strictly limited to one per client.
|
||||
if (type === "winner") {
|
||||
if (bucket.winnerMsgCount >= MAX_WINNER_MSGS) return "kick";
|
||||
bucket.winnerMsgCount++;
|
||||
return "ok";
|
||||
if (bucket.totalBytes >= TOTAL_BYTES) return "kick";
|
||||
|
||||
if (type === "intent") {
|
||||
// Intents are stored in turn history for the duration of the game, so
|
||||
// oversized intents would accumulate and fill up server RAM.
|
||||
// Intents are also sent to all players, so it increase outgoing
|
||||
// data.
|
||||
// Intents should never be larger than MAX_INTENT_SIZE, so we assume the client is malicious.
|
||||
if (bytes > MAX_INTENT_SIZE) {
|
||||
return "kick";
|
||||
}
|
||||
if (
|
||||
!bucket.perSecond.tryRemoveTokens(1) ||
|
||||
!bucket.perMinute.tryRemoveTokens(1)
|
||||
) {
|
||||
return "limit";
|
||||
}
|
||||
}
|
||||
|
||||
// Intents are stored in turn history for the duration of the game, so
|
||||
// oversized intents would accumulate and fill up server RAM.
|
||||
if (type === "intent" && bytes > MAX_INTENT_BYTES) return "kick";
|
||||
|
||||
if (!bucket.bytesPerMinute.tryRemoveTokens(bytes)) return "kick";
|
||||
|
||||
if (
|
||||
!bucket.perSecond.tryRemoveTokens(1) ||
|
||||
!bucket.perMinute.tryRemoveTokens(1)
|
||||
)
|
||||
return "limit";
|
||||
|
||||
return "ok";
|
||||
}
|
||||
|
||||
@@ -60,11 +56,7 @@ export class ClientMsgRateLimiter {
|
||||
tokensPerInterval: INTENTS_PER_MINUTE,
|
||||
interval: "minute",
|
||||
}),
|
||||
bytesPerMinute: new RateLimiter({
|
||||
tokensPerInterval: MAX_BYTES_PER_MINUTE,
|
||||
interval: "minute",
|
||||
}),
|
||||
winnerMsgCount: 0,
|
||||
totalBytes: 0,
|
||||
};
|
||||
this.buckets.set(clientID, bucket);
|
||||
return bucket;
|
||||
|
||||
+25
-1
@@ -3,6 +3,7 @@ import express, { NextFunction, Request, Response } from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import http from "http";
|
||||
import ipAnonymize from "ip-anonymize";
|
||||
import { RateLimiter } from "limiter";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
@@ -50,7 +51,7 @@ export async function startWorker() {
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: 2 * 1024 * 1024,
|
||||
maxPayload: 1024 * 1024, // 1MB
|
||||
});
|
||||
|
||||
const gm = new GameManager(config, log);
|
||||
@@ -289,6 +290,11 @@ export async function startWorker() {
|
||||
: // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
forwarded || req.socket.remoteAddress || "unknown";
|
||||
|
||||
if (!getWsIpLimiter(ip).tryRemoveTokens(1)) {
|
||||
ws.close(1008, "Rate limit exceeded");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse and handle client messages
|
||||
const parsed = ClientMessageSchema.safeParse(
|
||||
@@ -610,3 +616,21 @@ function generateGameIdForWorker(): GameID | null {
|
||||
log.warn(`Failed to generate game ID for worker ${workerId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Per-IP rate limiter for pre-join WebSocket messages.
|
||||
// Prevents unauthenticated connections from spamming messages
|
||||
// (e.g. pings) before joining a game.
|
||||
const wsIpLimiters = new Map<string, RateLimiter>();
|
||||
function getWsIpLimiter(ip: string): RateLimiter {
|
||||
let limiter = wsIpLimiters.get(ip);
|
||||
if (!limiter) {
|
||||
limiter = new RateLimiter({
|
||||
tokensPerInterval: 5,
|
||||
interval: "second",
|
||||
});
|
||||
wsIpLimiters.set(ip, limiter);
|
||||
}
|
||||
return limiter;
|
||||
}
|
||||
// Clean up stale IP limiters every 10 minutes
|
||||
setInterval(() => wsIpLimiters.clear(), 10 * 60 * 1000);
|
||||
|
||||
@@ -5,7 +5,6 @@ const CLIENT_A = "clientA" as any;
|
||||
const CLIENT_B = "clientB" as any;
|
||||
|
||||
const SMALL = 100;
|
||||
const LARGE = 501; // over MAX_INTENT_BYTES
|
||||
|
||||
describe("ClientMsgRateLimiter", () => {
|
||||
describe("intent messages", () => {
|
||||
@@ -14,11 +13,6 @@ describe("ClientMsgRateLimiter", () => {
|
||||
expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok");
|
||||
});
|
||||
|
||||
it("kicks on oversized intent", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
expect(limiter.check(CLIENT_A, "intent", LARGE)).toBe("kick");
|
||||
});
|
||||
|
||||
it("limits when per-second count exceeded", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -36,34 +30,46 @@ describe("ClientMsgRateLimiter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("winner messages", () => {
|
||||
it("allows first winner message", () => {
|
||||
describe("non-intent messages", () => {
|
||||
it("does not rate-limit non-intent messages", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok");
|
||||
for (let i = 0; i < 20; i++) {
|
||||
expect(limiter.check(CLIENT_A, "winner", 50)).toBe("ok");
|
||||
}
|
||||
});
|
||||
|
||||
it("allows up to 3 winner messages", () => {
|
||||
it("does not rate-limit ping messages", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok");
|
||||
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok");
|
||||
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok");
|
||||
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("kick");
|
||||
});
|
||||
|
||||
it("winner does not consume intent rate limit", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
limiter.check(CLIENT_A, "winner", 50000);
|
||||
expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok");
|
||||
for (let i = 0; i < 20; i++) {
|
||||
expect(limiter.check(CLIENT_A, "ping", 50)).toBe("ok");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("other messages", () => {
|
||||
it("applies rate limiting to other message types", () => {
|
||||
describe("total bytes limit", () => {
|
||||
it("kicks when cumulative bytes reach 2MB", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(limiter.check(CLIENT_A, "ping", 50)).toBe("ok");
|
||||
const chunkSize = 512 * 1024; // 512KB
|
||||
// Send 3 chunks = 1.5MB, should be ok
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(limiter.check(CLIENT_A, "other", chunkSize)).toBe("ok");
|
||||
}
|
||||
expect(limiter.check(CLIENT_A, "ping", 50)).toBe("limit");
|
||||
// 4th chunk pushes to 2MB, should kick
|
||||
expect(limiter.check(CLIENT_A, "other", chunkSize)).toBe("kick");
|
||||
});
|
||||
|
||||
it("byte tracking is per client", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
const almostFull = 2 * 1024 * 1024 - 1;
|
||||
expect(limiter.check(CLIENT_A, "other", almostFull)).toBe("ok");
|
||||
// CLIENT_B should still be fine
|
||||
expect(limiter.check(CLIENT_B, "other", 100)).toBe("ok");
|
||||
});
|
||||
|
||||
it("kicks on bytes regardless of message type", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
const twoMB = 2 * 1024 * 1024;
|
||||
expect(limiter.check(CLIENT_A, "intent", twoMB)).toBe("kick");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user