diff --git a/src/client/MultiTabDetector.ts b/src/client/MultiTabDetector.ts index bd9db4c5f..7b98965c6 100644 --- a/src/client/MultiTabDetector.ts +++ b/src/client/MultiTabDetector.ts @@ -1,130 +1,108 @@ 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 readonly tabId = `${Date.now()}-${Math.random()}`; + private readonly lockKey = "multi-tab-lock"; + private readonly heartbeatIntervalMs = 1_000; + private readonly staleThresholdMs = 3_000; - private numPunishmentsGiven = 0; + private heartbeatTimer: number | null = null; + private isPunished = false; + private punishmentCount = 0; + private startPenaltyCallback: (duration: number) => void = () => {}; + + constructor() { + window.addEventListener("storage", this.onStorageEvent.bind(this)); + window.addEventListener("beforeunload", this.onBeforeUnload.bind(this)); + } - /** - * 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), + this.writeLock(); + this.heartbeatTimer = window.setInterval( + () => this.heartbeat(), + this.heartbeatIntervalMs, ); } 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); + if (this.heartbeatTimer !== null) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; } - } - 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); + const lock = this.readLock(); + if (lock?.owner === this.tabId) { + localStorage.removeItem(this.lockKey); } + window.removeEventListener("storage", this.onStorageEvent.bind(this)); + window.removeEventListener("beforeunload", this.onBeforeUnload.bind(this)); } - private recordFocusChange(timestamp: number): void { - if (Math.abs(this.lastFocusChangeTime - timestamp) < 100) { - // Don't count multiple triggers at same time + private heartbeat(): void { + const now = Date.now(); + const lock = this.readLock(); + + if ( + !lock || + lock.owner === this.tabId || + now - lock.timestamp > this.staleThresholdMs + ) { + this.writeLock(); + this.isPunished = false; 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(); + if (!this.isPunished) { + this.applyPunishment(); } } - 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) { + private onStorageEvent(e: StorageEvent): void { + if (e.key === this.lockKey && e.newValue) { + let other: { owner: string; timestamp: number }; + try { + other = JSON.parse(e.newValue); + } catch (e) { + console.error("Failed to parse lock", e); + return; + } + if (other.owner !== this.tabId && !this.isPunished) { this.applyPunishment(); } } } + private onBeforeUnload(): void { + const lock = this.readLock(); + if (lock?.owner === this.tabId) { + localStorage.removeItem(this.lockKey); + } + } + 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 + this.punishmentCount++; + const delay = 10_000; + this.startPenaltyCallback(delay); setTimeout(() => { this.isPunished = false; - }, punishmentDelay); + }, delay); + } + + private writeLock(): void { + localStorage.setItem( + this.lockKey, + JSON.stringify({ owner: this.tabId, timestamp: Date.now() }), + ); + } + + private readLock(): { owner: string; timestamp: number } | null { + const raw = localStorage.getItem(this.lockKey); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch (e) { + console.error("Failed to parse lock", raw, e); + return null; + } } } diff --git a/src/client/graphics/layers/MultiTabModal.ts b/src/client/graphics/layers/MultiTabModal.ts index 6855cfbda..0fce859b0 100644 --- a/src/client/graphics/layers/MultiTabModal.ts +++ b/src/client/graphics/layers/MultiTabModal.ts @@ -15,6 +15,9 @@ export class MultiTabModal extends LitElement implements Layer { @property({ type: Number }) duration: number = 5000; @state() private countdown: number = 5; @state() private isVisible: boolean = false; + @state() private fakeIp: string = ""; + @state() private deviceFingerprint: string = ""; + @state() private reported: boolean = true; private intervalId?: number; @@ -38,6 +41,26 @@ export class MultiTabModal extends LitElement implements Layer { } } + init() { + this.fakeIp = this.generateFakeIp(); + this.deviceFingerprint = this.generateDeviceFingerprint(); + this.reported = true; + } + + // Generate fake IP in format xxx.xxx.xxx.xxx + private generateFakeIp(): string { + return Array.from({ length: 4 }, () => + Math.floor(Math.random() * 255), + ).join("."); + } + + // Generate fake device fingerprint (32 character hex) + private generateDeviceFingerprint(): string { + return Array.from({ length: 32 }, () => + Math.floor(Math.random() * 16).toString(16), + ).join(""); + } + // Show the modal with penalty information public show(duration: number): void { if (!this.game.myPlayer()?.isAlive()) { @@ -98,14 +121,44 @@ export class MultiTabModal extends LitElement implements Layer {
${translateText("multi_tab.detected")}
+${translateText("multi_tab.please_wait")} ${this.countdown} @@ -124,6 +177,10 @@ export class MultiTabModal extends LitElement implements Layer {
${translateText("multi_tab.explanation")}
+ ++ Repeated violations may result in permanent account suspension. +