diff --git a/.oxlintrc.json b/.oxlintrc.json index d30c21f1cd..f6130f5ca8 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -6,10 +6,12 @@ }, "ignorePatterns": [ "src/server/gatekeeper/**", - "tests/pathfinding/playground/**" + "tests/pathfinding/playground/**", + ".claude/**" ], + "plugins": ["eslint", "typescript", "oxc", "import"], "categories": { - "correctness": "off", + "correctness": "error", "suspicious": "off", "pedantic": "off", "perf": "off", @@ -22,9 +24,19 @@ }, "rules": { "typescript/no-explicit-any": "off", + "typescript/no-floating-promises": "off", + "typescript/restrict-template-expressions": "off", + "typescript/unbound-method": "off", + "typescript/no-base-to-string": "off", + "typescript/no-misused-spread": "off", + "typescript/require-array-sort-compare": "off", + "typescript/no-redundant-type-constituents": "off", + "typescript/await-thenable": "off", + "typescript/no-useless-default-assignment": "off", "typescript/prefer-nullish-coalescing": "error", "eqeqeq": "error", "no-case-declarations": "error", + "import/no-duplicates": "error", "no-unused-vars": [ "error", { @@ -36,5 +48,123 @@ } } ] - } + }, + "jsPlugins": [{ "name": "eslint-js", "specifier": "oxlint-plugin-eslint" }], + "overrides": [ + { + "files": [ + "packages/engine/src/**/*.ts", + "packages/engine-api/src/**/*.ts", + "packages/engine-lib/src/**/*.ts" + ], + "excludeFiles": ["packages/engine-lib/src/DetMath.ts"], + "rules": { + "eslint/no-restricted-properties": [ + "error", + { + "object": "Math", + "property": "exp", + "message": "Math.exp differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "expm1", + "message": "Math.expm1 differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "log", + "message": "Math.log differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "log1p", + "message": "Math.log1p differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "log2", + "message": "Math.log2 differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "log10", + "message": "Math.log10 differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "pow", + "message": "Math.pow differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "sin", + "message": "Math.sin differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "cos", + "message": "Math.cos differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "tan", + "message": "Math.tan differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "asin", + "message": "Math.asin differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "acos", + "message": "Math.acos differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "atan", + "message": "Math.atan differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "atan2", + "message": "Math.atan2 differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "sinh", + "message": "Math.sinh differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "cosh", + "message": "Math.cosh differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "tanh", + "message": "Math.tanh differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "cbrt", + "message": "Math.cbrt differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + }, + { + "object": "Math", + "property": "hypot", + "message": "Math.hypot differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + } + ], + "eslint-js/no-restricted-syntax": [ + "error", + { + "selector": "BinaryExpression[operator='**']:not([right.type='Literal'][right.value=2])", + "message": "`**` with a non-2 exponent differs between JS engines; use packages/engine-lib/src/DetMath.ts to keep the simulation deterministic." + } + ] + } + } + ] } diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 0cb1f7f7c5..259899e708 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -36,6 +36,8 @@ import { CopyButton } from "./components/CopyButton"; import "./components/GameConfigSettings"; import "./components/InputCard"; import "./components/InsufficientCurrencyDialog"; +// The type import below is erased at runtime; keep this to register the dialog. +// oxlint-disable-next-line import/no-duplicates -- Required custom-element registration. import "./components/ListLobbyDialog"; import { ListLobbyOptions } from "./components/ListLobbyDialog"; import "./components/LobbyPlayerView"; diff --git a/src/client/InGameModal.ts b/src/client/InGameModal.ts index d92adbece2..0371da0970 100644 --- a/src/client/InGameModal.ts +++ b/src/client/InGameModal.ts @@ -1,3 +1,5 @@ +// The type-only use below is erased at runtime; keep this to register the dialog. +// oxlint-disable-next-line import/no-duplicates -- Required custom-element registration. import "./components/ConfirmDialog"; import { ConfirmDialog } from "./components/ConfirmDialog"; import { gamePortal } from "./GamePortal"; diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 1bc4d74a83..1f5f14dc9a 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -2,6 +2,8 @@ import { assetUrl } from "@openfront/shared/AssetUrls"; import { LitElement, html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { desktopSteamLocale } from "./DesktopShell"; +// The type-only use below is erased at runtime; keep this to register the modal. +// oxlint-disable-next-line import/no-duplicates -- Required custom-element registration. import "./LanguageModal"; import { LanguageModal } from "./LanguageModal"; import { formatDebugTranslation } from "./Utils"; diff --git a/src/client/Main.ts b/src/client/Main.ts index 49fec5bebb..512e3b5d76 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -67,7 +67,6 @@ import { } from "./DesktopShell"; import "./FeaturedStream"; import { loadGameClient, prefetchGameClient } from "./GameClientLoader"; -import "./GameModeSelector"; import { GameModeSelector, joinIsGateable, @@ -83,6 +82,8 @@ import type { HomepagePromos } from "./HomepagePromos"; import type { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal"; import { showInGameAlert, showInGameConfirm } from "./InGameModal"; import type { JoinLobbyModal } from "./JoinLobbyModal"; +// The LangSelector class is used as a type; retain this for registration. +// oxlint-disable-next-line import/no-duplicates -- Required custom-element registration. import "./LangSelector"; import { LangSelector } from "./LangSelector"; import { initLayout } from "./Layout"; @@ -152,8 +153,10 @@ import { // Before the first userMeResponse, for the modals that load after it. import "./UserMeBroadcast"; import { classicReplayHref } from "./replay/ReplayEntry"; -import "./UsernameInput"; import { parseReplayViewerHash } from "./replay/ReplayViewerRoute"; +// The UsernameInput class is used as a type; retain this for registration. +// oxlint-disable-next-line import/no-duplicates -- Required custom-element registration. +import "./UsernameInput"; import { UsernameInput } from "./UsernameInput"; import { UserSettings } from "./UserSettings"; import "./components/BannedModal"; diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 9206edd18c..c030852f34 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -22,7 +22,6 @@ import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import "./components/GameConfigSettings"; -import "./components/SavedGamesPanel"; import { MEDAL_ORDER, medalIcon } from "./components/map/Medals"; import { type LoadSavedGameDetail, diff --git a/src/client/Store.ts b/src/client/Store.ts index 82ca3408e2..7adb8326c8 100644 --- a/src/client/Store.ts +++ b/src/client/Store.ts @@ -23,7 +23,6 @@ import type { InlineCheckout } from "./components/InlineCheckout"; import "./components/NotLoggedInWarning"; import "./components/PackContentsDialog"; import { ProgressiveList } from "./components/ProgressiveList"; -import "./components/PurchaseButton"; import { alignPurchaseRows } from "./components/PurchaseButton"; import "./components/TribesPanel"; import { modalHeader } from "./components/ui/ModalHeader"; diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 84e7866515..4b1b0ab6e7 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -2,9 +2,13 @@ import type { MapLayer } from "@openfront/engine-api/game/MapFiles"; import { html, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; import { formatKeyForDisplay, translateText } from "../client/Utils"; +// This type is erased at runtime; retain the import to register the setting. +// oxlint-disable-next-line import/no-duplicates -- Required custom-element registration. import "./components/baseComponents/setting/SettingKeybind"; import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind"; import "./components/baseComponents/setting/SettingNumber"; +// This type is erased at runtime; retain the import to register the setting. +// oxlint-disable-next-line import/no-duplicates -- Required custom-element registration. import "./components/baseComponents/setting/SettingSelect"; import { SettingSelect } from "./components/baseComponents/setting/SettingSelect"; import "./components/baseComponents/setting/SettingSlider"; diff --git a/src/client/components/NavAccountMenu.ts b/src/client/components/NavAccountMenu.ts index 2751c83dee..4186463096 100644 --- a/src/client/components/NavAccountMenu.ts +++ b/src/client/components/NavAccountMenu.ts @@ -26,7 +26,6 @@ import { import { steamSDK } from "../SteamSDK"; import { playerProfileUrl } from "../utilities/PlayerProfileUrl"; import { copyToClipboard, showToast, translateText } from "../Utils"; -import "./LevelBadge"; import { levelBadgeLabel } from "./LevelBadge"; import { xpBar, xpProgressText } from "./XpBar"; diff --git a/src/client/components/ProfileCard.ts b/src/client/components/ProfileCard.ts index 61ecddfe84..cc1e579745 100644 --- a/src/client/components/ProfileCard.ts +++ b/src/client/components/ProfileCard.ts @@ -11,7 +11,6 @@ import { customElement, property, state } from "lit/decorators.js"; import { clampPrestige, levelFraction } from "../Progression"; import { prefersReducedMotion } from "../utilities/ReducedMotion"; import { translateText } from "../Utils"; -import "./LevelBadge"; import { levelBadgeAccent } from "./LevelBadge"; import { splitAccountUsername, usernameText } from "./ui/UsernameText"; import { formatXp, xpProgressText } from "./XpBar"; diff --git a/src/client/replay/ReplayViewer.ts b/src/client/replay/ReplayViewer.ts index d9cdcd5bfb..917eafa61f 100644 --- a/src/client/replay/ReplayViewer.ts +++ b/src/client/replay/ReplayViewer.ts @@ -22,7 +22,6 @@ import "../hud/layers/PlayerInfoOverlay"; import type { PlayerInfoOverlay } from "../hud/layers/PlayerInfoOverlay"; import "../hud/layers/PlayerStats"; import type { PlayerStats } from "../hud/layers/PlayerStats"; -import "../hud/layers/SettingsModal"; import { ShowSettingsModalEvent, type SettingsModal, diff --git a/tests/ColumnPicker.test.ts b/tests/ColumnPicker.test.ts index d4b3fd9ee1..1912257405 100644 --- a/tests/ColumnPicker.test.ts +++ b/tests/ColumnPicker.test.ts @@ -33,10 +33,10 @@ describe("ColumnPicker", () => { selection = (event as CustomEvent).detail; }); // Registry order: index 0 is clan, 1 is playerType, 2 is tiles, 3 is gold. - (popup?.querySelectorAll("input")[3] as HTMLInputElement).click(); + (popup!.querySelectorAll("input")[3] as HTMLInputElement).click(); expect(selection).toEqual(["tiles", "gold"]); - (popup?.querySelectorAll("input")[0] as HTMLInputElement).click(); + (popup!.querySelectorAll("input")[0] as HTMLInputElement).click(); expect(selection).toEqual(["clan", "tiles"]); sidebar.remove(); diff --git a/tests/DesktopStatusBar.test.ts b/tests/DesktopStatusBar.test.ts index bd9fff0410..57cde4b709 100644 --- a/tests/DesktopStatusBar.test.ts +++ b/tests/DesktopStatusBar.test.ts @@ -1,7 +1,6 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ClientEnv } from "../src/client/ClientEnv"; -import "../src/client/components/DesktopStatusBar"; import { barSource } from "../src/client/components/DesktopStatusBar"; import { backendUnreachableConfirmed, diff --git a/tests/ReachabilityGating.test.ts b/tests/ReachabilityGating.test.ts index fc7f6c8f50..628b42b380 100644 --- a/tests/ReachabilityGating.test.ts +++ b/tests/ReachabilityGating.test.ts @@ -7,6 +7,7 @@ import type { } from "@openfront/shared/WireSchemas"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ClientEnv } from "../src/client/ClientEnv"; +// This named import also registers as a side effect. import { shouldBlockMultiplayerAction } from "../src/client/GameModeSelector"; import { attemptInFlight, @@ -42,9 +43,6 @@ vi.mock("../src/client/LazyModals", async (importOriginal) => ({ whenModalLoaded: (_tag: string, open: () => void) => open(), })); -// Registers as a side effect. -import "../src/client/GameModeSelector"; - /** * OPE-439. The server-list heartbeat already knows whether the API answers; * this is the half that turns that into something the player can see, on the diff --git a/tests/client/EffectsGrid.test.ts b/tests/client/EffectsGrid.test.ts index 232d09d64f..f5fd947755 100644 --- a/tests/client/EffectsGrid.test.ts +++ b/tests/client/EffectsGrid.test.ts @@ -300,7 +300,7 @@ describe("EffectsGrid", () => { await card.updateComplete; card.querySelector("[data-cosmetic-main]")!.click(); - expect((onMessage.mock.lastCall?.[0] as CustomEvent).detail.message).toBe( + expect((onMessage.mock.lastCall![0] as CustomEvent).detail.message).toBe( "Selected Localized Wake", ); diff --git a/tests/client/InventoryModal.test.ts b/tests/client/InventoryModal.test.ts index 37eecc6e16..b01d417de7 100644 --- a/tests/client/InventoryModal.test.ts +++ b/tests/client/InventoryModal.test.ts @@ -525,7 +525,7 @@ describe("InventoryModal", () => { it("announces flag, crown and skin selections too", async () => { const onMessage = vi.fn(); const lastMessage = () => - (onMessage.mock.lastCall?.[0] as CustomEvent).detail.message; + (onMessage.mock.lastCall![0] as CustomEvent).detail.message; window.addEventListener("show-message", onMessage); try { await showTab(modal, "skins"); diff --git a/tests/client/ListLobbyDialog.test.ts b/tests/client/ListLobbyDialog.test.ts index 551884efdb..6c89b0cb8c 100644 --- a/tests/client/ListLobbyDialog.test.ts +++ b/tests/client/ListLobbyDialog.test.ts @@ -3,6 +3,8 @@ import { MIN_HOSTED_LOBBY_PLAYERS, } from "@openfront/shared/WireSchemas"; import { describe, expect, it } from "vitest"; +// The class is used as a type below, so this import is needed for registration. +// oxlint-disable-next-line import/no-duplicates -- Required custom-element registration. import "../../src/client/components/ListLobbyDialog"; import { ListLobbyDialog } from "../../src/client/components/ListLobbyDialog"; diff --git a/tests/client/NewsModal.test.ts b/tests/client/NewsModal.test.ts index 91cd986db5..629391eada 100644 --- a/tests/client/NewsModal.test.ts +++ b/tests/client/NewsModal.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -// Side-effect import registers ; a type-only import would be -// elided and leave the element inert. -import "../../src/client/NewsModal"; +// The named import registers as a side effect; a type-only import +// would be elided and leave the element inert. import { NewsModal } from "../../src/client/NewsModal"; beforeEach(() => { diff --git a/tests/client/clan/ClanModal.rendering.test.ts b/tests/client/clan/ClanModal.rendering.test.ts index ad81d3f967..6f893378a7 100644 --- a/tests/client/clan/ClanModal.rendering.test.ts +++ b/tests/client/clan/ClanModal.rendering.test.ts @@ -553,12 +553,12 @@ describe("ClanModal — rendering", () => { it("uses the compact card variant, not the header size", async () => { const card = await showMyClan(makeClan({ hardBalance: "25" })); const cardSize = ( - card?.querySelector("plutonium-icon") as HTMLElement & { size: number } + card!.querySelector("plutonium-icon") as HTMLElement & { size: number } ).size; const header = await openDetail(makeClan({ hardBalance: "25" })); const headerSize = ( - header?.querySelector("plutonium-icon") as HTMLElement & { + header!.querySelector("plutonium-icon") as HTMLElement & { size: number; } ).size; diff --git a/tests/client/graphics/layers/ChatModalShortcut.test.ts b/tests/client/graphics/layers/ChatModalShortcut.test.ts index ff58f4c365..87d6220b54 100644 --- a/tests/client/graphics/layers/ChatModalShortcut.test.ts +++ b/tests/client/graphics/layers/ChatModalShortcut.test.ts @@ -1,6 +1,5 @@ import { EventBus } from "@openfront/shared/EventBus"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import "../../../../src/client/hud/layers/ChatModal"; import { ShowPlayerChatEvent, type ChatModal, diff --git a/tests/client/graphics/layers/EmojiTable.test.ts b/tests/client/graphics/layers/EmojiTable.test.ts index d6418ed25d..104b17898e 100644 --- a/tests/client/graphics/layers/EmojiTable.test.ts +++ b/tests/client/graphics/layers/EmojiTable.test.ts @@ -2,7 +2,6 @@ import { AllPlayers } from "@openfront/engine-api/game/GameTypes"; import { flattenedEmojiTable } from "@openfront/engine-api/Schemas"; import { EventBus } from "@openfront/shared/EventBus"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import "../../../../src/client/hud/layers/EmojiTable"; import { type EmojiTable, ShowPlayerEmojiMenuEvent, diff --git a/tests/client/graphics/layers/SpawnTimer.test.ts b/tests/client/graphics/layers/SpawnTimer.test.ts index ac4a3f3c2e..471d0b8ef3 100644 --- a/tests/client/graphics/layers/SpawnTimer.test.ts +++ b/tests/client/graphics/layers/SpawnTimer.test.ts @@ -1,9 +1,8 @@ import { GameMode, GameType } from "@openfront/engine-api/game/GameTypes"; import { EventBus } from "@openfront/shared/EventBus"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import "../../../../src/client/hud/layers/SpawnTimer"; -import type { SpawnTimer } from "../../../../src/client/hud/layers/SpawnTimer"; import { SpawnBarVisibleEvent } from "../../../../src/client/hud/layers/SpawnTimer"; +import type { SpawnTimer } from "../../../../src/client/hud/layers/SpawnTimer"; import type { GameView } from "../../../../src/client/view"; function makePlayer(team: string | null, tiles: number) {