Merge branch 'v30'

This commit is contained in:
evanpelle
2026-03-18 19:29:42 -07:00
24 changed files with 415 additions and 625 deletions
+2 -3
View File
@@ -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

+7 -7
View File
@@ -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

+29 -20
View File
@@ -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
View File
@@ -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();
}
+4
View File
@@ -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 = () => {
-213
View File
@@ -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>
`;
}
}
+6 -16
View File
@@ -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,
];
+5 -5
View File
@@ -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)}
+3 -3
View File
@@ -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>
`;
}
}
+146
View File
@@ -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) : ""}
+1 -1
View File
@@ -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>
`;
}
}
+22 -30
View File
@@ -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
View File
@@ -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);
+31 -25
View File
@@ -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");
});
});
});