Merge branch 'main' into meta3AI. remove underline at 90.

This commit is contained in:
1brucben
2025-04-25 21:33:51 +02:00
562 changed files with 2766 additions and 8269 deletions
+77 -99
View File
@@ -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;
}
}
}
+1
View File
@@ -7,6 +7,7 @@ import { getMapsImage } from "../utilities/Maps";
export const MapDescription: Record<keyof typeof GameMapType, string> = {
World: "World",
Europe: "Europe",
EuropeClassic: "Europe Classic",
Mena: "MENA",
NorthAmerica: "North America",
Oceania: "Oceania",
+63 -4
View File
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { GameEnv } from "../../../core/configuration/Config";
import { GameType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { MultiTabDetector } from "../../MultiTabDetector";
@@ -15,6 +16,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;
@@ -26,7 +30,8 @@ export class MultiTabModal extends LitElement implements Layer {
tick() {
if (
this.game.inSpawnPhase() ||
this.game.config().gameConfig().gameType == GameType.Singleplayer
this.game.config().gameConfig().gameType == GameType.Singleplayer ||
this.game.config().serverConfig().env() == GameEnv.Dev
) {
return;
}
@@ -38,6 +43,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 +123,44 @@ export class MultiTabModal extends LitElement implements Layer {
<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>
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold text-red-600 dark:text-red-400">
${translateText("multi_tab.warning")}
</h2>
<div
class="px-2 py-1 bg-red-600 text-white text-xs font-bold rounded-full animate-pulse"
>
RECORDING
</div>
</div>
<p class="mb-4 text-gray-800 dark:text-gray-200">
${translateText("multi_tab.detected")}
</p>
<div
class="mb-4 p-3 bg-gray-100 dark:bg-gray-900 rounded-md text-sm font-mono"
>
<div class="flex justify-between mb-1">
<span class="text-gray-500 dark:text-gray-400">IP:</span>
<span class="text-red-600 dark:text-red-400">${this.fakeIp}</span>
</div>
<div class="flex justify-between mb-1">
<span class="text-gray-500 dark:text-gray-400"
>Device Fingerprint:</span
>
<span class="text-red-600 dark:text-red-400"
>${this.deviceFingerprint}</span
>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Reported:</span>
<span class="text-red-600 dark:text-red-400"
>${this.reported ? "TRUE" : "FALSE"}</span
>
</div>
</div>
<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>
@@ -124,6 +179,10 @@ export class MultiTabModal extends LitElement implements Layer {
<p class="text-sm text-gray-600 dark:text-gray-400">
${translateText("multi_tab.explanation")}
</p>
<p class="mt-3 text-xs text-red-500 font-semibold">
Repeated violations may result in permanent account suspension.
</p>
</div>
</div>
`;
+4 -7
View File
@@ -214,6 +214,8 @@ export class NameLayer implements Layer {
troopsDiv.style.marginTop = "-5%";
element.appendChild(troopsDiv);
// TODO: enable this for new meta.
const shieldDiv = document.createElement("div");
shieldDiv.classList.add("player-shield");
shieldDiv.style.zIndex = "3";
@@ -307,13 +309,8 @@ export class NameLayer implements Layer {
shieldNumber.style.fontSize = `${render.fontSize * 0.6}px`;
shieldNumber.style.marginTop = `${-render.fontSize * 0.1}px`;
shieldNumber.textContent = density;
if (parseFloat(density) > 90) {
shieldNumber.style.textDecoration = "underline";
shieldNumber.style.fontWeight = "bold";
} else {
shieldNumber.style.textDecoration = "none";
shieldNumber.style.fontWeight = "normal";
}
shieldNumber.style.textDecoration = "none";
shieldNumber.style.fontWeight = "normal";
}
// Handle icons
+3 -1
View File
@@ -184,7 +184,9 @@ export class PlayerPanel extends LitElement implements Layer {
let other = this.g.owner(this.tile);
if (!other.isPlayer()) {
throw new Error("Tile is not owned by a player");
this.hide();
console.warn("Tile is not owned by a player");
return;
}
other = other as PlayerView;
+6 -3
View File
@@ -350,9 +350,12 @@ export class RadialMenu implements Layer {
actions: PlayerActions,
tile: TileRef,
) {
this.activateMenuElement(Slot.Build, "#ebe250", buildIcon, () => {
this.buildMenu.showMenu(tile);
});
if (!this.g.inSpawnPhase()) {
this.activateMenuElement(Slot.Build, "#ebe250", buildIcon, () => {
this.buildMenu.showMenu(tile);
});
}
if (this.g.hasOwner(tile)) {
this.activateMenuElement(Slot.Info, "#64748B", infoIcon, () => {
this.playerPanel.show(actions, tile);
+7 -2
View File
@@ -428,8 +428,13 @@ export class UnitLayer implements Layer {
let rel = this.relationship(unit);
if (unit.type() == UnitType.TradeShip && unit.dstPortId() != null) {
const target = this.game.unit(unit.dstPortId())?.owner();
if (this.game.myPlayer() != null && this.game.myPlayer() == target) {
rel = Relationship.Self;
const myPlayer = this.game.myPlayer();
if (myPlayer != null && target != null) {
if (myPlayer == target) {
rel = Relationship.Self;
} else if (myPlayer.isFriendly(target)) {
rel = Relationship.Ally;
}
}
}
switch (rel) {
+5 -3
View File
@@ -208,8 +208,6 @@
</header>
<div class="bg-image"></div>
<random-name-button></random-name-button>
<dark-mode-button></dark-mode-button>
<!-- Main container with responsive padding -->
<main class="flex justify-center flex-grow">
@@ -321,7 +319,11 @@
>
Wiki
</a>
<a target="_blank" href="https://discord.gg/openfront" class="t-link">
<a
target="_blank"
href="https://discord.gg/jRpxXvG42t"
class="t-link"
>
<span data-i18n="main.join_discord"> Join the Discord! </span>
</a>
</div>
+3
View File
@@ -4,6 +4,7 @@ import australia from "../../../resources/maps/AustraliaThumb.webp";
import betweenTwoSeas from "../../../resources/maps/BetweenTwoSeasThumb.webp";
import blackSea from "../../../resources/maps/BlackSeaThumb.webp";
import britannia from "../../../resources/maps/BritanniaThumb.webp";
import europeClassic from "../../../resources/maps/EuropeClassicThumb.webp";
import europe from "../../../resources/maps/EuropeThumb.webp";
import faroeislands from "../../../resources/maps/FaroeIslandsThumb.webp";
import gatewayToTheAtlantic from "../../../resources/maps/GatewayToTheAtlanticThumb.webp";
@@ -28,6 +29,8 @@ export function getMapsImage(map: GameMapType): string {
return oceania;
case GameMapType.Europe:
return europe;
case GameMapType.EuropeClassic:
return europeClassic;
case GameMapType.Mena:
return mena;
case GameMapType.NorthAmerica:
+3 -2
View File
@@ -90,6 +90,7 @@ export abstract class DefaultServerConfig implements ServerConfig {
GameMapType.Oceania,
GameMapType.Japan, // Japan at this level because its 2/3 water
GameMapType.FaroeIslands,
GameMapType.EuropeClassic,
].includes(map)
) {
return Math.random() < 0.3 ? 50 : 25;
@@ -148,7 +149,7 @@ export class DefaultConfig implements Config {
return 0.5;
}
traitorDuration(): number {
return 15 * 10; // 15 seconds
return 30 * 10; // 30 seconds
}
spawnImmunityDuration(): Tick {
return 5 * 10;
@@ -392,7 +393,7 @@ export class DefaultConfig implements Config {
return 80;
}
boatMaxNumber(): number {
return 9;
return 3;
}
numSpawnPhaseTurns(): number {
return this._gameConfig.gameType == GameType.Singleplayer ? 100 : 300;
@@ -26,7 +26,6 @@ export class TransportShipExecution implements Execution {
private mg: Game;
private attacker: Player;
private target: Player | TerraNullius;
private embarkDelay = 0;
// TODO make private
public path: TileRef[];
@@ -155,10 +154,6 @@ export class TransportShipExecution implements Execution {
this.active = false;
return;
}
if (this.embarkDelay > 0) {
this.embarkDelay--;
return;
}
if (ticks - this.lastMove < this.ticksPerMove) {
return;
}
+2
View File
@@ -51,6 +51,7 @@ export enum Team {
export enum GameMapType {
World = "World",
Europe = "Europe",
EuropeClassic = "Europe Classic",
Mena = "Mena",
NorthAmerica = "North America",
SouthAmerica = "South America",
@@ -76,6 +77,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.NorthAmerica,
GameMapType.SouthAmerica,
GameMapType.Europe,
GameMapType.EuropeClassic,
GameMapType.Asia,
GameMapType.Africa,
GameMapType.Oceania,
+4 -2
View File
@@ -102,13 +102,15 @@ export class GameImpl implements Game {
if (_config.gameConfig().gameMode === GameMode.Team) {
const numPlayerTeams = _config.numPlayerTeams();
if (numPlayerTeams < 2) throw new Error("Too few teams!");
if (numPlayerTeams < 2)
throw new Error(`Too few teams: ${numPlayerTeams}`);
if (numPlayerTeams >= 3) this.playerTeams.push(Team.Teal);
if (numPlayerTeams >= 4) this.playerTeams.push(Team.Purple);
if (numPlayerTeams >= 5) this.playerTeams.push(Team.Yellow);
if (numPlayerTeams >= 6) this.playerTeams.push(Team.Orange);
if (numPlayerTeams >= 7) this.playerTeams.push(Team.Green);
if (numPlayerTeams >= 8) throw new Error("Too many teams!");
if (numPlayerTeams >= 8)
throw new Error(`Too many teams: ${numPlayerTeams}`);
}
}
+5 -1
View File
@@ -277,7 +277,11 @@ export class GameMapImpl implements GameMap {
): Set<TileRef> {
const seen = new Set<TileRef>();
const q: TileRef[] = [];
q.push(tile);
if (filter(this, tile)) {
seen.add(tile);
q.push(tile);
}
while (q.length > 0) {
const curr = q.pop();
for (const n of this.neighbors(curr)) {
+1
View File
@@ -42,6 +42,7 @@ const MAP_FILE_NAMES: Record<GameMapType, string> = {
[GameMapType.BetweenTwoSeas]: "BetweenTwoSeas",
[GameMapType.KnownWorld]: "KnownWorld",
[GameMapType.FaroeIslands]: "FaroeIslands",
[GameMapType.EuropeClassic]: "EuropeClassic",
};
class GameMapLoader {
+1
View File
@@ -9,6 +9,7 @@ const maps = [
"WorldMap",
"BlackSea",
"Europe",
"EuropeClassic",
"Mars",
"Mena",
"Oceania",
+3
View File
@@ -95,6 +95,9 @@ export class GameServer {
if (gameConfig.gameMode != null) {
this.gameConfig.gameMode = gameConfig.gameMode;
}
if (gameConfig.numPlayerTeams != null) {
this.gameConfig.numPlayerTeams = gameConfig.numPlayerTeams;
}
}
public addClient(client: Client, lastTurn: number) {
+1
View File
@@ -94,6 +94,7 @@ export class MapPlaylist {
case PlaylistType.SmallMaps:
return {
World: 4,
EuropeClassic: 3,
Mena: 2,
Pangaea: 1,
Asia: 1,
+1
View File
@@ -165,6 +165,7 @@ export function startWorker() {
disableNPCs: req.body.disableNPCs,
disableNukes: req.body.disableNukes,
gameMode: req.body.gameMode,
numPlayerTeams: req.body.numPlayerTeams,
});
res.status(200).json({ success: true });
}),