diff --git a/src/client/MultiTabDetector.ts b/src/client/MultiTabDetector.ts new file mode 100644 index 000000000..bd9db4c5f --- /dev/null +++ b/src/client/MultiTabDetector.ts @@ -0,0 +1,130 @@ +export class MultiTabDetector { + private focusChanges: number[] = []; + private readonly maxFocusChanges: number = 10; + private readonly timeWindow: number = 60_000; + private readonly punishmentDelays: number[] = [ + 2_000, 3_000, 5_000, 10_000, 30_000, 60_000, + ]; + private lastFocusChangeTime: number = 0; + private isPunished: boolean = false; + private isMonitoring: boolean = false; + private startPenaltyCallback?: (duration: number) => void; + + private numPunishmentsGiven = 0; + + /** + * Start monitoring for multi-tabbing behavior + * + * @param startPenalty Callback function when punishment starts + */ + public startMonitoring(startPenalty: (duration: number) => void): void { + if (this.isMonitoring) return; + + this.isMonitoring = true; + this.startPenaltyCallback = startPenalty; + + // Event listeners for window focus/blur + window.addEventListener("blur", this.handleFocusChange.bind(this)); + window.addEventListener("focus", this.handleFocusChange.bind(this)); + + // Also track visibility changes for tab switching + document.addEventListener( + "visibilitychange", + this.handleVisibilityChange.bind(this), + ); + } + + public stopMonitoring(): void { + if (!this.isMonitoring) return; + + this.isMonitoring = false; + + // Remove event listeners + window.removeEventListener("blur", this.handleFocusChange.bind(this)); + window.removeEventListener("focus", this.handleFocusChange.bind(this)); + document.removeEventListener( + "visibilitychange", + this.handleVisibilityChange.bind(this), + ); + + // Clear data + this.focusChanges = []; + this.isPunished = false; + } + + private handleFocusChange(): void { + const currentTime = Date.now(); + + this.recordFocusChange(currentTime); + + // Check for multi-tabbing when focus is gained + if (document.hasFocus() && !this.isPunished) { + this.checkForMultiTabbing(currentTime); + } + } + + private handleVisibilityChange(): void { + const currentTime = Date.now(); + + // Record and check regardless of current focus state + this.recordFocusChange(currentTime); + + // Only check when tab becomes visible + if (document.visibilityState === "visible" && !this.isPunished) { + this.checkForMultiTabbing(currentTime); + } + } + + private recordFocusChange(timestamp: number): void { + if (Math.abs(this.lastFocusChangeTime - timestamp) < 100) { + // Don't count multiple triggers at same time + return; + } + this.focusChanges.push(timestamp); + console.log(`pushing focus change at ${timestamp}`); + this.lastFocusChangeTime = timestamp; + + // Keep only recent changes + if (this.focusChanges.length > this.maxFocusChanges) { + this.focusChanges.shift(); + } + } + + private checkForMultiTabbing(currentTime: number): void { + // Only if we have enough data points + if (this.focusChanges.length >= this.maxFocusChanges) { + const oldestChange = this.focusChanges[0]; + const timeSpan = currentTime - oldestChange; + + // If changes happened within detection window + if (timeSpan <= this.timeWindow) { + this.applyPunishment(); + } + } + } + + private applyPunishment(): void { + // Prevent multiple punishments + if (this.isPunished) return; + this.isPunished = true; + + let punishmentDelay = 0; + if (this.numPunishmentsGiven >= this.punishmentDelays.length) { + punishmentDelay = this.punishmentDelays[this.punishmentDelays.length - 1]; + } else { + punishmentDelay = this.punishmentDelays[this.numPunishmentsGiven]; + } + + this.numPunishmentsGiven++; + + // Call the start penalty callback + if (this.startPenaltyCallback) { + this.startPenaltyCallback(punishmentDelay); + } + + // Remove penalty after delay + setTimeout(() => { + this.isPunished = false; + }, punishmentDelay); + } +} diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 1fe89a791..9c92a3d0e 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -12,6 +12,7 @@ import { EmojiTable } from "./layers/EmojiTable"; import { EventsDisplay } from "./layers/EventsDisplay"; import { Layer } from "./layers/Layer"; import { Leaderboard } from "./layers/Leaderboard"; +import { MultiTabModal } from "./layers/MultiTabModal"; import { NameLayer } from "./layers/NameLayer"; import { OptionsMenu } from "./layers/OptionsMenu"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; @@ -125,6 +126,14 @@ export function createRenderer( playerPanel.eventBus = eventBus; playerPanel.emojiTable = emojiTable; + const multiTabModal = document.querySelector( + "multi-tab-modal", + ) as MultiTabModal; + if (!(multiTabModal instanceof MultiTabModal)) { + console.error("multi-tab modal not found"); + } + multiTabModal.game = game; + const layers: Layer[] = [ new TerrainLayer(game, transformHandler), new TerritoryLayer(game, eventBus), @@ -153,6 +162,7 @@ export function createRenderer( optionsMenu, topBar, playerPanel, + multiTabModal, ]; return new GameRenderer( diff --git a/src/client/graphics/layers/MultiTabModal.ts b/src/client/graphics/layers/MultiTabModal.ts new file mode 100644 index 000000000..6855cfbda --- /dev/null +++ b/src/client/graphics/layers/MultiTabModal.ts @@ -0,0 +1,131 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { GameType } from "../../../core/game/Game"; +import { GameView } from "../../../core/game/GameView"; +import { MultiTabDetector } from "../../MultiTabDetector"; +import { translateText } from "../../Utils"; +import { Layer } from "./Layer"; + +@customElement("multi-tab-modal") +export class MultiTabModal extends LitElement implements Layer { + public game: GameView; + + private detector: MultiTabDetector; + + @property({ type: Number }) duration: number = 5000; + @state() private countdown: number = 5; + @state() private isVisible: boolean = false; + + private intervalId?: number; + + // Disable shadow DOM to allow Tailwind classes to work + createRenderRoot() { + return this; + } + + tick() { + if ( + this.game.inSpawnPhase() || + this.game.config().gameConfig().gameType == GameType.Singleplayer + ) { + return; + } + if (!this.detector) { + this.detector = new MultiTabDetector(); + this.detector.startMonitoring((duration: number) => { + this.show(duration); + }); + } + } + + // Show the modal with penalty information + public show(duration: number): void { + if (!this.game.myPlayer()?.isAlive()) { + return; + } + this.duration = duration; + this.countdown = Math.ceil(duration / 1000); + this.isVisible = true; + + // Start countdown timer + this.intervalId = window.setInterval(() => { + this.countdown--; + + if (this.countdown <= 0) { + this.hide(); + } + }, 1000); + + this.requestUpdate(); + } + + // Hide the modal + public hide(): void { + this.isVisible = false; + + if (this.intervalId) { + window.clearInterval(this.intervalId); + this.intervalId = undefined; + } + + // Dispatch event when modal is closed + this.dispatchEvent( + new CustomEvent("penalty-complete", { + bubbles: true, + composed: true, + }), + ); + + this.requestUpdate(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.intervalId) { + window.clearInterval(this.intervalId); + } + } + + render() { + if (!this.isVisible) { + return html``; + } + + return html` +
+
+

+ ${translateText("multi_tab.warning")} +

+ +

+ ${translateText("multi_tab.detected")} +

+ +

+ ${translateText("multi_tab.please_wait")} + ${this.countdown} + ${translateText("multi_tab.seconds")} +

+ +
+
+
+ +

+ ${translateText("multi_tab.explanation")} +

+
+
+ `; + } +} diff --git a/src/client/index.html b/src/client/index.html index 78172b5fc..f0b8810f3 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -48,8 +48,10 @@ .left-gutter-ad { position: fixed; left: 0; - top: 200px; /* Changed from top: 50% */ - transform: none; /* Removed translateY(-50%) since we don't need to center anymore */ + top: 200px; + /* Changed from top: 50% */ + transform: none; + /* Removed translateY(-50%) since we don't need to center anymore */ z-index: 40; width: 300px; height: 600px; @@ -68,8 +70,10 @@ .right-gutter-ad { position: fixed; right: 0; - top: 200px; /* Changed from top: 50% */ - transform: none; /* Removed translateY(-50%) since we don't need to center anymore */ + top: 200px; + /* Changed from top: 50% */ + transform: none; + /* Removed translateY(-50%) since we don't need to center anymore */ z-index: 40; width: 300px; height: 600px; @@ -137,6 +141,7 @@ gtag("config", "G-WQGQQ8RDN4"); + @@ -353,6 +358,7 @@ +