mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:50:43 +00:00
detect and punish multi-tabbing
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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`
|
||||
<div
|
||||
class="fixed inset-0 z-50 overflow-auto bg-red-500/20 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="relative p-6 bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full m-4 transition-all transform"
|
||||
>
|
||||
<h2 class="text-2xl font-bold mb-4 text-red-600 dark:text-red-400">
|
||||
${translateText("multi_tab.warning")}
|
||||
</h2>
|
||||
|
||||
<p class="mb-4 text-gray-800 dark:text-gray-200">
|
||||
${translateText("multi_tab.detected")}
|
||||
</p>
|
||||
|
||||
<p class="mb-4 text-gray-800 dark:text-gray-200">
|
||||
${translateText("multi_tab.please_wait")}
|
||||
<span class="font-bold text-xl">${this.countdown}</span>
|
||||
${translateText("multi_tab.seconds")}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5 mb-4"
|
||||
>
|
||||
<div
|
||||
class="bg-red-600 dark:bg-red-500 h-2.5 rounded-full transition-all duration-1000 ease-linear"
|
||||
style="width: ${(this.countdown / (this.duration / 1000)) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
${translateText("multi_tab.explanation")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -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");
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="h-full select-none font-sans min-h-screen bg-opacity-0 bg-cover bg-center bg-fixed transition-opacity duration-300 ease-in-out flex flex-col"
|
||||
>
|
||||
@@ -353,6 +358,7 @@
|
||||
<help-modal></help-modal>
|
||||
<dark-mode-button></dark-mode-button>
|
||||
<user-setting></user-setting>
|
||||
<multi-tab-modal></multi-tab-modal>
|
||||
<div
|
||||
id="language-modal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
|
||||
|
||||
Reference in New Issue
Block a user