diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6eb7c411c..98b945676 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,7 +8,6 @@ Describe the PR. - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory - [ ] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced -- [ ] I have read and accepted the CLA agreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: diff --git a/.github/workflows/pr-description.yml b/.github/workflows/pr-description.yml index d108aaab2..941b931df 100644 --- a/.github/workflows/pr-description.yml +++ b/.github/workflows/pr-description.yml @@ -44,7 +44,6 @@ jobs: /- \[x\] I process any text displayed to the user through translateText\(\) and I\'ve added it to the en\.json file/i, /- \[x\] I have added relevant tests to the test directory/i, /- \[x\] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced/i, - /- \[x\] I have read and accepted the CLA agreement \(only required once\)\./i ]; for (const box of requiredBoxes) { diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..6be85b2f6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +*.[tj]s diff --git a/Dockerfile b/Dockerfile index 364ed9b04..8aaf57b8d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,7 +46,7 @@ ENV NPM_CONFIG_IGNORE_SCRIPTS=1 # Copy package.json and package-lock.json COPY package*.json ./ # Install dependencies -RUN npm ci --omit=dev +RUN npm ci # Final image FROM base diff --git a/eslint-plugin-local/plugin.js b/eslint-plugin-local/plugin.js new file mode 100644 index 000000000..f8b86efa4 --- /dev/null +++ b/eslint-plugin-local/plugin.js @@ -0,0 +1,7 @@ +import noZArray from "./rules/no-z-array.js"; + +export default { + rules: { + "no-z-array": noZArray, + }, +}; diff --git a/eslint-plugin-local/rules/no-z-array.js b/eslint-plugin-local/rules/no-z-array.js new file mode 100644 index 000000000..c45016f79 --- /dev/null +++ b/eslint-plugin-local/rules/no-z-array.js @@ -0,0 +1,35 @@ +export default { + create(context) { + return { + CallExpression(node) { + if ( + node.callee.type === "MemberExpression" && + node.callee.object.name === "z" && + node.callee.property.name === "array" && + node.arguments.length === 1 + ) { + const argSource = context.sourceCode.getText(node.arguments[0]); + context.report({ + data: { type: argSource }, + fix(fixer) { + return fixer.replaceText(node, `${argSource}.array()`); + }, + messageId: "noZArray", + node, + }); + } + }, + }; + }, + meta: { + docs: { + description: "Disallow z.array(type) in favor of type.array()", + }, + fixable: "code", + messages: { + noZArray: "Use `{{type}}.array()` instead of `z.array({{type}})`.", + }, + schema: [], + type: "suggestion", + }, +}; diff --git a/eslint.config.js b/eslint.config.js index 2984c0ac7..79471768f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,10 +1,12 @@ -import { includeIgnoreFile } from "@eslint/compat"; -import pluginJs from "@eslint/js"; import eslintConfigPrettier from "eslint-config-prettier/flat"; import globals from "globals"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import pluginJs from "@eslint/js"; +import stylistic from "@stylistic/eslint-plugin"; import tseslint from "typescript-eslint"; +import { fileURLToPath } from "node:url"; +import { includeIgnoreFile } from "@eslint/compat"; +import eslintPluginLocal from "./eslint-plugin-local/plugin.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -25,6 +27,8 @@ export default [ projectService: { allowDefaultProject: [ "__mocks__/fileMock.js", + "eslint-plugin-local/plugin.js", + "eslint-plugin-local/rules/no-z-array.js", "eslint.config.js", "jest.config.ts", "postcss.config.js", @@ -39,28 +43,119 @@ export default [ { rules: { // Disable rules that would fail. The failures should be fixed, and the entries here removed. - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-expressions": "off", - "@typescript-eslint/no-unused-vars": "off", - "no-case-declarations": "off", + "@typescript-eslint/no-unused-expressions": "off", // https://github.com/openfrontio/OpenFrontIO/issues/1790 + "no-case-declarations": "off", // https://github.com/openfrontio/OpenFrontIO/issues/1791 }, }, { + plugins: { + "@stylistic": stylistic, + }, rules: { // Enable rules + // '@stylistic/quotes': ['error', 'single'], TODO: Enable this rule, https://github.com/openfrontio/OpenFrontIO/issues/1788 + "@stylistic/indent": ["error", 2], + "@stylistic/semi": "error", + "@stylistic/space-infix-ops": "error", + "@stylistic/type-annotation-spacing": [ + "error", + { + after: true, + before: true, + overrides: { + colon: { + before: false, + }, + }, + }, + ], + "@stylistic/eol-last": "error", + "@typescript-eslint/consistent-type-definitions": [ + "error", + "type", + ], + "@typescript-eslint/no-duplicate-enum-values": "error", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-inferrable-types": "error", + "@typescript-eslint/no-mixed-enums": "error", + "@typescript-eslint/no-require-imports": "error", + "@typescript-eslint/no-unnecessary-type-assertion": "error", + "@typescript-eslint/prefer-as-const": "error", + "@typescript-eslint/prefer-function-type": "error", + "@typescript-eslint/prefer-includes": "error", + "@typescript-eslint/prefer-literal-enum-member": "error", "@typescript-eslint/prefer-nullish-coalescing": "error", - eqeqeq: "error", + "eqeqeq": "error", + "indent": "off", // @stylistic/indent "sort-keys": "error", + "@typescript-eslint/no-unsafe-argument": "error", + "@typescript-eslint/no-unsafe-assignment": "error", + "@typescript-eslint/no-unsafe-member-access": "error", + // "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], // TODO: Enable this rule, https://github.com/openfrontio/OpenFrontIO/issues/1784 + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/prefer-for-of": "error", + "array-bracket-newline": ["error", "consistent"], + "array-bracket-spacing": ["error", "never"], + "array-element-newline": ["error", "consistent"], + "arrow-parens": ["error", "always"], + "comma-dangle": ["error", "always-multiline"], + "comma-spacing": ["error", { before: false, after: true }], + "func-call-spacing": ["error", "never"], + "function-call-argument-newline": ["error", "consistent"], + "max-depth": ["error", { max: 5 }], + // "max-len": ["error", { code: 120 }], // TODO: Enable this rule, https://github.com/openfrontio/OpenFrontIO/issues/1785 + "max-lines": ["error", { max: 1065, skipBlankLines: true, skipComments: true }], + "max-lines-per-function": ["error", { max: 561 }], + "no-loss-of-precision": "error", + "no-multi-spaces": "error", + "no-trailing-spaces": "error", + "object-curly-newline": ["error", { multiline: true, consistent: true }], + "object-curly-spacing": ["error", "always"], + "object-property-newline": ["error", { allowAllPropertiesOnSameLine: true }], + // "no-undef": "error", // TODO: Enable this rule, https://github.com/openfrontio/OpenFrontIO/issues/1786 + "no-unused-vars": "off", // @typescript-eslint/no-unused-vars + "quote-props": ["error", "consistent-as-needed"], + // 'sort-imports': 'error', // TODO: Enable this rule, https://github.com/openfrontio/OpenFrontIO/issues/1787 + "space-before-blocks": ["error", "always"], + "space-before-function-paren": ["error", { + anonymous: "always", + named: "never", + asyncArrow: "always", + }], + "space-infix-ops": "off", }, }, { files: [ "**/*.config.{js,ts,jsx,tsx}", "**/*.test.{js,ts,jsx,tsx}", - "src/client/**/*.{js,ts,jsx,tsx}", + "tests/**/*.{js,ts,jsx,tsx}", + "eslint-plugin-local/**/*.{js,ts,jsx,tsx}", ], rules: { + // Disabled rules for tests, configs + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-member-access": "off", "sort-keys": "off", }, }, + { + files: [ + "src/client/**/*.{js,ts,jsx,tsx}", + ], + rules: { + // Disabled rules for frontend + "sort-keys": "off", + }, + }, + { + plugins: { + local: eslintPluginLocal, + }, + rules: { + "local/no-z-array": "error", + }, + }, ]; diff --git a/package-lock.json b/package-lock.json index 36f478588..2612dafad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,9 @@ "@datastructures-js/priority-queue": "^6.3.3", "@eslint/compat": "^1.2.7", "@eslint/js": "^9.21.0", + "@stylistic/eslint-plugin": "^5.2.3", "@swc/jest": "^0.2.39", + "@total-typescript/ts-reset": "^0.6.1", "@types/benchmark": "^2.1.5", "@types/chai": "^4.3.17", "@types/d3": "^7.4.3", @@ -6358,6 +6360,54 @@ "node": ">=18.0.0" } }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.2.3.tgz", + "integrity": "sha512-oY7GVkJGVMI5benlBDCaRrSC1qPasafyv5dOBLLv5MTilMGnErKhO6ziEfodDDIZbo5QxPUNW360VudJOFODMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/types": "^8.38.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz", + "integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@swc/core": { "version": "1.13.3", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.3.tgz", @@ -6604,6 +6654,13 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@total-typescript/ts-reset": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.6.1.tgz", + "integrity": "sha512-cka47fVSo6lfQDIATYqb/vO1nvFfbPw7uWLayIXIhGETj0wcOOlrlkobOMDNQOFr9QOafegUPq13V2+6vtD7yg==", + "dev": true, + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", diff --git a/package.json b/package.json index fda1d731c..0d1603dea 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "@datastructures-js/priority-queue": "^6.3.3", "@eslint/compat": "^1.2.7", "@eslint/js": "^9.21.0", + "@stylistic/eslint-plugin": "^5.2.3", "@swc/jest": "^0.2.39", + "@total-typescript/ts-reset": "^0.6.1", "@types/benchmark": "^2.1.5", "@types/chai": "^4.3.17", "@types/d3": "^7.4.3", diff --git a/resources/lang/en.json b/resources/lang/en.json index 103f0f579..34eec63f9 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -130,7 +130,9 @@ "disable_nations": "Disable Nations", "instant_build": "Instant build", "infinite_gold": "Infinite gold", + "donate_gold": "Donate gold", "infinite_troops": "Infinite troops", + "donate_troops": "Donate troops", "disable_nukes": "Disable Nukes", "enables_title": "Enable Settings", "start": "Start Game" @@ -212,7 +214,9 @@ "disable_nations": "Disable Nations", "instant_build": "Instant build", "infinite_gold": "Infinite gold", + "donate_gold": "Donate gold", "infinite_troops": "Infinite troops", + "donate_troops": "Donate troops", "enables_title": "Enable Settings", "player": "Player", "players": "Players", diff --git a/resources/maps/oceania/manifest.json b/resources/maps/oceania/manifest.json index ce5147d81..b46541a84 100644 --- a/resources/maps/oceania/manifest.json +++ b/resources/maps/oceania/manifest.json @@ -23,12 +23,6 @@ "name": "New Zealand", "strength": 1 }, - { - "coordinates": [686, 407], - "flag": "pg", - "name": "Papua New Guinea", - "strength": 1 - }, { "coordinates": [436, 407], "flag": "tl", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 6d073fd30..b96cd8257 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -47,7 +47,7 @@ import { import { createCanvas } from "./Utils"; import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; -export interface LobbyConfig { +export type LobbyConfig = { serverConfig: ServerConfig; pattern: string | undefined; flag: string; @@ -59,7 +59,7 @@ export interface LobbyConfig { gameStartInfo?: GameStartInfo; // GameRecord exists when replaying an archived game. gameRecord?: GameRecord; -} +}; export function joinLobby( eventBus: EventBus, @@ -192,7 +192,7 @@ export class ClientGameRunner { private lastMousePosition: { x: number; y: number } | null = null; - private lastMessageTime: number = 0; + private lastMessageTime = 0; private connectionCheckInterval: NodeJS.Timeout | null = null; constructor( @@ -229,7 +229,7 @@ export class ClientGameRunner { players, // Not saving turns locally [], - startTime(), + startTime() ?? 0, Date.now(), update.winner, this.lobby.serverConfig, @@ -364,7 +364,7 @@ export class ClientGameRunner { this.transport.connect(onconnect, onmessage); } - public stop(saveFullGame: boolean = false) { + public stop(saveFullGame = false) { if (!this.isActive) return; this.isActive = false; @@ -420,7 +420,7 @@ export class ClientGameRunner { const owner = this.gameView.owner(tile); if (owner.isPlayer()) { - this.gameView.setFocusedPlayer(owner as PlayerView); + this.gameView.setFocusedPlayer(owner); } else { this.gameView.setFocusedPlayer(null); } @@ -623,7 +623,7 @@ export class ClientGameRunner { if (this.gameView.isLand(tile)) { const owner = this.gameView.owner(tile); if (owner.isPlayer()) { - this.gameView.setFocusedPlayer(owner as PlayerView); + this.gameView.setFocusedPlayer(owner); } else { this.gameView.setFocusedPlayer(null); } @@ -637,7 +637,7 @@ export class ClientGameRunner { .sort((a, b) => a.distSquared - b.distSquared); if (units.length > 0) { - this.gameView.setFocusedPlayer(units[0].unit.owner() as PlayerView); + this.gameView.setFocusedPlayer(units[0].unit.owner()); } else { this.gameView.setFocusedPlayer(null); } diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index bfd991c4a..1d5389f57 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -1,4 +1,8 @@ -import { UserMeResponse } from "../core/ApiSchemas"; +import { z } from "zod"; +import { + StripeCreateCheckoutSessionResponseSchema, + UserMeResponse, +} from "../core/ApiSchemas"; import { Cosmetics, CosmeticsSchema, Pattern } from "../core/CosmeticSchemas"; import { getApiBase, getAuthHeader } from "./jwt"; @@ -37,7 +41,7 @@ export async function handlePurchase(priceId: string) { method: "POST", headers: { "Content-Type": "application/json", - authorization: getAuthHeader(), + "authorization": getAuthHeader(), }, body: JSON.stringify({ priceId: priceId, @@ -59,7 +63,15 @@ export async function handlePurchase(priceId: string) { return; } - const { url } = await response.json(); + const json = await response.json(); + const parsed = StripeCreateCheckoutSessionResponseSchema.safeParse(json); + if (!parsed.success) { + const error = z.prettifyError(parsed.error); + console.error("Invalid checkout session response:", error); + alert("Checkout failed. Please try again later."); + return; + } + const { url } = parsed.data; // Redirect to Stripe checkout window.location.href = url; diff --git a/src/client/FlagInput.ts b/src/client/FlagInput.ts index 584253700..aeb1f0493 100644 --- a/src/client/FlagInput.ts +++ b/src/client/FlagInput.ts @@ -1,11 +1,11 @@ import { LitElement, css, html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { renderPlayerFlag } from "../core/CustomFlag"; -const flagKey: string = "flag"; +const flagKey = "flag"; @customElement("flag-input") export class FlagInput extends LitElement { - @state() public flag: string = ""; + @state() public flag = ""; static styles = css` @media (max-width: 768px) { diff --git a/src/client/FlagInputModal.ts b/src/client/FlagInputModal.ts index 86e413677..eb4b65ad3 100644 --- a/src/client/FlagInputModal.ts +++ b/src/client/FlagInputModal.ts @@ -9,7 +9,7 @@ export class FlagInputModal extends LitElement { close: () => void; }; - @state() private search: string = ""; + @state() private search = ""; createRenderRoot() { return this; diff --git a/src/client/GoogleAdElement.ts b/src/client/GoogleAdElement.ts index 8d86e47fa..2d22d33bc 100644 --- a/src/client/GoogleAdElement.ts +++ b/src/client/GoogleAdElement.ts @@ -2,6 +2,7 @@ import { LitElement, css, html } from "lit"; import { customElement, property } from "lit/decorators.js"; declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Window { adsbygoogle: unknown[]; } @@ -108,7 +109,7 @@ const isElectron = () => { if ( typeof navigator === "object" && typeof navigator.userAgent === "string" && - navigator.userAgent.indexOf("Electron") >= 0 + navigator.userAgent.includes("Electron") ) { return true; } diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 8b5e256e3..d684939b6 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -18,6 +18,7 @@ import { ClientInfo, GameConfig, GameInfo, + GameInfoSchema, TeamCountConfig, } from "../core/Schemas"; import { generateID } from "../core/Util"; @@ -39,17 +40,19 @@ export class HostLobbyModal extends LitElement { @state() private disableNPCs = false; @state() private gameMode: GameMode = GameMode.FFA; @state() private teamCount: TeamCountConfig = 2; - @state() private bots: number = 400; - @state() private infiniteGold: boolean = false; - @state() private infiniteTroops: boolean = false; - @state() private instantBuild: boolean = false; + @state() private bots = 400; + @state() private infiniteGold = false; + @state() private donateGold = false; + @state() private infiniteTroops = false; + @state() private donateTroops = false; + @state() private instantBuild = false; @state() private lobbyId = ""; @state() private copySuccess = false; @state() private clients: ClientInfo[] = []; - @state() private useRandomMap: boolean = false; + @state() private useRandomMap = false; @state() private disabledUnits: UnitType[] = []; - @state() private lobbyCreatorClientID: string = ""; - @state() private lobbyIdVisible: boolean = true; + @state() private lobbyCreatorClientID = ""; + @state() private lobbyIdVisible = true; private playersInterval: NodeJS.Timeout | null = null; // Add a new timer for debouncing bot changes @@ -293,8 +296,8 @@ export class HostLobbyModal extends LitElement { ${typeof o === "string" ? translateText(`public_lobby.teams_${o}`) : translateText("public_lobby.teams", { - num: o, - })} + num: o, + })} `, @@ -362,6 +365,38 @@ export class HostLobbyModal extends LitElement { + + + +