[
  {
    "path": ".github/copilot-instructions.md",
    "content": "# Copilot Instructions for infinitechess.org\n\n### ABOVE ALL: Follow the requirements and guidelines for pull requests found in `docs/GUIDELINES.md`!\n\nEach non-local session requires installing dependancies via `npm i --silent`. Check the working directory: if it contains Users, it's local; if it contains /home/runner/ or /github/, it's a GitHub Actions runner.\n\nBEFORE commiting any new changes, and before responding to review feedback, always ensure all workflow checks pass: `npm run lint --silent`, `npx tsc --noEmit`, and `npm test`. You must repeat each of these commands, even if you only made a minor code change since your last check to fix one of their errors.\n\n## Key Guidelines\n\n1. Follow industry standards and best code practices of today.\n2. Maintain existing code structure, organization, and consistency.\n3. Perform testing for new complex functions to ensure their output is as expected.\n4. Actual unit/integration tests are not required, unless explicitly asked for.\n5. Remember before committing changes, that all pull requests must follow the guidelines in `docs/GUIDELINES.md`.\n6. No types should ever be re-exported inside scripts. All imports of a type should reference the source.\n\n## Project Architecture\n\n- **Frontend:** TS, CSS, and assets in `src/client`. No major frameworks detected; uses vanilla and modular scripts.\n- **Backend:** Node.js server in `src/server/server.js`, with API, game logic, and socket communication.\n\n## Key Files & Directories\n\n- `src/client/` — Frontend code\n- `src/server/` — Backend code\n- `src/shared/` — Shared utilities and chess logic\n- `dev-utils/` — Depricated code. Do not maintain. It is not imported by the source code.\n- `translation/` — Localization\n\n## Conventions & Patterns\n\n- **Translations:** TOML files in `translation/` for i18n. News per locale in `translation/news/`. Any modification to the en-US.toml requires you update the version number at the top of the file, and reflect the change in `translation/changes.json`. Change notes in `changes.json` should be clear and concise, not containing more information than necessary, and always indicate the line numbers of the removed/added keys.\n- **UI Changes:** When asked to make UI changes, please verify the changes look good via the integrated browser.\n- **Rendering:** When asked to add new graphics and visuals to the game (canvas), refer to the Graphics Rendering Guide in `docs/GRAPHICS.md`.\n- When determining which imports can safely be removed, the command `npm run lint --silent` automatically tells you what imports are unused.\n\n## Integration Points\n\n- **Database:** Uses SQLite via the `better-sqlite3` package.\n- **Socket Communication:** Real-time features via `src/server/socket/`.\n\n## VS Code Tool Notes\n\n- **Rename Symbol:** To rename a symbol across all files that import it, point the rename symbol tool at the symbol's name inside a named `export { }` or `export type { }` block — this works for named exports only; `export default { }` object-style exports require manual renaming of all external call sites regardless of where the rename is applied.\n\n## Integrated Browser\n\n- **Game interaction:** The infinite chess game board & pieces are on a canvas, which contents is only visible to you in screenshots. drag_element won't work on the canvas as it requires a DOM ref. Use run_playwright_code to probe board coordinates: hover page.mouse.move(sx, sy) at candidate screen positions and read await page.locator('#x').inputValue() / await page.locator('#y').inputValue() to map screen pixels to board squares.\n\n- **Moving pieces:** Use explicit mouse.down()+mouse.up() pairs, not page.mouse.click() — the game's input loop polls isKeyDown per frame and click() is too fast. After clicking \"Start Game\" to start a local game, wait at least 2000ms before making any moves — the canvas game loop needs time to initialize.\n\n- **Reading the board position:** press Digit5 (hold down for ~200ms so the game loop detects it) to trigger a clipboard copy of the ICN position string. Intercept it via: (1) inject `window._capturedClipboard=null; const orig=navigator.clipboard.writeText.bind(navigator.clipboard); navigator.clipboard.writeText=async(t)=>{window._capturedClipboard=t;navigator.clipboard.writeText=orig;return orig(t);}` into the page before pressing the key, then (2) `await page.keyboard.down('Digit5'); await page.waitForTimeout(200); await page.keyboard.up('Digit5');`, then (3) read `await page.evaluate(()=>window._capturedClipboard)`. navigator.clipboard.readText() will fail with permission denied — do not use it.\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "### Type of Change (new feature, quality of life, bug fix, refactor, tooling, chore, tests, translation, or documentation):\n\n### Scope (what part of the website or game does it apply to):\n\n### Details of what it does (if it makes css style changes, include screenshots of the before & after):\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches:\n      - main\n      - prod\n      - distance-display\n    paths-ignore:\n      - '*.md'\n      - 'docs/**'\n      - 'LICENSE'\n  pull_request:\n    paths-ignore:\n      - '*.md'\n      - 'docs/**'\n      - 'LICENSE'\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  lint:\n    name: Lint & Format\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Check formatting\n        run: npm run format:check\n\n      - name: Check linter rules\n        run: npm run lint\n\n  type-check:\n    name: Type Check\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Run TypeScript compiler\n        run: npx tsc --noEmit\n\n  test:\n    name: Tests\n    runs-on: ubuntu-latest\n    timeout-minutes: 15\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Run tests\n        run: npm test\n\n  build:\n    name: Build\n    runs-on: ubuntu-latest\n    timeout-minutes: 15\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Run build\n        run: npm run build\n\n      - name: Upload build artifact\n        uses: actions/upload-artifact@v4\n        with:\n          name: dist\n          path: dist/\n          retention-days: 1\n\n  smoke-test:\n    name: Server Smoke Test\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n    needs: build\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Download build artifact\n        uses: actions/download-artifact@v4\n        with:\n          name: dist\n          path: dist/\n\n      - name: Smoke test server startup\n        run: |\n          node dist/server/server.js > server.log 2>&1 &\n          SERVER_PID=$!\n          sleep 5\n\n          if ! kill -0 $SERVER_PID 2>/dev/null; then\n            echo \"Server process exited prematurely. Log output:\"\n            cat server.log\n            exit 1\n          fi\n\n          response=$(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:$HTTPPORT_LOCAL/)\n          kill $SERVER_PID 2>/dev/null || true\n          wait $SERVER_PID 2>/dev/null || true\n\n          if [ \"$response\" = \"000\" ]; then\n            echo \"Health check failed (no response). Log output:\"\n            cat server.log\n            exit 1\n          fi\n          echo \"Server is healthy.\"\n        env:\n          NODE_ENV: development\n          HTTPPORT_LOCAL: 3000\n          ACCESS_TOKEN_SECRET: ci-smoke-test-placeholder\n          REFRESH_TOKEN_SECRET: ci-smoke-test-placeholder\n"
  },
  {
    "path": ".github/workflows/deploy.yml",
    "content": "name: Deploy\n\non:\n  push:\n    branches:\n      - prod\n    paths-ignore:\n      - '*.md'\n      - 'docs/**'\n      - 'LICENSE'\n  workflow_dispatch:\n\n# Only one deploy may run at a time. If a second is triggered while one is in\n# progress, it waits in the queue (cancel-in-progress: false) rather than\n# being dropped — so no deploy is ever silently skipped.\n# Deploys are rare anyway, only ever happening when code is merged into `prod`\n# or triggered manually / from HydroChess via workflow_dispatch.\nconcurrency:\n  group: deploy\n  cancel-in-progress: false\n\n# The deploy job only runs shell commands on the self-hosted machine and makes\n# no calls to the GitHub API, so no token permissions are needed.\npermissions: {}\n\njobs:\n  deploy:\n    name: Deploy to Production\n    runs-on: self-hosted\n    timeout-minutes: 15\n\n    steps:\n      # Step 1 — Pre-deploy DB backup.\n      # Calls the live server over loopback. The server validates the secret,\n      # performs a SQLite backup, and returns 200 on success.\n      # The runner aborts the deploy if the backup fails.\n      # Skipped for manual and API-triggered deploys.\n      - name: Pre-deploy DB backup\n        if: github.event_name == 'push'\n        run: |\n          response=$(curl -sk -o /dev/null -w \"%{http_code}\" \\\n            -X POST \"https://localhost:${{ vars.HTTPSPORT }}/api/prepare-restart\" \\\n            -H \"X-Restart-Secret: ${{ secrets.RESTART_SECRET }}\")\n          if [ \"$response\" != \"200\" ]; then\n            echo \"Pre-deploy backup failed (HTTP $response). Aborting deploy.\"\n            exit 1\n          fi\n          echo \"Pre-deploy backup succeeded.\"\n\n      # Step 2 — Pull and install.\n      # Skipped for manual and API-triggered deploys.\n      - name: Pull latest code and install dependencies\n        if: github.event_name == 'push'\n        working-directory: ${{ secrets.DEPLOY_DIR }}\n        run: git pull && npm ci --silent\n\n      # Step 3 — Build.\n      # Always runs. For HydroChess-triggered deploys this re-runs esbuild so\n      # the new WASM binaries (fetched at build time) are bundled into the output.\n      - name: Build\n        working-directory: ${{ secrets.DEPLOY_DIR }}\n        run: npm run build\n\n      # Step 4 — Reload.\n      # pm2 reload sends SIGINT to the current process, waits for it to exit\n      # gracefully, then starts a new process from the freshly built files.\n      # Clients whose WebSockets drop reconnect automatically within ~2.5s.\n      - name: Reload server\n        working-directory: ${{ secrets.DEPLOY_DIR }}\n        run: pm2 reload infinitechess\n\n      # Step 5 — Health check.\n      # Waits 5 seconds for the new process to start, then hits the homepage\n      # over HTTPS. -k skips hostname verification (cert is issued for the\n      # domain, not localhost). Only HTTP 200 is treated as healthy.\n      - name: Health check\n        run: |\n          sleep 5\n          response=$(curl -sk -o /dev/null -w \"%{http_code}\" \\\n            \"https://localhost:${{ vars.HTTPSPORT }}/\")\n          if [ \"$response\" != \"200\" ]; then\n            echo \"Health check failed (HTTP $response).\"\n            exit 1\n          fi\n          echo \"Server is healthy.\"\n"
  },
  {
    "path": ".gitignore",
    "content": "# .gitignore\n\n.env\ncert\nlogs\ndist\n# Old json data storage \"database\"\n/database\n\n# SQLite Database Files\n*.db\n*.sqlite\n*.sqlite3\n\n# SQLite Journal Files\n*.db-journal\n*.sqlite-journal\n*.sqlite3-journal\n# SQLite WAL mode sidecar files (created when journal_mode = WAL is enabled)\n*.db-wal\n*.db-shm\n*.sqlite-wal\n*.sqlite-shm\n*.sqlite3-wal\n*.sqlite3-shm\n\n# DB backups directory\nbackups/\n\n# Visual Studio automatically generated directories\nnode_modules\n.vs\n# VSCode automatically generated directory\n.vscode\n\n# JetBrains IDEs\n.idea/\n\n# PNPM\npnpm-lock.yaml\n\n# Hidden mac file storing attributes of the directory\n.DS_Store\n\n# Speckit - FirePlank\n.specify\nspecs\n.agent\n\n# HydroChess Engine build output\nsrc/client/pkg/"
  },
  {
    "path": ".husky/pre-commit",
    "content": "npx lint-staged\n"
  },
  {
    "path": ".prettierignore",
    "content": "# .prettierignore\n\ndist/\ndev-utils/\nsrc/client/pkg/\n*.ejs"
  },
  {
    "path": ".prettierrc.json",
    "content": "{\n  \"semi\": true,\n  \"useTabs\": true,\n  \"tabWidth\": 4,\n  \"singleQuote\": true,\n  \"bracketSpacing\": true,\n  \"trailingComma\": \"all\",\n  \"arrowParens\": \"always\",\n  \"printWidth\": 100,\n  \"endOfLine\": \"lf\",\n  \"overrides\": [\n    {\n      \"files\": [\"*.yml\", \"*.json\"],\n      \"options\": {\n        \"tabWidth\": 2,\n        \"useTabs\": false\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# Claude Instructions for infinitechess.org\n\n### ABOVE ALL: Follow the requirements and guidelines for pull requests found in `docs/GUIDELINES.md`!\n\nWhen you finish making any new changes, always ensure all workflow checks pass: `npm run lint --silent`, `npx tsc --noEmit`, and `npm test`. You must repeat each of these commands, even if you only made a minor code change since your last check to fix one of their errors.\n\n## Key Guidelines\n\n1. Follow industry standards and best code practices of today.\n2. Maintain existing code structure, organization, and consistency.\n3. Perform testing for new complex functions to ensure their output is as expected.\n4. Actual unit/integration tests are not required, unless explicitly asked for.\n5. Remember before committing changes, that all pull requests must follow the guidelines in `docs/GUIDELINES.md`.\n6. No types should ever be re-exported inside scripts. All imports of a type should reference the source.\n\n## Project Architecture\n\n- **Frontend:** TS, CSS, and assets in `src/client`. No major frameworks detected; uses vanilla and modular scripts.\n- **Backend:** Node.js server in `src/server/server.js`, with API, game logic, and socket communication.\n\n## Key Files & Directories\n\n- `src/client/` — Frontend code\n- `src/server/` — Backend code\n- `src/shared/` — Shared utilities and chess logic\n- `dev-utils/` — Depricated code. Do not maintain. It is not imported by the source code.\n- `translation/` — Localization\n\n## Conventions & Patterns\n\n- **Translations:** TOML files in `translation/` for i18n. News per locale in `translation/news/`. Any modification to the en-US.toml requires you update the version number at the top of the file, and reflect the change in `translation/changes.json`. Change notes in `changes.json` should be clear and concise, not containing more information than necessary, and always indicate the line numbers of the removed/added keys.\n- **Rendering:** When asked to add new graphics and visuals to the game (canvas), refer to the Graphics Rendering Guide in `docs/GRAPHICS.md`.\n- When determining which imports can safely be removed, the command `npm run lint --silent` automatically tells you what imports are unused.\n\n## Integration Points\n\n- **Database:** Uses SQLite via the `better-sqlite3` package.\n- **Socket Communication:** Real-time features via `src/server/socket/`.\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "# Infinite Chess Web Server\n\n[InfiniteChess.org](https://www.infinitechess.org) is a free and ad-less website for playing several variants on an infinite, boundless board.\n\nWhat began as an indie project by [Naviary](https://www.youtube.com/@Naviary) in 2022 has been growing since. What drives this project is the concept of Chess and Infinity intertwined! There shall be no more limits, only freedom! That being said, there are many interesting tactics that arise from the size of the board, not to mention lots of wild mathematics surrounding it, just check out a couple of the [YouTube videos](https://www.youtube.com/@Naviary)!\n\n[Join us on Discord](https://discord.gg/NFWFGZeNh5) for more info, or just to chat about the game!\n\n## Contributing\n\nThis project is open source! If you have skills in HTML, CSS, JavaScript, TypeScript, or Node.js, we welcome contributions!\n\n| Document                                                                 | Description                               |\n| ------------------------------------------------------------------------ | ----------------------------------------- |\n| **[Setup Guide](./docs/SETUP.md)**                                       | Setup your contribution workflow          |\n| **[Navigation Guide](./docs/NAVIGATING.md)**                             | Get an overview of the codebase           |\n| **[Contributing Guide](./docs/GUIDELINES.md)**                           | PR requirements and guidelines            |\n| **[Issues](https://github.com/Infinite-Chess/infinitechess.org/issues)** | Inquire available tasks                   |\n| **[Translation Guide](./docs/TRANSLATIONS.md)**                          | Translate the website to another language |\n| **[Graphics Guide](./docs/GRAPHICS.md)**                                 | Learn how the game renders graphics       |\n\n## Roadmap\n\nThere are still MANY more items I have planned for this project. Just a few of them are:\n\n- Modern website design\n- Analysis Board\n- Spectating\n- Games with infinitely many pieces\n- 4 Player\n- Massive Multiplayer Online\n\nBecause I want Infinite Chess to grow to as big of an audience as possible, it's licensed with a goal of keeping this game free forever! Check out [Copying](./docs/COPYING.md) for more details.\n"
  },
  {
    "path": "build/client.ts",
    "content": "// build/client.ts\n\nimport fs from 'fs';\nimport swc from '@swc/core';\nimport path from 'node:path';\nimport { glob } from 'glob';\nimport { readFile } from 'node:fs/promises';\nimport browserslist from 'browserslist';\n// @ts-ignore this package doesn't have a declaration file\nimport stripComments from 'glsl-strip-comments';\nimport { transform, browserslistToTargets } from 'lightningcss';\nimport esbuild, { BuildOptions, Plugin, PluginBuild } from 'esbuild';\n\nimport { getESBuildLogStatusLogger } from './plugins.js';\n\n// ================================= CONSTANTS =================================\n\n// Targetted browsers for CSS transpilation\n// Format: https://github.com/browserslist/browserslist?tab=readme-ov-file#query-composition\nconst cssTargets = browserslistToTargets(browserslist('defaults'));\n\n/**\n * Any ES Module that any HTML document IMPORTS directly!\n * ADD TO THIS when we create new modules that nothing else depends on!\n * ESBuild has to build each of them and their dependancies\n * into their own bundle!\n */\nconst ESMEntryPoints = [\n\t'src/client/scripts/esm/game/main.ts',\n\t'src/client/scripts/esm/audio/processors/downsampler/DownsamplerProcessor.ts',\n\t'src/client/scripts/esm/components/header/header.ts',\n\t'src/client/scripts/esm/views/index.ts',\n\t'src/client/scripts/esm/views/member.ts',\n\t'src/client/scripts/esm/views/leaderboard.ts',\n\t'src/client/scripts/esm/views/login.ts',\n\t'src/client/scripts/esm/views/news.ts',\n\t'src/client/scripts/esm/views/createaccount.ts',\n\t'src/client/scripts/esm/views/resetpassword.ts',\n\t'src/client/scripts/esm/views/guide.ts',\n\t'src/client/scripts/esm/views/admin.ts',\n\t'src/client/scripts/esm/views/icnvalidator.ts',\n\t'src/client/scripts/esm/game/chess/engines/engineCheckmatePractice.ts',\n\t'src/client/scripts/esm/game/chess/engines/hydrochess.ts',\n\t'src/client/scripts/esm/workers/icnvalidator.worker.ts',\n];\n\n/** CommonJS modules imported by html pages. */\nconst CJSEntryPoints = ['src/client/scripts/cjs/game/htmlscript.ts'];\n\n// ================================= PLUGINS ===================================\n\nconst ESMBuildPlugin = getESBuildLogStatusLogger(\n\t'✅ Client ESM Build successful.',\n\t'❌ Client ESM Build failed.',\n);\n\nconst CJSBuildPlugin = getESBuildLogStatusLogger(\n\t'✅ Client CJS Build successful.',\n\t'❌ Client CJS Build failed.',\n);\n\n/**\n * Returns an esbuild plugin that resolves a promise once the initial esbuild build is complete.\n * BuildContext.watch() resolves only once the watch mode is SET UP, not when the first build is DONE,\n * so this provides a promise that ONLY resolves once the first build is done.\n * @returns An object containing the esbuild plugin and the promise to await.\n */\nfunction getInitialBuildPlugin(): { plugin: Plugin; initialBuild: Promise<void> } {\n\tlet isFirstBuild = true;\n\n\tlet resolve: () => void;\n\tconst promise = new Promise<void>((r) => {\n\t\tresolve = r;\n\t});\n\n\tconst plugin: Plugin = {\n\t\tname: 'initial-build-waiter',\n\t\tsetup(build: PluginBuild) {\n\t\t\t// This hook runs when a build has finished\n\t\t\tbuild.onEnd(() => {\n\t\t\t\tif (!isFirstBuild) return;\n\t\t\t\tisFirstBuild = false;\n\t\t\t\t// Signal that the first build is done, even if\n\t\t\t\t// there was an error, so that watch mode can continue.\n\t\t\t\tresolve();\n\t\t\t});\n\t\t},\n\t};\n\n\treturn { plugin, initialBuild: promise };\n}\n\n/** An esbuild plugin object that minifies GLSL shader files by stripping comments. */\nconst GLSLMinifyPlugin = {\n\tname: 'glsl-minify',\n\tsetup(build: PluginBuild) {\n\t\t// Intercept .glsl files and minify them\n\t\tbuild.onLoad({ filter: /\\.glsl$/ }, async (args) => {\n\t\t\ttry {\n\t\t\t\t// Read the GLSL file\n\t\t\t\tconst source = await readFile(args.path, 'utf8');\n\t\t\t\t// Strip comments from the GLSL source\n\t\t\t\tconst minified = stripComments(source);\n\t\t\t\t// Return the minified content as text\n\t\t\t\treturn {\n\t\t\t\t\tcontents: minified,\n\t\t\t\t\tloader: 'text',\n\t\t\t\t};\n\t\t\t} catch (error: unknown) {\n\t\t\t\treturn {\n\t\t\t\t\terrors: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttext: `Failed to minify GLSL file: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t\t\t\tlocation: { file: args.path },\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t};\n\t\t\t}\n\t\t});\n\t},\n};\n\nconst ESMBuildOptions: BuildOptions = {\n\tbundle: true,\n\tentryPoints: ESMEntryPoints,\n\toutdir: './dist/client/scripts/esm',\n\t/**\n\t * Enable code splitting, which means if multiple entry points require the same module,\n\t * that dependancy will be separated out of both of them which means it isn't duplicated,\n\t * and there's only one instance of it per page.\n\t * This also means more requests to the server, but not many.\n\t * If this is false, multiple copies of the same code may be loaded onto a page,\n\t * each belonging to a separate entry point module.\n\t */\n\tsplitting: true,\n\tformat: 'esm',\n\tsourcemap: true, // Enables sourcemaps for debugging in the browser.\n\t// minify: true, // Enable minification. SWC is more compact so we don't use esbuild's\n\tloader: { '.wasm': 'file' },\n};\n\nconst CJSBuildOptions: BuildOptions = {\n\tbundle: true,\n\tentryPoints: CJSEntryPoints,\n\toutdir: './dist/client/scripts/cjs',\n\toutbase: 'src/client/scripts/cjs', // Without this, htmlscript.js gets put in cjs/ instead of cjs/game/\n\tformat: 'cjs',\n\tsourcemap: true,\n};\n\n// ================================= BUILDING ===================================\n\n/** Builds the client's scripts and minifies css. */\nexport async function buildClient(isDev: boolean): Promise<void> {\n\t// console.log(`Building client in ${isDev ? 'DEVELOPMENT' : 'PRODUCTION'} mode...`);\n\n\t// Create signaling plugins to wait for the initial build in watch mode\n\tconst { plugin: esmInitialBuildPlugin, initialBuild: esmInitialBuild } =\n\t\tgetInitialBuildPlugin();\n\tconst { plugin: cjsInitialBuildPlugin, initialBuild: cjsInitialBuild } =\n\t\tgetInitialBuildPlugin();\n\n\tconst ESMContext = await esbuild.context({\n\t\t...ESMBuildOptions,\n\t\tlegalComments: isDev ? undefined : 'none', // Only strip copyright notices in production.\n\t\tplugins: [ESMBuildPlugin, GLSLMinifyPlugin, esmInitialBuildPlugin],\n\t});\n\tconst CJSContext = await esbuild.context({\n\t\t...CJSBuildOptions,\n\t\tlegalComments: isDev ? undefined : 'none', // Only strip copyright notices in production.\n\t\tplugins: [CJSBuildPlugin, GLSLMinifyPlugin, cjsInitialBuildPlugin],\n\t});\n\n\tif (isDev) {\n\t\t// Start watch mode. This kicks off the initial builds\n\t\tESMContext.watch();\n\t\tCJSContext.watch();\n\n\t\t// Wait for both of the initial builds to complete\n\t\tawait Promise.all([esmInitialBuild, cjsInitialBuild]);\n\t} else {\n\t\t/**\n\t\t * ESBuild takes each entry point and all of their dependencies and merges them bundling them into one file.\n\t\t * If multiple entry points share dependencies, then those dependencies will be split into separate modules,\n\t\t * which means they aren't duplicated, and there's only one instance of it per page.\n\t\t * This also means more requests to the server, but not many.\n\t\t */\n\t\t// Production\n\t\t// Build once and exit since not in watch mode\n\t\tawait ESMContext.rebuild();\n\t\tESMContext.dispose();\n\n\t\tawait CJSContext.rebuild();\n\t\tCJSContext.dispose();\n\n\t\t// Minify JS and CSS\n\t\t// console.log('Minifying production assets...');\n\t\t// Further minify them. This cuts off their size a further 60%!!!\n\t\tawait minifyScriptDirectory(\n\t\t\t'./dist/client/scripts/cjs/',\n\t\t\t'./dist/client/scripts/cjs/',\n\t\t\tfalse,\n\t\t);\n\t\tawait minifyScriptDirectory(\n\t\t\t'./dist/client/scripts/esm/',\n\t\t\t'./dist/client/scripts/esm/',\n\t\t\ttrue,\n\t\t);\n\t\tawait minifyCSSFiles();\n\t}\n}\n\n/**\n * Minifies all JavaScript files in a directory and writes them to an output directory.\n * @param inputDir - The directory to scan for scripts.\n * @param outputDir - The directory where the minified files will be written.\n * @param module - True if the scripts are ES Modules instead of CommonJS.\n * @returns Resolves when all files are minified.\n */\nasync function minifyScriptDirectory(\n\tinputDir: string,\n\toutputDir: string,\n\tmodule: boolean,\n): Promise<void> {\n\tconst files = await glob('**/*.js', { cwd: inputDir, nodir: true });\n\n\tfor (const file of files) {\n\t\tconst inputFilePath = path.join(inputDir, file);\n\t\tconst outputFilePath = path.join(outputDir, file);\n\n\t\tconst content = await readFile(inputFilePath, 'utf-8');\n\t\tconst minified = await swc.minify(content, {\n\t\t\tmangle: true, // Enable variable name mangling\n\t\t\tcompress: true, // Enable compression\n\t\t\tsourceMap: false,\n\t\t\tmodule, // Include if we're minifying ES Modules instead of Common JS\n\t\t});\n\n\t\t// Write the minified file to the output directory\n\t\tfs.mkdirSync(path.dirname(outputFilePath), { recursive: true });\n\t\tfs.writeFileSync(outputFilePath, minified.code);\n\t\t// console.log(`Minified: ${outputFilePath}`);\n\t}\n}\n\n/**\n * Minifies all CSS files from src/client/css/ directory\n * to the distribution directory, preserving the original structure.\n * @returns Resolves when all CSS files are processed.\n */\nasync function minifyCSSFiles(): Promise<void> {\n\t// Bundle and compress all css files\n\tconst cssFiles = await glob('**/*.css', { cwd: './dist/client/css', nodir: true });\n\tfor (const file of cssFiles) {\n\t\t// Minify css files\n\t\tconst outputFilePath = `./dist/client/css/${file}`;\n\t\tconst { code } = transform({\n\t\t\ttargets: cssTargets,\n\t\t\tcode: Buffer.from(await readFile(outputFilePath, 'utf8')),\n\t\t\tminify: true,\n\t\t\tfilename: path.basename(outputFilePath),\n\t\t});\n\t\t// Write into /dist\n\t\tfs.mkdirSync(path.dirname(outputFilePath), { recursive: true });\n\t\tfs.writeFileSync(outputFilePath, code);\n\t}\n}\n"
  },
  {
    "path": "build/engine-wasm.ts",
    "content": "// build/engine-wasm.ts\n\n/**\n * HydroChess WASM Engine Setup Script\n *\n * This ensures that the HydroChess WASM engine is available.\n */\n\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport * as z from 'zod';\n\nimport { logZodError } from '../src/server/utility/zodlogger';\n\n// Constants -------------------------------------------------------------------\n\n/** Absolute path to the HydroChess WASM engine pkg directory */\nconst HYDROCHESS_WASM_DIR = path.join(process.cwd(), 'src', 'client', 'pkg', 'hydrochess');\n\n/** API URL to check the latest released version */\nconst LATEST_RELEASE_API_URL =\n\t'https://api.github.com/repos/Infinite-Chess/hydrochess/releases/latest';\n\n/** Zod schema for validating GitHub release API response */\nconst releaseDataSchema = z.object({\n\ttag_name: z.string(),\n\tassets: z.array(\n\t\tz.object({\n\t\t\tname: z.string(),\n\t\t\tbrowser_download_url: z.string(),\n\t\t}),\n\t),\n});\n\n// Functions -------------------------------------------------------------------\n\n/**\n * Ensures the HydroChess WASM engine is available and up-to-date.\n * Automatically downloads the pre-built WASM if there is a new release.\n */\nexport async function setupEngineWasm(): Promise<void> {\n\tconst label = '[hydrochess]';\n\tconst pkgDir = path.join(HYDROCHESS_WASM_DIR, 'pkg');\n\tconst wasmFile = path.join(pkgDir, 'hydrochess_wasm_bg.wasm');\n\tconst jsFile = path.join(pkgDir, 'hydrochess_wasm.js');\n\t// Note: If you are manually rebuilding the engine binaries on a separate\n\t// vscode window with the hydrochess repo open, and have setup a symlink\n\t// for this submodule to point to that project, then this file will be innacurate.\n\t// But it works because the local build process thinks we're already on the latest version.\n\tconst versionFile = path.join(pkgDir, '.engine-version');\n\n\t// Download pre-built binary if new version available\n\tlet localVersion = '';\n\tif (fs.existsSync(versionFile)) {\n\t\tlocalVersion = fs.readFileSync(versionFile, 'utf-8').trim();\n\t}\n\n\tlet releaseData: z.infer<typeof releaseDataSchema>;\n\n\ttry {\n\t\tconst response = await fetch(LATEST_RELEASE_API_URL, {\n\t\t\theaders: { 'User-Agent': 'Infinite-Chess-Build-Script' },\n\t\t});\n\t\tif (!response.ok) throw new Error(`GitHub API failed: ${response.statusText}`);\n\n\t\tconst rawReleaseData = await response.json();\n\t\tconst parseResult = releaseDataSchema.safeParse(rawReleaseData);\n\t\tif (!parseResult.success) {\n\t\t\tlogZodError(\n\t\t\t\trawReleaseData,\n\t\t\t\tparseResult.error,\n\t\t\t\t`${label} GitHub API returned invalid data.`,\n\t\t\t);\n\t\t\tthrow new Error(`GitHub API returned invalid data: ${parseResult.error.message}`);\n\t\t}\n\n\t\treleaseData = parseResult.data;\n\t} catch (error: unknown) {\n\t\tconsole.warn(\n\t\t\t`${label} Could not check for new version:`,\n\t\t\terror instanceof Error ? error.message : String(error),\n\t\t);\n\t\tif (fs.existsSync(wasmFile)) {\n\t\t\tconsole.log(`${label} Using existing local version.`);\n\t\t\treturn;\n\t\t}\n\t\t// If we can't check and have no local copy, fail and inform the user.\n\t\tconsole.error(`${label} Automatic download failed and no local copy exists.`);\n\t\treturn;\n\t}\n\n\tconst remoteVersion = releaseData.tag_name;\n\n\tif (\n\t\tlocalVersion &&\n\t\tlocalVersion === remoteVersion &&\n\t\tfs.existsSync(wasmFile) &&\n\t\tfs.existsSync(jsFile)\n\t) {\n\t\tconsole.log(`${label} Engine is up-to-date (${localVersion}).`);\n\t\treturn;\n\t}\n\n\tconsole.log(`${label} New version detected (${remoteVersion}). Downloading release...`);\n\n\t// Extract dynamic download URLs from the API response\n\tconst wasmAsset = releaseData.assets.find((a) => a.name === 'hydrochess_wasm_bg.wasm');\n\tconst jsAsset = releaseData.assets.find((a) => a.name === 'hydrochess_wasm.js');\n\n\tif (!wasmAsset || !jsAsset) {\n\t\tconsole.error(`${label} Release ${remoteVersion} is missing required asset files.`);\n\t\treturn;\n\t}\n\n\ttry {\n\t\tawait fs.promises.mkdir(pkgDir, { recursive: true });\n\n\t\tconst downloadFile = async (url: string, dest: string): Promise<void> => {\n\t\t\tconst response = await fetch(url);\n\t\t\tif (!response.ok) throw new Error(`Failed to download ${url}: ${response.statusText}`);\n\t\t\tconst buffer = Buffer.from(await response.arrayBuffer());\n\t\t\tawait fs.promises.writeFile(dest, buffer);\n\t\t\tconsole.log(`${label} Downloaded ${path.basename(dest)}`);\n\t\t};\n\n\t\tawait Promise.all([\n\t\t\tdownloadFile(wasmAsset.browser_download_url, wasmFile),\n\t\t\tdownloadFile(jsAsset.browser_download_url, jsFile),\n\t\t]);\n\n\t\t// Stamp the downloaded version\n\t\tawait fs.promises.writeFile(versionFile, remoteVersion);\n\n\t\tconsole.log(`${label} Hydrochess engine is ready (${remoteVersion}).`);\n\t} catch (error) {\n\t\tconsole.error(\n\t\t\t`${label} Automatic download failed:`,\n\t\t\terror instanceof Error ? error.message : String(error),\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "build/env.ts",
    "content": "// build/env.ts\n\n/**\n * Ensures the .env file exists, generating it with default values if it doesn't.\n * And ensures its contents are valid.\n */\n\nimport fs from 'fs';\nimport crypto from 'crypto';\nimport dotenv from 'dotenv';\n\nconst envPath = '.env';\n\n/** Ensure .env file exists and is valid. */\nexport function setupEnv(): void {\n\tensureExists();\n\tensureValid();\n}\n\n/** Ensure .env exists, generating it with default values if it doesn't. */\nfunction ensureExists(): void {\n\tif (fs.existsSync(envPath)) return;\n\n\t// Doesn't exist, generate it with default values\n\n\tconst ACCESS_TOKEN_SECRET = generateSecret(32); // 32 bytes = 64 characters in hex\n\tconst REFRESH_TOKEN_SECRET = generateSecret(32);\n\n\tconst content = `\nNODE_ENV=development\nACCESS_TOKEN_SECRET=${ACCESS_TOKEN_SECRET}\nREFRESH_TOKEN_SECRET=${REFRESH_TOKEN_SECRET}\nRESTART_SECRET=\nCERT_PATH=\nAWS_REGION=\nEMAIL_FROM_ADDRESS=\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nHTTPPORT=80\n# In production, must match the HTTPSPORT repository variable for the Deploy workflow to work correctly.\nHTTPSPORT=443\nHTTPPORT_LOCAL=3000\nHTTPSPORT_LOCAL=3443\nGITHUB_API_KEY=\nGITHUB_REPO=Infinite-Chess/infinitechess.org\nAPP_BASE_URL=https://www.infinitechess.org\n\t`;\n\n\tfs.writeFileSync(envPath, content.trim());\n\n\tconsole.log('Generated .env file');\n\n\t// Immediately UPDATE the contents of process.env\n\tdotenv.config();\n}\n\n/**\n * Generate a random string of specified length.\n * @param length - The length of the generated string, in bytes. The resulting string will be double this amount in characters.\n * @returns The generated random string\n */\nfunction generateSecret(length: number): string {\n\treturn crypto.randomBytes(length).toString('hex');\n}\n\n/** Ensures some existing environment variables are valid. */\nfunction ensureValid(): void {\n\tconst NODE_ENV = process.env['NODE_ENV'];\n\tconst validValues = ['development', 'production', 'test']; // 'test' only appears during Vitest unit testing.\n\n\tif (NODE_ENV === undefined || !validValues.includes(NODE_ENV)) {\n\t\tthrow new Error(\n\t\t\t`NODE_ENV environment variable must be either 'development', 'production', or 'test', received '${NODE_ENV}'.`,\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "build/index.ts",
    "content": "// build/index.ts\n\n/**\n * This script deploys all files and assets from /src/client to /dist in order to run the website.\n *\n * Development mode: Transpile all TypeScript files to JavaScript.\n * Production mode: Transpile and bundle all TypeScript files to JavaScript, and minify via @swc/core.\n * \t\t\t\t\tFurther, all css files are minified by lightningcss.\n */\n\nimport { setupEnv } from './env';\nimport { buildViews } from './views';\nimport { buildClient } from './client';\nimport { buildServer } from './server';\nimport { setupEngineWasm } from './engine-wasm';\n\nimport 'dotenv/config'; // Imports all properties of process.env, if it exists\n\n// Ensure .env file exists and has valid contents\nsetupEnv();\n\n/** Whether additional minifying of bundled scripts and css files should be skipped. */\nconst USE_DEVELOPMENT_BUILD = process.argv.includes('--dev');\n\nif (USE_DEVELOPMENT_BUILD && process.env['NODE_ENV'] === 'production') {\n\tthrow new Error(\n\t\t\"Cannot run build process with --dev flag when NODE_ENV environment variable is 'production'!\",\n\t);\n}\n\n// Ensure the HydroChess WASM engine is available\n// Must be awaited since client build has a .wasm dependency on it.\nawait setupEngineWasm();\n\n// Build both client and server scripts\n// Await all so the script doesn't finish and node terminate before esbuild is done.\nawait Promise.all([buildClient(USE_DEVELOPMENT_BUILD), buildServer(USE_DEVELOPMENT_BUILD)]);\n\n// Generate Static Views (HTML)\nawait buildViews();\n\n// console.log('Build process finished.');\n"
  },
  {
    "path": "build/plugins.ts",
    "content": "// build/plugins.ts\n\n/**\n * Contains shared esbuild plugins used in both client and server builds.\n */\n\nimport type { Plugin, PluginBuild } from 'esbuild';\n\n/** Returns an esbuild plugin that logs whenever a build finishes/fails. */\nexport function getESBuildLogStatusLogger(successMessage: string, failureMessage: string): Plugin {\n\treturn {\n\t\tname: 'log-rebuild',\n\t\tsetup(build: PluginBuild) {\n\t\t\t// This hook runs when a build has finished\n\t\t\tbuild.onEnd((result) => {\n\t\t\t\tif (result.errors.length > 0) console.error(failureMessage);\n\t\t\t\telse console.log(successMessage);\n\t\t\t});\n\t\t},\n\t};\n}\n"
  },
  {
    "path": "build/server.ts",
    "content": "// build/server.ts\n\nimport { glob } from 'glob';\nimport esbuild, { BuildOptions } from 'esbuild';\n\nimport { getESBuildLogStatusLogger } from './plugins.js';\n\n// ================================= CONSTANTS =================================\n\nconst entryPoints = await glob(['src/server/**/*.{ts,js}', 'src/shared/**/*.{ts,js}'], {\n\tignore: ['**/*.test.{ts,js}'],\n});\n\n// ================================= BUILDING ===================================\n\nconst esbuildServerRebuildPlugin = getESBuildLogStatusLogger(\n\t'✅ Server Build successful.',\n\t'❌ Server Build failed.',\n);\n\nconst esbuildOptions: BuildOptions = {\n\t// Transpile all TS files from BOTH directories\n\tentryPoints: entryPoints,\n\tplatform: 'node',\n\tbundle: false, // No bundling for the server. Just transpile each file individually\n\toutdir: 'dist',\n\tformat: 'esm',\n\tsourcemap: true, // Patches file paths from server console errors to the correct src/ file\n\tplugins: [esbuildServerRebuildPlugin],\n};\n\n// ================================= BUILDING ===================================\n\n/** Builds the server's scripts, transpiling them all into javascript (no bundling). */\nexport async function buildServer(isDev: boolean): Promise<void> {\n\t// console.log(`Building server in ${isDev ? 'DEVELOPMENT' : 'PRODUCTION'} mode...`);\n\n\tconst context = await esbuild.context(esbuildOptions);\n\n\tif (isDev) {\n\t\tawait context.watch();\n\t\t// console.log('esbuild is watching for SERVER changes...');\n\t} else {\n\t\tawait context.rebuild();\n\t\tcontext.dispose();\n\t\t// console.log('Server build complete.');\n\t}\n}\n"
  },
  {
    "path": "build/views.ts",
    "content": "// build/views.ts\n\n/**\n * Generates static HTML views from EJS templates and translation files.\n */\n\nimport fs from 'fs';\nimport path from 'path';\nimport i18next from 'i18next';\nimport ejs, { Data } from 'ejs';\nimport { fileURLToPath } from 'node:url';\n\nimport editorutil from '../src/shared/util/editorutil.js';\n\nimport translationLoader from '../src/server/config/translationLoader.js';\nimport { DEFAULT_LANGUAGE } from '../src/server/utility/translate.js';\nimport { UNCERTAIN_LEADERBOARD_RD } from '../src/server/game/gamemanager/ratingcalculation.js';\n\n// Constants -----------------------------------------------------------------\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\n/**\n * Templates without any external data other than translations.\n * Don't insert names with file extensions.\n */\nconst staticTranslatedTemplates = [\n\t'createaccount',\n\t'credits',\n\t'guide',\n\t'index',\n\t'login',\n\t'member',\n\t'news',\n\t'leaderboard',\n\t'play',\n\t'termsofservice',\n\t'resetpassword',\n\t'admin',\n\t'errors/400',\n\t'errors/401',\n\t'errors/404',\n\t'errors/409',\n\t'errors/500',\n];\n\n// Functions -----------------------------------------------------------------\n\n/** Generates translated versions of templates in {@link staticTranslatedTemplates}. */\nexport async function buildViews(): Promise<void> {\n\t// Load data\n\tconst translations = translationLoader.loadTranslations();\n\t// Grab supported languages from the loaded translations\n\tconst supportedLanguages = Object.keys(translations);\n\tconst news = translationLoader.loadNews(supportedLanguages);\n\n\t// Initialize i18next for the build process so the 't' function works during render\n\tawait i18next.init({\n\t\tresources: translations,\n\t\tdefaultNS: 'default',\n\t\tfallbackLng: DEFAULT_LANGUAGE,\n\t\t// debug: true, // Enable debug mode to see logs for missing keys and other details\n\t});\n\n\tconst languages_list = Object.entries(translations).map(\n\t\t([languageCode, languageTranslations]) => ({\n\t\t\tcode: languageCode,\n\t\t\tname: languageTranslations.default['name'] as string,\n\t\t\tenglishName: languageTranslations.default['english_name'] as string,\n\t\t}),\n\t);\n\n\tconst templatesPath = path.join(__dirname, '../dist/client/views');\n\n\tfor (const languageCode of Object.keys(translations)) {\n\t\t// Specific ejsOptions for rendering this language\n\t\tconst ejsData: Data = {\n\t\t\t// Function for translations\n\t\t\tt: function (key: string, options: Record<string, any> = {}) {\n\t\t\t\toptions['lng'] = languageCode; // Make sure language is correct\n\t\t\t\treturn i18next.t(key, options);\n\t\t\t},\n\t\t\tlanguages: languages_list,\n\t\t\tlanguage: languageCode,\n\n\t\t\tdistfolder: path.join(__dirname, '../dist'),\n\t\t\tviewsfolder: templatesPath,\n\n\t\t\t// Inject the news HTML\n\t\t\tnewsHTML: news[languageCode],\n\n\t\t\t// Custom included variables\n\t\t\tratingDeviationUncertaintyThreshold: UNCERTAIN_LEADERBOARD_RD,\n\t\t\teditorPositionNameMaxLength: editorutil.MAX_POSITION_NAME_LENGTH,\n\t\t};\n\n\t\t// The output directory for this language's rendered templates\n\t\tconst renderDirectory = path.join(templatesPath, languageCode);\n\n\t\t// Render each of this language's static translated templates\n\t\tfor (const template of staticTranslatedTemplates) {\n\t\t\tconst templatePath = path.join(templatesPath, template + '.ejs');\n\t\t\tconst templateFile = fs.readFileSync(templatePath).toString();\n\n\t\t\tconst renderedPath = path.join(renderDirectory, template + '.html');\n\t\t\tconst renderedFile = ejs.render(templateFile, ejsData); // Render the file\n\n\t\t\tfs.mkdirSync(path.dirname(renderedPath), { recursive: true }); // Ensure directory exists\n\t\t\tfs.writeFileSync(renderedPath, renderedFile); // Write the rendered file\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "dev-utils/ICN_METADATA_TRANSLATIONS.md",
    "content": "# English Translations Required for ICN Metadata\n\nICN metadata must **always** be written in English, regardless of the user's language.\n\n---\n\n## 1. Player Names (`White` / `Black` metadata)\n\nUsed when a player is not signed in, or is the local user in engine games.\n\n| TOML Key                          | English Value | Used In                                                                         |\n| --------------------------------- | ------------- | ------------------------------------------------------------------------------- |\n| `play.javascript.you_indicator`   | `(You)`       | Engine & board-editor engine games — assigned to the human player's color       |\n| `play.javascript.guest_indicator` | `(Guest)`     | Online games — assigned to non-signed-in players (server-side, already English) |\n\n---\n\n## 2. Variant Names (`Variant` metadata / `Event` metadata)\n\nThe variant code (e.g. `CoaIP`) is translated to its English spoken name when writing the `Event` string\nand when copying a game to ICN (the `Variant` metadata field).\n\n| TOML Key                            | English Value                                      |\n| ----------------------------------- | -------------------------------------------------- |\n| `play.play-menu.Classical`          | `Classical`                                        |\n| `play.play-menu.Confined_Classical` | `Confined Classical`                               |\n| `play.play-menu.Classical_Plus`     | `Classical+`                                       |\n| `play.play-menu.CoaIP`              | `Chess on an Infinite Plane`                       |\n| `play.play-menu.Pawndard`           | `Pawndard`                                         |\n| `play.play-menu.Knighted_Chess`     | `Knighted Chess`                                   |\n| `play.play-menu.Palace`             | `Palace`                                           |\n| `play.play-menu.Knightline`         | `Knightline`                                       |\n| `play.play-menu.Core`               | `Core`                                             |\n| `play.play-menu.Standarch`          | `Standarch`                                        |\n| `play.play-menu.Pawn_Horde`         | `Pawn Horde`                                       |\n| `play.play-menu.Space_Classic`      | `Space Classic`                                    |\n| `play.play-menu.Space`              | `Space`                                            |\n| `play.play-menu.Obstocean`          | `Obstocean`                                        |\n| `play.play-menu.Abundance`          | `Abundance`                                        |\n| `play.play-menu.Amazon_Chandelier`  | `Amazon Chandelier`                                |\n| `play.play-menu.Containment`        | `Containment`                                      |\n| `play.play-menu.Classical_Limit_7`  | `Classical - Limit 7`                              |\n| `play.play-menu.CoaIP_Limit_7`      | `Coaip - Limit 7`                                  |\n| `play.play-menu.Chess`              | `Chess`                                            |\n| `play.play-menu.Classical_KOTH`     | `Experimental: Classical - KOTH`                   |\n| `play.play-menu.CoaIP_KOTH`         | `Experimental: Coaip - KOTH`                       |\n| `play.play-menu.CoaIP_HO`           | `Chess on an Infinite Plane - Huygens Option`      |\n| `play.play-menu.CoaIP_RO`           | `Chess on an Infinite Plane - Roses Option`        |\n| `play.play-menu.CoaIP_NO`           | `Chess on an Infinite Plane - Knightriders Option` |\n| `play.play-menu.Omega`              | `Showcase: Omega`                                  |\n| `play.play-menu.Omega_Squared`      | `Showcase: Omega^2`                                |\n| `play.play-menu.Omega_Cubed`        | `Showcase: Omega^3`                                |\n| `play.play-menu.Omega_Fourth`       | `Showcase: Omega^4`                                |\n| `play.play-menu.4x4x4x4_Chess`      | `4×4×4×4 Chess`                                    |\n| `play.play-menu.5D_Chess`           | `5D Chess`                                         |\n\n---\n\n## In the Future\n\nDuring the website redesign, all of these required keys should be better restructured (they should more apparently be for the javascript, and not for any play-menu).\n\nIn addition, these are the only keys for which all English translations should be sent to the client, on top of their existing language-specific translations which should already be sent.\n"
  },
  {
    "path": "dev-utils/REDESIGN/design.md",
    "content": "# Summary of what should go on each page/component\n\n## Header\n\n- Site name + logo -> Home page\n- News\n- Practice\n- Editor\n- Analysis\n- Leaderboard\n- Donate\n- Profile/Login\n- Register/Logout\n- Settings\n\n## Footer\n\n- About Infinite Chess\n- Contact\n- Terms of Service\n- Privacy\n- GitHub\n- Discord\n- Youtube\n\n\n## Homepage\n- Scrolling perspective mode board? Generally across the site though, a static 2D checkerboard background like that of the chess stack exchange.\n- Lobby sits on the homepage.\n- Below that: Spectate live games.\n\n## Lobby\n- [ ] Determine the maximum piece count where images are barely below recognizable. Convert that to characters\n- Modal for creating an invite. Public/Private option. Private creates a url your friend can visit to view the invite and its properties. Option to provide custom position via ICN. Button to take selected variant to the editor. Maximum piece count prevents dirty images. Game modes available: Chess, 4 Dimensions, Showcases. Each has their own dropdowns with respective variants.\n- Hovering over invites renders a small tooltip-popup window that previews the board, and custom gamerules, if any.\n\n## Games\n- Online games navigatable to via a link. Allows spectating if still live. Allows accepting a private invite if not yet joined.\n- One vertical bar with clocks, moves, and chat, and material lost per side. Does chat have reporting? Do laws require it have reporting?\n- The moves bar uses silhouettes of the piece svgs.\n\n## Analysis Board\n- Make, undo, change move history to perform analysis on positions.\n- Turn on the engine to display the top move, and the score.\n\n## Board Editor\n- Share games via url. Next to the link to copy notation. Maximum piece count / icn length prevents dirty images.\n- Create an invite from the position. Maximum piece count / icn length prevents dirty images. Same model popup as creating an invite from the lobby.\n- Move to Analysis Board\n\n## Profile\n\n- Game history\n- Change username\n\n## Donation Page\n\nAnyone that becomes a patron gets a cool badge next to their username.\nAny monthly donation gives you the badge. $1+\nWhen monthly dontations stop, badge is removed.\nMaybe a lifetime donation amount where the badge is permanent?\nLichess offers golden wings after 5 years of active patron status. And instantly after a liftime donation, unlocking all colors.\n\n## Light and Dark Theme\n\nFor light and dark themes, store colors once per theme as a small set of semantic variables, and every element in the entire codebase references those variables.\n\nEXAMPLE THEME (for us we will have significantly fewer variables to start out):\n\n/* src/client/css/themes.css */\n\n:root,\n[data-theme=\"light\"] {\n  --c-bg:            #f0efea;\n  --c-surface:       #ffffff;\n  --c-surface-raise: #e8e7e2;\n  --c-surface-sink:  #dddcd6;\n  --c-text:          #1a1a1a;\n  --c-text-2:        #4a4a4a;\n  --c-text-muted:    #757575;\n  --c-text-inv:      #f0efea;\n  --c-border:        #cccccc;\n  --c-border-focus:  #5a9a5a;\n  --c-brand:         #5a9a5a;\n  --c-brand-hover:   #4a8a4a;\n  --c-link:          #2060a0;\n  --c-focus-ring:    rgba(90, 154, 90, 0.4);\n  --c-error:         #cc2222;\n  --c-warning:       #b06000;\n  --c-success:       #2a7a2a;\n}\n\n[data-theme=\"dark\"] {\n  --c-bg:            #18181a;\n  --c-surface:       #222226;\n  --c-surface-raise: #2c2c30;\n  --c-surface-sink:  #141416;\n  --c-text:          #e2e2da;\n  --c-text-2:        #b0b0a8;\n  --c-text-muted:    #787870;\n  --c-text-inv:      #18181a;\n  --c-border:        #3a3a3e;\n  --c-border-focus:  #70ba70;\n  --c-brand:         #6aaa6a;\n  --c-brand-hover:   #7aba7a;\n  --c-link:          #6090d0;\n  --c-focus-ring:    rgba(106, 170, 106, 0.4);\n  --c-error:         #ee5555;\n  --c-warning:       #e09020;\n  --c-success:       #50aa50;\n}"
  },
  {
    "path": "dev-utils/REDESIGN/runner_setup.md",
    "content": "# GitHub Actions Runner Setup\n\n[← Back to Navigation Guide](./NAVIGATING.md)\n\nThis guide covers everything needed to bring up automated deployment for `infinitechess.org`:\n\n1. [Install the self-hosted runner on the production Mac](#part-1-install-the-self-hosted-runner)\n2. [Configure repository secrets and variables](#part-2-configure-repository-secrets-and-variables)\n3. [Add `RESTART_SECRET` to the production `.env`](#part-3-add-restart_secret-to-the-production-env)\n4. [Add the `workflow_dispatch` trigger to the HydroChess workflow](#part-4-add-the-workflow_dispatch-trigger-to-the-hydrochess-workflow)\n5. [Verify all three triggers work](#part-5-verify-each-trigger)\n\n> **Note:** PM2 is assumed to be fully configured and running `infinitechess` before starting these steps.\n\n---\n\n## Part 1: Install the Self-Hosted Runner\n\nThe self-hosted runner is a small background process that holds a persistent long-poll connection to GitHub. When a deploy workflow is triggered, GitHub wakes the runner and it executes the workflow steps as shell commands on the production machine.\n\n### 1.1 Open the runner registration page\n\n1. Go to `https://github.com/Infinite-Chess/infinitechess.org` on GitHub.\n2. Click **Settings → Actions → Runners → New self-hosted runner**.\n3. Select **macOS** as the operating system and choose the correct architecture:\n    - **x64** for Intel Macs\n    - **ARM64** for Apple Silicon (M1/M2/M3)\n4. GitHub displays a set of shell commands with the exact download URL, checksum, and a one-time registration token. **Do not close this page** — the token expires after one hour.\n\n### 1.2 Create the runner directory and download the runner\n\nRun the commands shown on the GitHub setup page. They will look like the following (use the exact URLs and checksums from GitHub, not these examples):\n\n```bash\n# Create a dedicated directory for the runner, outside the production code directory\nmkdir ~/actions-runner && cd ~/actions-runner\n\n# Download the runner package (use the URL shown on the GitHub page)\ncurl -o actions-runner-osx-arm64-X.Y.Z.tar.gz -L https://github.com/actions/runner/releases/download/vX.Y.Z/actions-runner-osx-arm64-X.Y.Z.tar.gz\n\n# Verify the downloaded file's integrity (use the checksum shown on the GitHub page)\necho \"CHECKSUM  actions-runner-osx-arm64-X.Y.Z.tar.gz\" | shasum -a 256 -c\n\n# Extract the archive\ntar xzf ./actions-runner-osx-arm64-X.Y.Z.tar.gz\n```\n\n- **`curl -o … -L`**: Downloads the runner binary. `-o` sets the output filename; `-L` follows redirects.\n- **`shasum -a 256 -c`**: Verifies the SHA-256 checksum to ensure the download was not corrupted.\n- **`tar xzf`**: Extracts the gzipped tar archive into the current directory.\n\n### 1.3 Configure the runner\n\nRun the configuration command shown on the GitHub setup page. It will look like:\n\n```bash\n./config.sh --url https://github.com/Infinite-Chess/infinitechess.org --token <TOKEN>\n```\n\n- **`--url`**: The repository the runner will serve jobs for.\n- **`--token`**: The one-time registration token from step 1.1.\n\nWhen prompted:\n\n- **Runner name**: Press **Enter** to accept the default (the machine's hostname), or type a custom name.\n- **Additional labels**: Press **Enter** to skip.\n- **Work folder**: Press **Enter** to accept the default (`_work`).\n\n### 1.4 Install the runner as a launchd service\n\nInstalling as a service means the runner starts automatically on login and survives terminal sessions.\n\n```bash\n# Register the runner as a macOS launchd user service\n./svc.sh install\n\n# Start the service immediately (no need to log out and back in)\n./svc.sh start\n```\n\n- **`./svc.sh install`**: Creates a `launchd` plist under `~/Library/LaunchAgents/` so the runner starts every time you log in.\n- **`./svc.sh start`**: Starts the service right now.\n\nTo check the runner's status at any time (make sure you're cd'd into `~/actions-runner/`):\n\n```bash\n./svc.sh status\n```\n\nTo view runner logs if something goes wrong:\n\n```bash\n# Tail the last 50 lines of the runner log\ntail -50 ~/actions-runner/_diag/Runner_*.log\n```\n\nOnce installed, the runner appears as **Online** in **Settings → Actions → Runners** on GitHub.\n\n---\n\n## Part 2: Configure Repository Secrets and Variables\n\nGo to **Settings → Secrets and variables → Actions** on the `infinitechess.org` repository to add the following.\n\n### 2.1 `RESTART_SECRET` (Secret)\n\nThe server's `deployController.ts` checks the `X-Restart-Secret` header against this value before performing the pre-deploy database backup. Only the runner (which has this secret injected as an environment variable at runtime) can call that endpoint.\n\n**Generate the secret** by running this command in any terminal:\n\n```bash\nopenssl rand -hex 32\n```\n\n- **`openssl rand -hex 32`**: Generates 32 cryptographically random bytes and encodes them as a 64-character hexadecimal string. Never share or commit this value.\n\nAdd the output as a **Secret** named `RESTART_SECRET`. You will also need to add the same value to the production `.env` file — see [Part 3](#part-3-add-restart_secret-to-the-production-env).\n\n### 2.2 `DEPLOY_DIR` (Secret)\n\nThe absolute path to the production code directory on the server — the directory where PM2 currently runs the app from and where `git pull` / `npm ci` / `npm run build` should execute.\n\nExample value: `/Users/naviary/infinitechess.org`\n\nAdd this as a **Secret** (under the \"Secrets\" tab) named `DEPLOY_DIR`. Storing it as a secret keeps the server's filesystem layout out of public workflow logs.\n\n### 2.3 `HTTPSPORT` (Variable — not a secret)\n\nThe HTTPS port the production server listens on. The deploy workflow uses when connecting to the server — it hits `https://localhost:$HTTPSPORT/[endpoint]`.\n\nThis must match the `HTTPSPORT` value in the production `.env` file.\n\nAdd this as an Actions **Variable** named `HTTPSPORT`.\n\n---\n\n## Part 3: Add `RESTART_SECRET` to the Production `.env`\n\nOpen the production `.env` file (in the root of the `DEPLOY_DIR` directory) and add:\n\n```\nRESTART_SECRET=<the same value you added to GitHub Actions secrets>\n```\n\nThen reload the server and force PM2 to flush its environment cache, ensuring your app's `dotenv` package picks up the changes:\n\n```bash\npm2 reload infinitechess --update-env\npm2 save\n```\n\n- `pm2 reload infinitechess --update-env`: Performs a graceful reload — starts a new process with the freshly updated system environment, then shuts down the old one.\n- `pm2 save`: Freezes the current PM2 state so that if the Mac server ever reboots, the app starts back up with the correct environment variables.\n\n---\n\n## Part 4: Add the `workflow_dispatch` Trigger to the HydroChess Workflow\n\nThe HydroChess `build-wasm.yml` workflow must trigger the `deploy.yml` workflow on `infinitechess.org` **after** the engine release is fully published. Placing this as the very last step guarantees the new WASM binaries are available before the infinitechess.org build runs.\n\n### 4.1 Create a fine-grained Personal Access Token (PAT)\n\nThe dispatch API call needs a token with permission to trigger Actions workflows on `infinitechess.org`.\n\n1. Go to **GitHub.com → Settings → Developer settings → Personal access tokens → Fine-grained tokens**.\n2. Click **Generate new token**.\n3. Fill in:\n    - **Token name**: `hydrochess-dispatch`\n    - **Expiration**: Set to **1 year** and add a calendar reminder to rotate it before it expires. If the token expires, the dispatch step in HydroChess will silently fail with no other visible error.\n    - **Resource owner**: `Infinite-Chess`\n    - **Repository access**: Only selected repositories → `infinitechess.org`\n    - **Repository permissions**: Set **Actions** to **Read and write** (this automatically selects Metadata: Read). Do **not** grant Contents access — it is not needed and would allow pushing code to the repository.\n4. Click **Generate token** and **copy the value immediately** — it is shown only once.\n\n### 4.2 Add the PAT as a secret in the HydroChess repository\n\n1. Go to `https://github.com/Infinite-Chess/HydroChess` → **Settings → Secrets and variables → Actions**.\n2. Click **New repository secret**.\n3. **Name**: `INFINITECHESS_DISPATCH_TOKEN`\n4. **Value**: the PAT generated in step 4.1.\n\n### 4.3 Add the dispatch step to `build-wasm.yml`\n\nOpen `.github/workflows/build-wasm.yml` in the HydroChess repository. Append the following as the **last step** of the `build-and-release` job, after the \"Create Release\" step:\n\n```yaml\n- name: Trigger infinitechess.org deploy\n  run: |\n      curl -s -X POST \\\n        -H \"Authorization: Bearer ${{ secrets.INFINITECHESS_DISPATCH_TOKEN }}\" \\\n        -H \"Accept: application/vnd.github.v3+json\" \\\n        https://api.github.com/repos/Infinite-Chess/infinitechess.org/actions/workflows/deploy.yml/dispatches \\\n        -d '{\"ref\":\"prod\"}'\n```\n\n**What this does**: Sends an authenticated `POST` to GitHub's API directly triggering the `deploy.yml` workflow on `infinitechess.org`. This uses the `workflow_dispatch` endpoint, which only requires `Actions: Read and write` — unlike the `repository_dispatch` endpoint which requires `Contents: Read and write` (a far broader permission that allows pushing code). The self-hosted runner skips `git pull`/`npm ci` (since no new commits or dependencies changed on this repo), re-runs the build (which fetches the freshly published WASM files), and reloads PM2.\n\n---\n\n## Part 5: Verify Each Trigger\n\n### 5.1 Trigger 1 — push to `prod`\n\nMerge any real (non-markdown) change from `main` into `prod`. Watch the **Actions** tab on the `infinitechess.org` repository — the \"Deploy\" workflow should appear, run on the self-hosted runner, and complete successfully.\n\n### 5.2 Trigger 2 — `workflow_dispatch`\n\n1. Go to **Actions → Deploy** on the `infinitechess.org` repository.\n2. Click **Run workflow → Run workflow**.\n3. Confirm the workflow runs on `self-hosted` and succeeds.\n\n### 5.3 Trigger 3 — HydroChess `workflow_dispatch`\n\nPush a commit to the `main` branch of HydroChess (or manually trigger the `build-wasm.yml` workflow via `workflow_dispatch`). After the HydroChess workflow finishes and the release is published, the \"Deploy\" workflow on `infinitechess.org` should appear in the Actions tab and run.\n\n### 5.4 Verify near-zero downtime\n\nWhile a deploy is in progress, open the play page in a browser with the console visible. You should observe the WebSocket disconnect and reconnect within approximately 2.5 seconds, with the game resuming normally.\n\n---\n"
  },
  {
    "path": "dev-utils/REDESIGN/stack.md",
    "content": "# Website Redesign Plan\n\nThe website will undergo a complete redesign to modernize its look and feel, making it much more professional and expandable. Every single page is going to be overhauled- their look, and their underlying code.\n\n## Deployment Environment\n\nSelf-hosted on a Mac, no VPS. SSD storage. Cloudflare in front. Low traffic — a few hundred unique visitors per day.\n\n## Technical Stack & Decisions\n\n### Build Pipeline\n\n- **esbuild:** Extend the existing pipeline in `build/`. Two additions to `build/client.ts`: (1) `entryNames: '[dir]/[name]-[hash]'` for content-hashed output filenames; (2) `metafile: true` plus a `writeManifest()` post-build function that reads esbuild's input→output map and writes `dist/manifest.json`. The server loads this manifest at startup and injects the correct hashed filenames into every Nunjucks render.\n\n- **Content-hashed asset caching:** JS and CSS files are emitted as `main.[hash].js` / `styles.[hash].css` and served with `Cache-Control: immutable, max-age=31536000` — browsers cache them forever and fetch a new URL automatically when content (and thus the file fingerprint) changes. HTML is served with `Cache-Control: no-store` and always embeds the current hashed filesnames. For images and other static assets referenced directly in templates (e.g. `<img src=\"/img/king_w.png\">`), use `Cache-Control: max-age=31536000` (without `immutable`) and append a `?v=2` query string manually in the template when the file changes. Reserve `immutable` only for build-pipeline-hashed files.\n\n- **Nunjucks** replaces EJS as the server-side templating engine. Layout inheritance is the key benefit: one `layout.njk` defines the full `<html>`/`<head>`/`<body>` shell with named `{% block %}` slots, and every page file just `{% extends \"layout.njk\" %}` and fills in its title, styles, and body content. Changing a favicon or global meta tag means editing one file. Logic stays in route handlers; templates only use `{% for %}` / `{% if %}`. `build/views.ts` is deleted entirely — it only existed to pre-render every EJS template × every language to static `.html` files because the old server had no SSR capability. With Nunjucks, HTML is rendered at request time, `dist/client/views/` no longer exists, `root.ts` switches from `res.sendFile()` to `res.render()`, and `nodemon.json` no longer needs to watch `src/client/views`.\n\n### Page Architecture\n\n- **Proper MPA.** Each major feature lives on its own page — no cramming everything into one giant page. Pages are bandwidth-aware: each page only loads the JS it needs. This matters slightly less now that scripts are indefinitely cached after the first load, but it still keeps things clean and fast on first visit.\n\n- **SSR (server-side render) everything that affects the first paint.** The server renders the full HTML — header auth state, notification badge count, member profile data, news \"NEW\" badges — before sending the response. The client never needs to fetch these or patch the DOM on load. Use client-side fetching only for things triggered by user interaction or that need live updates (e.g. leaderboard \"Show More\", editor saves, preferences writes).\n\n- **Snabbdom for data-driven in-page reactivity.** Use it when DOM content is generated from data at runtime — leaderboard lists, chat windows, live game panels. Don't use it for static content known at author time (e.g. the fairy piece carousel in the Guide), or for pre-authored fixed elements like modals and tab panels that are simply shown/hidden. Each Snabbdom component needs a plain module-level `state` object and a `render(state)` function that should return a virtual DOM tree via `h()`. State is a plain JS object updated directly on socket events or user interactions.\n\n### CSS & Styling\n\n- **CSS methodology:** One shared stylesheet for global styles, plus a per-page stylesheet for each page. Short, descriptive class names scoped with native CSS nesting — no BEM, no prefixes. Each page's stylesheet has one top-level block matching its `<main>` class (e.g. `.login { .form-field {} }`), preventing any bleed between pages. lightningcss in the existing build pipeline handles transpilation for older browsers. Utility classes (`.hidden`, `.italic`, `.flex`, etc.) are hand-rolled and added to the shared stylesheet when redundancy appears — no Tailwind. CSS files are colocated with the component they style (e.g. `src/client/components/header/header.css`).\n\n- **CSS custom property light/dark theme system.** A `[data-theme]` attribute on `<html>` (e.g. `data-theme=\"dark\"`) selects a block of several semantic CSS variables (`--c-bg`, `--c-surface`, `--c-text`, `--c-brand`, `--c-border`, etc.) defined in the shared stylesheet. Switching themes is one `setAttribute` call plus a `localStorage` write. A small inline `<script>` in `<head>` reads `localStorage` and sets the attribute before any CSS loads, preventing a flash of the wrong light/dark theme on page load.\n\n- **Font stack:** `\"Noto Sans\", Verdana, sans-serif` (Lichess font). Self-host Noto Sans (not loaded from Google Fonts CDN, but via `@font-face`) to avoid the extra DNS/connection overhead and the 1-day CSS cache expiry that Google Fonts carries. Font files are served with `Cache-Control: immutable, max-age=31536000` alongside JS/CSS. Ensure our middleware is capable of serving fonts, with the same cache-control as other static assets.\n\n- **Header layout without JS measurement.** The header renders its correct auth state (logged-in vs. logged-out) entirely server-side. CSS container queries or a CSS-only overflow fallback handle layout at different widths. Language-width variation is the remaining open challenge.\n\n### Auth & Session\n\n- **Keep the existing dual-token auth system unchanged** — no backend migration needed. The refresh token (`jwt`) is already an `httpOnly` cookie sent on every request including page navigations. `verifyJWT` middleware already reads it and sets `req.memberInfo`, so Nunjucks SSR gets full auth context for free. Short-lived access tokens (managed by `validatorama.ts`) are kept for API calls — they encapsulate the DB-skipping refresh logic and have only 3 call sites. The one known limitation: pages without a websocket (currently only the editor) can go stale after logout in another tab, which we accept.\n\n- **Logout in another tab:** When a socket-connected page receives the logout event, call `window.location.reload()` rather than trying to swap out header elements on the client. This lets the server re-render the correct logged-out state with no need to hide/show DOM elements in JS.\n\n- **Defer non-critical DB writes with `res.on('finish')`.** Since SSR means the server is doing more DB work per request, use `res.on('finish')` to delay writes that don't affect the response — e.g. updating the user's last-active timestamp or marking notifications as read — until after the response has already been sent.\n\n### Localization\n\n- **Weblate-compatible translation system.** One TOML file per website feature (header nav, game UI, settings, leaderboard, profile, etc.) — dozens of components total. Weblate automatically marks strings in other languages as stale when the English source changes. No `removeOutdated()` function is needed. Stale translations are rendered as-is rather than falling back to English. `deepMerge()` is still kept for strings that are entirely absent in a given language (fallback to English). Markdown/article content is not run through Weblate but can optionally be translated manually by trnaslators. Components no longer need a version field for versioning, `loadTranslations()` should not support versioning.\n\n- **No translations for ToS or Privacy Policy** — English only, sourced from markdown files. Optionally include a notice in the document that the English version takes precedence if they are ever translated. Markdown is too hard to version-control for translators in a way that communicates exactly what changed, so these pages are excluded from the translation pipeline.\n\n### Late-Stage Polish\n\n- **`<link rel=\"modulepreload\">`** for each page's JS entry points, injected into the page HTML. This lets the browser fetch all ES modules in parallel from the first response, eliminating the waterfall of sequential import round trips. Add this last, once each page's import graph is finalized. Lower priority since scripts are cached indefinitely after the first load anyway.\n\n- **White flash on navigation.** `@view-transition { navigation: auto }` with `::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0s }` in the shared stylesheet eliminates a potential white flash between page loads by holding the old page visible until the new one is ready to paint — no crossfade, but an instant cut.\n\n- **Audio autoplay fallback.** If the browser blocks the first move's sound, when navigating to tbr game page, before the first user gesture, refer to Lichess's approach as a reference: they show a small red mute icon in the header when audio is blocked. See: https://lichess.org/faq#autoplay"
  },
  {
    "path": "dev-utils/REDESIGN/todo.md",
    "content": "# Redesign TODO\n\n---\n\n## Build Pipeline\n\n- Add `entryNames: '[dir]/[name]-[hash]'` to the esbuild client build options in `build/client.ts` so JS and CSS output filenames are content-hashed.\n\n- Add `metafile: true` to the esbuild client build and write a `writeManifest()` post-build function that reads esbuild's input→output map and writes `dist/manifest.json`.\n\n- At server startup, load `dist/manifest.json` and expose the hashed filenames to the Nunjucks render context so templates can reference them.\n\n- Update static asset middleware: serve hashed JS/CSS with `Cache-Control: immutable, max-age=31536000`; serve HTML with `Cache-Control: no-store`; serve images/fonts with `Cache-Control: max-age=31536000` (without `immutable`).\n\n- Add to the Pull Request Requirements and Guidelines that whenever an image or font asset changes, we must append a `?v=2` manually in the template so browsers know to fetch the new version instead of using the cached one. (Not needed for JS/CSS since those are content-hashed).\n\n---\n\n## Nunjucks Migration\n\n- Install `nunjucks` and `@types/nunjucks`; configure the Nunjucks environment in the Express app (set views directory, autoescape, etc.).\n\n- Create `layout.njk` — the full HTML shell (`<html>`, `<head>`, `<body>`) with `{% block %}` slots for: page title, extra `<head>` tags, page stylesheet, body content, and page script.\n\n- Delete `build/views.ts`; remove the `copy:views` script from `package.json`; remove `src/client/views` from `nodemon.json`'s watch list.\n\n- Migrate all existing route handlers from `res.sendFile()` to `res.render()`, pointing each to a minimal placeholder `.njk` file that extends `layout.njk`. This keeps the site functional while individual pages are redesigned.\n\n---\n\n## CSS Foundation\n\n- Create the shared stylesheet (`src/client/css/global.css`) with some CSS custom property variables for both `[data-theme=\"dark\"]` and `[data-theme=\"light\"]` (e.g. `--c-bg`, `--c-surface`, `--c-text`, `--c-brand`, `--c-border`).\n\n- Add the inline `<script>` to `layout.njk <head>` that reads `localStorage` and sets `data-theme` on `<html>` before any CSS loads, preventing a flash of the wrong theme.\n\n- Create a `@font-face` declaration for Noto Sans and the font-stack CSS into the shared stylesheet.\n\n- Ensure our middleware is capable of serving fonts, with the same cache-control as other static assets.\n\n- Add other CSS rules we think will be shared across all pages.\n\n---\n\n## Translation System Refactor\n\n- Restructure TOML translation files from one-file-per-page to one-file-per-feature-component (header nav, game UI, settings, leaderboard, profile, etc.). Do not migrate all existing keys, create new ones as we go, in the appropriate component. Do away with the `version` field.\n\n- Update `loadTranslations()` to remove versioning support entirely; delete `removeOutdated()`. Keep `deepMerge()` (fallback to English for missing keys).\n\n---\n\n## Shared Components\n\n*All page redesigns depend on these being done first.*\n\n- Redesign and implement the shared header component (`src/client/components/header/`) — Nunjucks partial, CSS (CSS-only responsive layout, no JS measurement), and TS. Server receives auth state via `req.memberInfo` and passes it to the template.\n\n- Redesign and implement the shared footer component — Nunjucks partial and CSS.\n\n- Implement logout-in-another-tab handling: on all socket-connected pages, call `window.location.reload()` when the socket logout event is received so the server re-renders the correct logged-out state.\n\n- Install `snabbdom` — required before any page that uses it for reactive lists.\n\n---\n\n## Page Redesigns\n\n*Each page: new Nunjucks template extending `layout.njk`, new colocated CSS file, updated route handler with full SSR context, and updated/new TS where needed.*\n\n- Redesign the **home (index)** page.\n\n- Redesign other pages as you go. SSR all profile data (username, rating, join date, etc.). SSR initla batch of leaderboard rows; Snabbdom for the \"Show More\" interaction. SSR for news post \"NEW\" badges.\n\n- Add the **Terms of Service** page — English only, rendered from a Markdown file, with an optional notice that the English version is authoritative.\n\n- Add the **Privacy Policy** page — English only, same approach as ToS.\n\n---\n\n## Deferred DB Writes\n\n- Audit all route handlers and wrap non-critical DB writes (last-active timestamp updates, marking notifications as read, etc.) in `res.on('finish')` so they run after the response is sent. *(Best done after all pages are complete)*\n\n---\n\n## Late-Stage Polish\n\n- Add `<link rel=\"modulepreload\">` for each page's JS entry points in its Nunjucks template. *(Do last, once every page's import graph is finalized)*\n\n- Consider `@view-transition` if there's white flashes between page loads.\n\n- Implement the audio autoplay fallback: detect when the browser has blocked audio before the first user gesture and display a muted indicator in the header (similar to Lichess's approach).\n"
  },
  {
    "path": "dev-utils/SKELETON.css",
    "content": "* {\n    margin: 0;\n    padding: 0;\n    font-family: Verdana;\n    border: 0;\n    /* Enable temporarily during dev to see the borders of all elements */\n    /* outline: 1px solid rgba(0, 0, 0, 0.191); */\n}\n\nhtml {\n    height: 100%;\n    background-color: rgb(33, 33, 33);\n}\n\n\n\nheader {\n    position: fixed;\n    left: 0;\n    top: 0;\n    right: 0;\n    box-shadow: 0px 1px 5px rgb(107, 107, 107);\n    height: 40px;\n    font-size: 0;\n    overflow: scroll;\n    white-space: nowrap;\n    text-align: center;\n    background-color: white;\n    z-index: 1;\n}\n\nheader a {\n    display: inline-block;\n    text-decoration: none;\n    line-height: 40px;\n    margin-left: 4px;\n    min-width: 70px;\n    font-size: 16px;\n    color: black;\n}\n\nheader a:hover {\n    background-color: rgb(211, 235, 255);\n}\n\nheader p {\n    padding: 0 10px;\n}\n\n\n\nmain {\n        background-color: #fff;\n    background-image: url('/img/blank_board.png');\n    @supports (background-image: url('/img/blank_board.webp')) {\n        background-image: url('/img/blank_board.webp');\n    }\n    @supports (background-image: url('/img/blank_board.avif')) {\n        background-image: url('/img/blank_board.avif');\n    }\n    background-position: center;\n    background-repeat: no-repeat;\n    background-size: cover;\n    -webkit-background-size: cover;\n    -moz-background-size: cover;\n    -o-background-size: cover;\n    background-attachment: fixed;\n\n    margin-top: 40px;\n    min-height: 400px;\n}\n\n#content {\n    background-color: rgba(255, 255, 255, 0.805);\n    min-height: 450px;\n    margin: auto;\n    box-shadow: 0 0 10px rgba(0, 0, 0, 0.522);\n    padding: 30px 20px;\n}\n\n#content h1 {\n    font-size: 40px;\n    font-family: georgia;\n}\n\n\n\nfooter {\n    text-align: center;\n    padding: 10px 0;\n}\n\nfooter a {\n    display: inline-block;\n    color: rgb(207, 207, 207);\n    margin: 10px 10px;\n    text-decoration: underline;\n}\n\n\n\n.center {\n    text-align: center;\n}\n\na {\n    -webkit-tap-highlight-color: rgba(0, 0, 0, 0.099);\n}\n\n\n\n/* Start increasing header links width */\n@media only screen and (min-width: 450px) {\n    header {\n        overflow: unset;\n    }\n\n    header a {\n        min-width: calc(70px + (100vw - 450px) * 0.15);\n    }\n}\n\n/* Stop increasing header links width */\n@media only screen and (min-width: 715px) {\n    header a {\n        min-width: 110px;\n    }\n}\n\n/* Cap content width size, revealing image on the sides */\n@media only screen and (min-width: 810px) {\n    #content {\n        max-width: calc(810px - 60px); /* 60px less than 810 to account for padding */\n        padding: 40px 30px;\n        min-height: 800px;\n    }\n}\n"
  },
  {
    "path": "dev-utils/SKELETON.html",
    "content": "<!DOCTYPE html>\n<html>\n    <head>\n        <meta charset=\"utf-8\" />\n        <meta http-equiv=\"x-ua-compatible\" content=\"ie=edge\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n        <title>Infinite Chess | Home - The Official Website</title>\n        <link rel='stylesheet' href='/css/SKELETON.css'>\n        <link rel=\"icon\" type=\"image/png\" href=\"/img/favicon.png\">\n    </head>\n    <body>\n        <header>\n            <a href=\"/\">\n                <p>Home</p>\n            </a>\n            <a href=\"/play\">\n                <p>Play</p>\n            </a>\n            <a href=\"/news\">\n                <p>News</p>\n            </a>\n            <a href=\"/leaderboard\">\n                <p>Leaderboard</p>\n            </a>\n            <a href=\"/login\" id=\"loginlink\">\n                <p id=\"logintext\">Log In</p>\n            </a>\n            <a href=\"/createaccount\" id=\"createaccountlink\">\n                <p id=\"createaccounttext\">Create Account</p>\n            </a>\n            <script src=\"/scripts/memberHeader.js\"></script>\n        </header>\n        <main>\n            <div id=\"content\">\n                <h1 class=\"center\">Skeleton</h1>\n            </div>\n        </main>\n        <footer>\n            <a href=\"mailto:support@infinitechess.org\">Contact us.</a>\n            <a href=\"/termsofservice\">Terms of Service</a>\n        </footer>\n    </body>\n</html>"
  },
  {
    "path": "dev-utils/live-game-persistence.md",
    "content": "# Live Game Persistence\n\nActive games are persisted to the database so they survive server restarts instead of being aborted. This document describes the two-table schema, what each column stores, and the event matrix that drives every DB write.\n\n---\n\n## Database Schema: Two Tables\n\nFollowing the pattern of `games` + `player_games` for ended games, live state is split across two tables to support an arbitrary number of players per game:\n\n- **`live_games`** — One row per active game. Contains game-level state.\n- **`live_player_games`** — One row per player per active game. Contains per-player state.\n\n### Table 1: `live_games`\n\n#### Group 1: Game Identity\n\n| Column         | Type                                       | Notes                                    |\n| -------------- | ------------------------------------------ | ---------------------------------------- |\n| `game_id`      | INTEGER PRIMARY KEY                        | Unique across live and logged games      |\n| `time_created` | INTEGER NOT NULL                           | Epoch milliseconds                       |\n| `variant`      | TEXT NOT NULL                              | e.g. `\"Classical\"`, `\"Omega^3\"`          |\n| `clock`        | TEXT NOT NULL                              | e.g. `\"600+5\"` or `\"-\"` for untimed     |\n| `rated`        | BOOLEAN NOT NULL CHECK (rated IN (0, 1))   | 0 = casual, 1 = rated                    |\n| `private`      | BOOLEAN NOT NULL CHECK (private IN (0, 1)) | 0 = public, 1 = private                 |\n\n#### Group 2: Move History\n\n| Column  | Type                       | Notes                                                                                                                      |\n| ------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------- |\n| `moves` | TEXT NOT NULL DEFAULT `''` | Pipe-delimited compact moves with embedded clock comments via ICN format (e.g. `1,2>3,4{[%clk 0:09:56.7]}`). See below. |\n\n**Move format:** Produced by `getShortFormMovesFromMoves()` in `icnconverter.ts` with `{ compact: true, spaces: false, comments: true, move_numbers: false }`. Each move encodes `startCoords > endCoords`, optional promotion, and a clock comment. Parsed back via `parseShortFormMoves()`. The entire column is rewritten on each move submission.\n\n#### Group 3: Clock State\n\n| Column                | Type    | Notes                                                                               |\n| --------------------- | ------- | ----------------------------------------------------------------------------------- |\n| `color_ticking`       | INTEGER | Player number whose clock is running. NULL if untimed, < 2 moves, or game over.    |\n| `clock_snapshot_time` | INTEGER | Epoch ms when clock values were snapshotted. Used to adjust the ticking player's time on restoration: `actual = stored_remaining - (Date.now() - clock_snapshot_time)`. |\n\nPer-player `time_remaining_ms` lives in `live_player_games`.\n\n#### Group 4: Draw Offer State\n\n| Column             | Type    | Notes                                                         |\n| ------------------ | ------- | ------------------------------------------------------------- |\n| `draw_offer_state` | INTEGER | Player number who extended the current offer. NULL if none.   |\n\nPer-player `last_draw_offer_ply` lives in `live_player_games`.\n\n#### Group 5: Game Conclusion\n\n| Column                 | Type    | Notes                                                                                              |\n| ---------------------- | ------- | -------------------------------------------------------------------------------------------------- |\n| `conclusion_condition` | TEXT    | e.g. `\"checkmate\"`, `\"time\"`, `\"resignation\"`, `\"aborted\"`, `\"agreement\"`. NULL if ongoing.        |\n| `conclusion_victor`    | INTEGER | Winning player number. NULL for draw, ongoing, or aborted.                                         |\n| `time_ended`           | INTEGER | Epoch ms when game concluded. NULL if ongoing.                                                     |\n\n#### Group 6: Timer State\n\n| Column            | Type    | Notes                                                                                                                                                  |\n| ----------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `afk_resign_time` | INTEGER | Epoch ms when the AFK auto-resign fires. NULL if no AFK timer active. On restoration, remaining = `stored - Date.now()`; if ≤ 0, immediately resign.   |\n| `delete_time`     | INTEGER | Epoch ms when the concluded game is deleted and logged. NULL if ongoing. Set to `timeEnded + timeBeforeGameDeletionMillis`. On restoration, if elapsed, immediately run logging. |\n\n#### Group 7: Flags\n\n| Column            | Type                                                         | Notes                                                                                                            |\n| ----------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |\n| `position_pasted` | BOOLEAN NOT NULL DEFAULT 0 CHECK (position_pasted IN (0, 1)) | Whether a custom position was pasted. Pasted games are never logged to the permanent `games` table.              |\n| `validate_moves`  | BOOLEAN NOT NULL DEFAULT 1 CHECK (validate_moves IN (0, 1))  | Whether server-side move validation is active (`boardsim` is defined). Set to 0 when a position is pasted.       |\n\n---\n\n### Table 2: `live_player_games`\n\nOne row per player per live game.\n\n| Column                        | Type             | Notes                                                                                                                                                                  |\n| ----------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `game_id`                     | INTEGER NOT NULL | FK → `live_games.game_id` ON DELETE CASCADE                                                                                                                            |\n| `player_number`               | INTEGER NOT NULL | 1 = White, 2 = Black, etc. Supports future multi-player games.                                                                                                         |\n| `user_id`                     | INTEGER          | NULL if guest.                                                                                                                                                         |\n| `browser_id`                  | TEXT NOT NULL    | Always present (guests are identified by `browser_id` alone).                                                                                                         |\n| `elo`                         | TEXT             | Snapshot at game start (e.g. `\"1500\"` or `\"1200?\"`). NULL if guest.                                                                                                   |\n| `last_draw_offer_ply`         | INTEGER          | Ply (0-based) of the player's last draw offer. NULL if never offered.                                                                                                  |\n| `time_remaining_ms`           | INTEGER          | Milliseconds remaining at time of snapshot. NULL if untimed.                                                                                                           |\n| `disconnect_cushion_end_time` | INTEGER          | Epoch ms when the 5-second reconnection cushion expires. NULL if no cushion is active.                                                                                 |\n| `disconnect_resign_time`      | INTEGER          | Epoch ms when the auto-resign fires. NULL if no active disconnect timer.                                                                                               |\n| `disconnect_by_choice`        | BOOLEAN          | 1 = intentional disconnect (20s timer), 0 = network drop (60s timer). NULL if player was connected. CHECK (disconnect_by_choice IN (0, 1)).                            |\n\n**Three-case disconnect restoration:**\n- `disconnect_resign_time` non-NULL → auto-resign timer was active; restore from that timestamp.\n- `disconnect_cushion_end_time` non-NULL, `disconnect_resign_time` NULL → still in the 5-second cushion; revive it (or start the auto-resign timer if elapsed).\n- All disconnect columns NULL → player was connected before the restart; start a fresh 60-second timer (server restart counts as not-by-choice).\n\n---\n\n## Event Matrix: When Each Column Is Written\n\n| Event                       | `live_games` Columns Updated                                                                                     | `live_player_games` Columns Updated                                                                  |\n| --------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |\n| **Game created**            | INSERT full row (all Group 1 columns, defaults for the rest)                                                     | INSERT one row per player (identity, elo, defaults)                                                  |\n| **Move submitted**          | `moves`, `color_ticking`, `clock_snapshot_time`, `validate_moves`                                                | `time_remaining_ms` (both players)                                                                   |\n| **Draw offer extended**     | `draw_offer_state`                                                                                               | `last_draw_offer_ply` (offering player)                                                              |\n| **Draw offer declined**     | `draw_offer_state` → NULL                                                                                        | —                                                                                                    |\n| **Draw accepted**           | `conclusion_condition`, `conclusion_victor`, `time_ended`, `draw_offer_state`, `delete_time`                     | —                                                                                                    |\n| **Resignation**             | `conclusion_condition`, `conclusion_victor`, `time_ended`, `delete_time`                                         | —                                                                                                    |\n| **Abort**                   | `conclusion_condition`, `time_ended`, `delete_time`                                                              | —                                                                                                    |\n| **Time loss**               | `conclusion_condition`, `conclusion_victor`, `time_ended`, `color_ticking`, `clock_snapshot_time`, `delete_time` | `time_remaining_ms`                                                                                  |\n| **Disconnect loss**         | `conclusion_condition`, `conclusion_victor`, `time_ended`, `delete_time`                                         | —                                                                                                    |\n| **Player disconnects**      | —                                                                                                                | `disconnect_cushion_end_time`, `disconnect_resign_time`, `disconnect_by_choice`                      |\n| **Player reconnects**       | —                                                                                                                | `disconnect_cushion_end_time` → NULL, `disconnect_resign_time` → NULL, `disconnect_by_choice` → NULL |\n| **Player goes AFK**         | `afk_resign_time`                                                                                                | —                                                                                                    |\n| **Player returns from AFK** | `afk_resign_time` → NULL                                                                                         | —                                                                                                    |\n| **AFK auto-resign**         | `conclusion_condition`, `conclusion_victor`, `time_ended`, `afk_resign_time` → NULL, `delete_time`               | —                                                                                                    |\n| **Position pasted**         | `position_pasted`, `validate_moves` → 0                                                                         | —                                                                                                    |\n| **Game deleted/logged**     | DELETE row (cascades to `live_player_games`)                                                                     | —                                                                                                    |\n"
  },
  {
    "path": "dev-utils/pieces/spritesheet 512/How to create spritesheet.md",
    "content": "# How to generate the game's spritesheet\n\n1. Go to [Stitches](https://draeton.github.io/stitches/).\n\n2. Make sure there are 64 images (not including this guide). If there are not, duplicate the empty placeholder until you do.\n\n3. Drag all files inside [this folder](../spritesheet%20512/) and upload them, except this guide.\n\n4. Make sure they are in the correct order.\n\n5. Decrease the padding to 0px."
  },
  {
    "path": "dev-utils/pieces/svg/Converting PNG to SVG.md",
    "content": "# Steps to converting a PNG to SVG #\n\nThis is the best method I've found, to retain high quality, yet remain highly compact!\n\n1. Go to [SVG Trace](https://svgtrace.com/png-to-svg)\n\n2. Drag in your desired PNG, approximately 512x512. Larger will lead to a larger ending file size.\n\n3. Do NOT change any of the settings\n\n4. Convert & Export\n\n5. Open [Compress or Die](https://compress-or-die.com/svg)\n\n6. Upload your new SVG\n\n7. Drag \"Decimal precision\" to exactly 1. Checkmark \"Extreme compression (experimental). \n\n8. Click \"Generate Optimized Image\". Download optimized image.\n\n9. Open your SVG's code, find all `fill` attributes. Change the ones super close to white to `#ffffff`, and the ones super close to black to `#000000`. Remove unneeded external links.\n\n10. See if it can further be compressed by running it through [SVG Minify](https://www.svgminify.com/).\n\n11. Enjoy your optimized SVG."
  },
  {
    "path": "dev-utils/post_processing_effects/posterize/PosterizePass.ts",
    "content": "// dev-utils/post_processing_effects/posterize/PosterizePass.ts\n\nimport type { PostProcessPass } from '../PostProcessingPipeline';\nimport type { ProgramManager, ProgramMap } from '../../ProgramManager';\n\n/**\n * A post-processing pass that reduces the number of colors in the scene\n * to create a \"posterized\" effect.\n */\nexport class PosterizePass implements PostProcessPass {\n\treadonly program: ProgramMap['posterize'];\n\n\t// --- Public Properties for Control ---\n\n\t/** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */\n\tpublic masterStrength: number = 1.0;\n\n\t/**\n\t * The number of distinct color levels per channel (red, green, blue).\n\t * A value of 4, for example, means each channel can only be one of 4 values.\n\t * Set 1 or less to effectively disable the effect.\n\t */\n\tpublic levels: number = 8;\n\n\tconstructor(programManager: ProgramManager) {\n\t\tthis.program = programManager.get('posterize');\n\t}\n\n\t/**\n\t * Renders the posterization effect.\n\t * @param gl - The WebGL2 rendering context.\n\t * @param inputTexture - The texture to process (usually the output of the previous pass).\n\t */\n\trender(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void {\n\t\tthis.program.use();\n\n\t\t// Bind the input texture to texture unit 0\n\t\tgl.activeTexture(gl.TEXTURE0);\n\t\tgl.bindTexture(gl.TEXTURE_2D, inputTexture);\n\n\t\t// Set the uniforms for the shader\n\t\tgl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_masterStrength'), this.masterStrength);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_levels'), this.levels);\n\t}\n}\n"
  },
  {
    "path": "dev-utils/post_processing_effects/posterize/fragment.glsl",
    "content": "#version 300 es\r\nprecision highp float;\r\n\r\n// The texture containing the scene to be posterized\r\nuniform sampler2D u_sceneTexture;\r\n\r\nuniform float u_masterStrength; // 0.0 = no effect, 1.0 = full effect\r\nuniform float u_levels; // The number of color levels per channel\r\n\r\n// The texture coordinates passed from the vertex shader\r\nin vec2 v_uv;\r\n\r\n// The final output color\r\nout vec4 out_color;\r\n\r\nvoid main() {\r\n    // Sample the original color from the input texture\r\n    vec4 originalColor = texture(u_sceneTexture, v_uv);\r\n\r\n    // Calculate the fully posterized color\r\n    vec3 posterizedColor;\r\n\r\n    // If levels are 1.0 or less, the \"posterized\" color is just the original color.\r\n    // This prevents division by zero and provides an easy way to toggle the effect.\r\n    if (u_levels <= 1.0) {\r\n        posterizedColor = originalColor.rgb;\r\n    } else {\r\n        // Apply the posterization formula\r\n        float numLevels = u_levels - 1.0;\r\n        posterizedColor = floor(originalColor.rgb * numLevels) / numLevels;\r\n    }\r\n\r\n    // Blend between the original and the posterized color using master strength.\r\n    vec3 finalRgb = mix(originalColor.rgb, posterizedColor, u_masterStrength);\r\n\r\n    // Output the final color, preserving the original alpha\r\n    out_color = vec4(finalRgb, originalColor.a);\r\n}"
  },
  {
    "path": "dev-utils/post_processing_effects/radial_distortion/RadialDistortionPass.ts",
    "content": "import type { ProgramManager, ProgramMap } from \"../../ProgramManager\";\nimport type { PostProcessPass } from \"../PostProcessingPipeline\";\n\n\n/**\n * A post-processing pass that applies radial distortion.\n * Used for both barrel and pincushion effects.\n */\nexport class RadialDistortionPass implements PostProcessPass {\n\treadonly program: ProgramMap['radial_distortion'];\n\n\t// --- Public Properties to Control the Effect ---\n\n\t/**\n\t * The strength of the distortion.\n\t * Positive values create a barrel (bulging) effect.\n\t * Negative values create a pincushion (pinching) effect.\n\t */\n\tpublic strength: number = 0.0;\n\n\t/** The center of the distortion, in UV coordinates [0, 1]. */\n\tpublic center: [number, number] = [0.5, 0.5];\n\n\n\tconstructor(programManager: ProgramManager) {\n\t\tthis.program = programManager.get('radial_distortion');\n\t}\n\n\trender(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void {\n\t\tthis.program.use();\n\t\t\n\t\tgl.activeTexture(gl.TEXTURE0);\n\t\tgl.bindTexture(gl.TEXTURE_2D, inputTexture);\n\t\t\n\t\tgl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_strength'), this.strength);\n\t\tgl.uniform2fv(this.program.getUniformLocation('u_center'), this.center);\n\t}\n}"
  },
  {
    "path": "dev-utils/post_processing_effects/radial_distortion/fragment.glsl",
    "content": "#version 300 es\nprecision highp float;\n\nuniform sampler2D u_sceneTexture;\n\n// --- Distortion Controls ---\nuniform float u_strength; // Positive for barrel, negative for pincushion\nuniform vec2 u_center;    // The center point of the distortion\n\nin vec2 v_uv;\nout vec4 out_color;\n\nvoid main() {\n    // Vector from the current UV coordinate to the distortion center\n\tvec2 to_center = v_uv - u_center;\n\n    // Calculate the distance squared from the center.\n    // Using dot product is often faster than length() -> sqrt().\n    float dist_sq = dot(to_center, to_center);\n\n    // Calculate the displacement factor.\n    // This is the core of the effect.\n    float displacement = 1.0 + u_strength * dist_sq;\n\n    // Apply the displacement to the vector from the center\n    vec2 displaced_uv = u_center + to_center * displacement;\n\n    // Look up the color from the original texture at the new, distorted coordinate\n\tout_color = texture(u_sceneTexture, displaced_uv);\n}"
  },
  {
    "path": "dev-utils/post_processing_effects/rolling_hills/RollingHillsPass.ts",
    "content": "import type { ProgramManager, ProgramMap } from \"../../ProgramManager\";\nimport type { PostProcessPass } from \"../PostProcessingPipeline\";\n\n\n/**\n * A post-processing pass that applies a single-axis sine wave distortion,\n * creating a \"rolling hills\" or flag-waving effect.\n */\nexport class RollingHillsPass implements PostProcessPass {\n\treadonly program: ProgramMap['rolling_hills'];\n\n\t// --- Public Properties to Control the Effect ---\n\n\t/** The strength of the wave (how far pixels are displaced). */\n\tpublic amplitude: number = 0.1;\n\n\t/** The number of full waves across the screen. */\n\tpublic frequency: number = 1.0;\n\n\t/** The angle of the wave crests in degrees. 0 creates vertical waves, 90 creates horizontal waves. */\n\tpublic angle: number = 0.0;\n\n\t/** The current time, used to animate the waves. */\n\tpublic time: number = 0.0;\n\n\t\n\tconstructor(programManager: ProgramManager) {\n\t\tthis.program = programManager.get('rolling_hills');\n\t}\n\n\trender(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void {\n\t\tthis.program.use();\n\t\t\n\t\tgl.activeTexture(gl.TEXTURE0);\n\t\tgl.bindTexture(gl.TEXTURE_2D, inputTexture);\n\n\t\t// Convert user-friendly degrees to radians for the shader\n\t\tconst angleInRadians = this.angle * (Math.PI / 180.0);\n\t\t\n\t\t// Set all the uniforms\n\t\tgl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_amplitude'), this.amplitude);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_frequency'), this.frequency);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_angle'), angleInRadians);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_time'), this.time);\n\t}\n}"
  },
  {
    "path": "dev-utils/post_processing_effects/rolling_hills/fragment.glsl",
    "content": "#version 300 es\nprecision highp float;\n\nuniform sampler2D u_sceneTexture;\n\n// --- Distortion Controls ---\nuniform float u_amplitude; // The strength of the wave\nuniform float u_frequency; // The number of waves across the screen\nuniform float u_angle;     // The angle of the waves in radians\nuniform float u_time;      // Animate the waves over time\n\nin vec2 v_uv;\nout vec4 out_color;\n\nconst float PI = 3.1415926535;\n\nvoid main() {\n    // Center the coordinates before rotation\n    vec2 centeredUV = v_uv - 0.5;\n\n    // Define the direction the pixels will be displaced.\n    // This is perpendicular to the wave crests.\n    vec2 displaceDir = vec2(cos(u_angle), sin(u_angle));\n\n    // Define the axis along which the wave's crests lie.\n    // This is perpendicular to the displacement direction.\n    vec2 waveAxis = vec2(-displaceDir.y, displaceDir.x);\n\n    // Calculate the input for the sine function.\n    // We project the UV coordinate onto the wave's axis. This tells us \"how far along\"\n    // the wave we are for any given pixel, creating straight, parallel wave crests.\n    float waveInput = dot(centeredUV, waveAxis);\n\n\n    // --- NEW: Get the SIGNED distance from the center. ---\n    // This value will be negative on one side of the center and positive on the other.\n    float signedDist = dot(centeredUV, displaceDir);\n\n    // --- NEW: Create a linear multiplier from the signed distance. ---\n    // The distance is roughly -0.5 to 0.5, so multiplying by 2.0 scales it to a nice -1.0 to 1.0 range.\n    float amplitudeMultiplier = signedDist * 2.0;\n\n    // // Calculate the amplitude multiplier based on distance from center.\n    // // Get the distance from the center along the wave's travel direction.\n    // float distFromCenter = abs(dot(centeredUV, displaceDir));\n    // // Create a smooth multiplier that goes from 0.0 (at center) to 1.0 (at screen edge, ~0.5 distance).\n    // // smoothstep gives a nice ease-in/out effect.\n    // float amplitudeMultiplier = smoothstep(0.0, 0.5, distFromCenter);\n\n    // Calculate the offset amount using the sine function, and apply the multiplier to it.\n    float offset = sin(waveInput * u_frequency * 2.0 * PI + u_time) * u_amplitude * amplitudeMultiplier;\n\n    // Apply the offset to the UVs in the displacement direction.\n    vec2 distortedUV = v_uv + displaceDir * offset;\n\n\tout_color = texture(u_sceneTexture, distortedUV);\n}"
  },
  {
    "path": "dev-utils/readme.md",
    "content": "# Dev Utils\n\nThis directory contains both depricated scripts that we believe might be useful in the future, as well as assets useful for development but not production.\n\nNo source code script imports and runs any code from this directory, it is completely isolated from the production codebase.\n\nFor this reason, code in here does not have to follow linting or formatting rules."
  },
  {
    "path": "dev-utils/scripts/PatreonAPI.ts",
    "content": "// dev-utils/scripts/PatreonAPI.ts\n\n/*\n * This module, in the future, will be where we connect to Patreon's API\n * to dynamically refresh the list of Patreon-specific patrons on the website.\n */\n\n/** A list of patrons on Naviary's [patreon](https://www.patreon.com/Naviary) page.\n * This should be periodically refreshed. */\nconst patrons: string[] = [];\n/** An object, containing patron usernames for the key, and their preferred\n * name on the website's patron list for the value. */\nconst replacementNames: Record<string, string> = {};\n\n/** The interval, in milliseconds, to use Patreon's API to refresh the patron list. */\n// const intervalToRefreshPatreonPatronsMillis = 1000 * 60 * 60; // 1 hour\n\n// /**\n//  * Uses Patreon's API to fetch all patrons on Naviary's\n//  * [patreon](https://www.patreon.com/Naviary) page, and updates our list!\n//  *\n//  * STILL TO BE WRITTEN\n//  */\n// function refreshPatreonPatronList() {\n\n// }\n\n/**\n * Returns a list of patrons on Naviary's [patreon](https://www.patreon.com/Naviary) page,\n * updated every {@link intervalToRefreshPatreonPatronsMillis}.\n */\nexport function getPatreonPatrons(): string[] {\n\t// Replace their true usernames with replacements\n\tconst patronsWithReplacedNames = patrons.map((patron) => {\n\t\treturn replacementNames[patron] || patron;\n\t});\n\n\treturn patronsWithReplacedNames;\n}\n"
  },
  {
    "path": "dev-utils/scripts/audio/processors/bitcrusher/BitcrusherNode.ts",
    "content": "// dev-utils/scripts/audio/processors/bitcrusher/BitcrusherNode.ts\n\nexport class BitcrusherNode extends AudioWorkletNode {\n\tconstructor(context: AudioContext) {\n\t\tsuper(context, 'bitcrusher-processor');\n\t}\n\n\t/**\n\t * Factory method to asynchronously create and initialize a BitcrusherNode.\n\t * @param context The AudioContext to create the node in.\n\t * @param workletUrl The URL to the compiled bitcrusher-processor.js file.\n\t * @returns A promise that resolves with a fully initialized BitcrusherNode instance.\n\t */\n\tpublic static async create(context: AudioContext): Promise<BitcrusherNode> {\n\t\ttry {\n\t\t\t// Load the worklet processor from the specified URL\n\t\t\tawait context.audioWorklet.addModule(\n\t\t\t\t'scripts/esm/audio/processors/bitcrusher/BitcrusherProcessor.js',\n\t\t\t);\n\t\t\t// Once loaded, create an instance of the node\n\t\t\treturn new BitcrusherNode(context);\n\t\t} catch (e) {\n\t\t\tconsole.error('Failed to load bitcrusher audio worklet', e);\n\t\t\tthrow e;\n\t\t}\n\t}\n\n\t/**\n\t * The number of bits to quantize the audio signal to.\n\t * Range: 1 to 16. Lower = more distortion.\n\t */\n\tget bitDepth(): AudioParam | undefined {\n\t\treturn this.parameters.get('bitDepth');\n\t}\n\n\t/**\n\t * The factor by which to reduce the sample rate.\n\t * A value of 1 means no downsampling.\n\t * Range: 1 to 40.\n\t */\n\tget downsampling(): AudioParam | undefined {\n\t\treturn this.parameters.get('downsampling');\n\t}\n}\n"
  },
  {
    "path": "dev-utils/scripts/audio/processors/bitcrusher/BitcrusherProcessor.ts",
    "content": "// dev-utils/scripts/audio/processors/bitcrusher/BitcrusherProcessor.ts\n\nimport type { AudioParamDescriptor } from '../worklet-types';\n\n/*\n * These need to be declared in every audio worklet processor file,\n * because apparently our typescript setup doesn't have the\n * AudioWorkletGlobalScope, and nothing I do will add it.\n */\n\ndeclare abstract class AudioWorkletProcessor {\n\tstatic get parameterDescriptors(): AudioParamDescriptor[];\n\tconstructor(options?: any);\n\tabstract process(\n\t\tinputs: Float32Array[][],\n\t\toutputs: Float32Array[][],\n\t\tparameters: Record<string, Float32Array>,\n\t): boolean;\n}\n\ndeclare function registerProcessor(name: string, processorCtor: typeof AudioWorkletProcessor): void;\n\n/** Parameters for the BitcrusherProcessor. */\ninterface BitcrusherParameters extends Record<string, Float32Array> {\n\tbitDepth: Float32Array;\n\tdownsampling: Float32Array;\n}\n\n/** An AudioWorkletProcessor that applies a bitcrusher and/or downsampling effect to audio. */\nclass BitcrusherProcessor extends AudioWorkletProcessor {\n\tstatic override get parameterDescriptors(): AudioParamDescriptor[] {\n\t\treturn [\n\t\t\t{\n\t\t\t\tname: 'bitDepth',\n\t\t\t\tdefaultValue: 8,\n\t\t\t\tminValue: 1,\n\t\t\t\tmaxValue: 16,\n\t\t\t\tautomationRate: 'k-rate',\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: 'downsampling',\n\t\t\t\tdefaultValue: 1,\n\t\t\t\tminValue: 1,\n\t\t\t\tmaxValue: 40,\n\t\t\t\tautomationRate: 'k-rate',\n\t\t\t},\n\t\t];\n\t}\n\n\tprivate phase = 0;\n\tprivate lastSampleValue = 0;\n\n\tprocess(\n\t\tinputs: Float32Array[][],\n\t\toutputs: Float32Array[][],\n\t\tparameters: BitcrusherParameters,\n\t): boolean {\n\t\tconst input = inputs[0];\n\t\tconst output = outputs[0];\n\t\tif (!input || !output) return true; // Nothing to process\n\n\t\tconst bitDepth = parameters['bitDepth'];\n\t\tconst downsampling = parameters['downsampling'];\n\n\t\tfor (let channel = 0; channel < input.length; ++channel) {\n\t\t\tconst inputChannel = input[channel];\n\t\t\tconst outputChannel = output[channel];\n\t\t\tif (!inputChannel || !outputChannel) continue;\n\n\t\t\tfor (let i = 0; i < inputChannel.length; ++i) {\n\t\t\t\tconst bitDepthValue = bitDepth.length > 1 ? bitDepth[i]! : bitDepth[0]!;\n\t\t\t\tconst downsamplingValue =\n\t\t\t\t\tdownsampling.length > 1 ? downsampling[i]! : downsampling[0]!;\n\n\t\t\t\t// Downsampling\n\t\t\t\tif (this.phase % downsamplingValue < 1) this.lastSampleValue = inputChannel[i]!;\n\n\t\t\t\t// Bit-depth reduction\n\t\t\t\tconst step = Math.pow(0.5, bitDepthValue);\n\t\t\t\toutputChannel[i] = step * Math.floor(this.lastSampleValue / step + 0.5);\n\n\t\t\t\tthis.phase++;\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t}\n}\n\nregisterProcessor('bitcrusher-processor', BitcrusherProcessor);\n"
  },
  {
    "path": "dev-utils/scripts/clientEventDispatcher.ts",
    "content": "\n/**\n * This event dispatcher will only dispatch events in the browser environment.\n * \n * NOTHING will happen if it is imported, or its methods are called, in the Node.js environment.\n */\n\n\n/** Whether the current environment is a browser. */\nconst isBrowser = typeof window !== 'undefined' && typeof window.dispatchEvent === 'function';\nconst target: Window = isBrowser ? window : null!;\n\n\n/**\n * Dispatches an event with the given name. If data is provided, a CustomEvent is dispatched\n * with the data in the detail property. Otherwise, a standard Event is dispatched.\n * @param eventName The name of the event to dispatch.\n * @param [data] Optional data to include in the event's detail property.\n */\nfunction dispatch(eventName: string, data?: any): void {\n\tif (!isBrowser) return;\n\tif (data !== undefined) target.dispatchEvent(new CustomEvent(eventName, { detail: data }));\n\telse target.dispatchEvent(new Event(eventName));\n}\n\n/**\n * Listens for an event with the given name.\n * @param eventName The name of the event to listen for.\n * @param callback The callback function to invoke when the event occurs.\n */\nfunction listen(eventName: string, callback: (event: CustomEvent) => void): void {\n\tif (!isBrowser) return;\n\ttarget.addEventListener(eventName, callback as EventListener);\n}\n\n/**\n * Removes a previously added event listener.\n * @param eventName The name of the event.\n * @param callback The callback function to remove.\n */\nfunction removeListener(eventName: string, callback: (event: CustomEvent) => void): void {\n\tif (!isBrowser) return;\n\ttarget.removeEventListener(eventName, callback as EventListener);\n}\n\nexport default {\n\tdispatch,\n\tlisten,\n\tremoveListener\n};"
  },
  {
    "path": "dev-utils/scripts/events.ts",
    "content": "// dev-utils/scripts/events.ts\n\n\n/**\n * A script that was intended for managing gamefile events for games\n * on both client and server ends.\n * \n * @author Idontuse\n */\n\n// Disabling this  cause will be using func types lots\n/* eslint-disable no-unused-vars */\n\nimport type gamefile from \"../../src/client/scripts/esm/chess/logic/gamefile\";\n\ntype ExtractArr<T extends any[]> = T extends (infer U)[] ? U : never\n \ninterface Eventlist {\n\t[eventName: string]: ((...args: any[]) => boolean)[]\n}\n\nfunction runEvent<E extends Eventlist, N extends keyof E, A extends Parameters<ExtractArr<E[N]>>>(eventlist: E, event: N, ...args: A): boolean {\n\tconst funcs = eventlist[event];\n\tif (funcs === undefined) return false;\n\tfor (const f of funcs) {\n\t\t// @ts-ignore ts thinks that the paramters of the function \"could\" not match the parameters of the function\n\t\tif (f(...args)) {\n\t\t\treturn true;\n\t\t}\n\t}\n\treturn false;\n}\n\nfunction addEventListener<E extends Eventlist, N extends keyof E, L extends ExtractArr<E[N]>>(eventlist: E, event: N, listener: L): void {\n\tconst listeners = eventlist[event];\n\tif (listeners === undefined) {\n\t\t// @ts-ignore it should work but ts thinks there could be a specific subtype where this errors\n\t\t// IT WILL ONLY BE AN ARRAY OF FUNCTIONS NO SUBTYPES NEEDED\n\t\teventlist[event] = [listener];\n\t\treturn;\n\t}\n\tlisteners.push(listener);\n\treturn;\n}\n\nfunction removeEventListener<E extends Eventlist, N extends keyof E, L extends ExtractArr<E[N]>>(eventlist: E, event: N, listener: L): boolean {\n\tconst listeners = eventlist[event];\n\tif (listeners === undefined) {\n\t\treturn false;\n\t}\n\tfor (let i = 0; i < listeners.length; i++ ) {\n\t\tif (listeners[i] !== listener) continue;\n\t\tlisteners.splice(i, 1);\n\t\treturn true;\n\t}\n\treturn false;\n}\n\n// import type { RegenerateHook } from \"./organizedpieces\";\n\n// interface GameEvents extends Eventlist {\n// \t// Runs when organizedPieces regenerate, DO NOT INTERRUPT.\n// \tregenerateLists: RegenerateHook[]\n// }\n\nexport type {\n\tEventlist,\n\n\t// GameEvents\n};\n\nexport default {\n\taddEventListener,\n\tremoveEventListener,\n\t\n\trunEvent,\n};"
  },
  {
    "path": "dev-utils/scripts/gl-matrix.js",
    "content": "\n/*!\n@fileoverview gl-matrix - High performance matrix and vector operations\n@author Brandon Jones\n@author Colin MacKenzie IV\n@version 3.4.0\n\nCopyright (c) 2015-2021, Brandon Jones, Colin MacKenzie IV.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n\n*/\n(function (global, factory) {\n  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :\n  typeof define === 'function' && define.amd ? define(['exports'], factory) :\n  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.glMatrix = {}));\n})(this, (function (exports) { 'use strict';\n\n  /**\n   * Common utilities\n   * @module glMatrix\n   */\n  // Configuration Constants\n  var EPSILON = 0.000001;\n  var ARRAY_TYPE = typeof Float32Array !== \"undefined\" ? Float32Array : Array;\n  var RANDOM = Math.random;\n  var ANGLE_ORDER = \"zyx\";\n  /**\n   * Sets the type of array used when creating new vectors and matrices\n   *\n   * @param {Float32ArrayConstructor | ArrayConstructor} type Array type, such as Float32Array or Array\n   */\n\n  function setMatrixArrayType(type) {\n    ARRAY_TYPE = type;\n  }\n  var degree = Math.PI / 180;\n  /**\n   * Convert Degree To Radian\n   *\n   * @param {Number} a Angle in Degrees\n   */\n\n  function toRadian(a) {\n    return a * degree;\n  }\n  /**\n   * Tests whether the arguments have approximately the same value, within an absolute\n   * or relative tolerance of glMatrix.EPSILON (an absolute tolerance is used for values less\n   * than or equal to 1.0, and a relative tolerance is used for larger values)\n   *\n   * @param {Number} a The first number to test.\n   * @param {Number} b The second number to test.\n   * @returns {Boolean} True if the numbers are approximately equal, false otherwise.\n   */\n\n  function equals$9(a, b) {\n    return Math.abs(a - b) <= EPSILON * Math.max(1.0, Math.abs(a), Math.abs(b));\n  }\n  if (!Math.hypot) Math.hypot = function () {\n    var y = 0,\n        i = arguments.length;\n\n    while (i--) {\n      y += arguments[i] * arguments[i];\n    }\n\n    return Math.sqrt(y);\n  };\n\n  var common = /*#__PURE__*/Object.freeze({\n    __proto__: null,\n    EPSILON: EPSILON,\n    get ARRAY_TYPE () { return ARRAY_TYPE; },\n    RANDOM: RANDOM,\n    ANGLE_ORDER: ANGLE_ORDER,\n    setMatrixArrayType: setMatrixArrayType,\n    toRadian: toRadian,\n    equals: equals$9\n  });\n\n  /**\n   * 2x2 Matrix\n   * @module mat2\n   */\n\n  /**\n   * Creates a new identity mat2\n   *\n   * @returns {mat2} a new 2x2 matrix\n   */\n\n  function create$8() {\n    var out = new ARRAY_TYPE(4);\n\n    if (ARRAY_TYPE != Float32Array) {\n      out[1] = 0;\n      out[2] = 0;\n    }\n\n    out[0] = 1;\n    out[3] = 1;\n    return out;\n  }\n  /**\n   * Creates a new mat2 initialized with values from an existing matrix\n   *\n   * @param {ReadonlyMat2} a matrix to clone\n   * @returns {mat2} a new 2x2 matrix\n   */\n\n  function clone$8(a) {\n    var out = new ARRAY_TYPE(4);\n    out[0] = a[0];\n    out[1] = a[1];\n    out[2] = a[2];\n    out[3] = a[3];\n    return out;\n  }\n  /**\n   * Copy the values from one mat2 to another\n   *\n   * @param {mat2} out the receiving matrix\n   * @param {ReadonlyMat2} a the source matrix\n   * @returns {mat2} out\n   */\n\n  function copy$8(out, a) {\n    out[0] = a[0];\n    out[1] = a[1];\n    out[2] = a[2];\n    out[3] = a[3];\n    return out;\n  }\n  /**\n   * Set a mat2 to the identity matrix\n   *\n   * @param {mat2} out the receiving matrix\n   * @returns {mat2} out\n   */\n\n  function identity$5(out) {\n    out[0] = 1;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 1;\n    return out;\n  }\n  /**\n   * Create a new mat2 with the given values\n   *\n   * @param {Number} m00 Component in column 0, row 0 position (index 0)\n   * @param {Number} m01 Component in column 0, row 1 position (index 1)\n   * @param {Number} m10 Component in column 1, row 0 position (index 2)\n   * @param {Number} m11 Component in column 1, row 1 position (index 3)\n   * @returns {mat2} out A new 2x2 matrix\n   */\n\n  function fromValues$8(m00, m01, m10, m11) {\n    var out = new ARRAY_TYPE(4);\n    out[0] = m00;\n    out[1] = m01;\n    out[2] = m10;\n    out[3] = m11;\n    return out;\n  }\n  /**\n   * Set the components of a mat2 to the given values\n   *\n   * @param {mat2} out the receiving matrix\n   * @param {Number} m00 Component in column 0, row 0 position (index 0)\n   * @param {Number} m01 Component in column 0, row 1 position (index 1)\n   * @param {Number} m10 Component in column 1, row 0 position (index 2)\n   * @param {Number} m11 Component in column 1, row 1 position (index 3)\n   * @returns {mat2} out\n   */\n\n  function set$8(out, m00, m01, m10, m11) {\n    out[0] = m00;\n    out[1] = m01;\n    out[2] = m10;\n    out[3] = m11;\n    return out;\n  }\n  /**\n   * Transpose the values of a mat2\n   *\n   * @param {mat2} out the receiving matrix\n   * @param {ReadonlyMat2} a the source matrix\n   * @returns {mat2} out\n   */\n\n  function transpose$2(out, a) {\n    // If we are transposing ourselves we can skip a few steps but have to cache\n    // some values\n    if (out === a) {\n      var a1 = a[1];\n      out[1] = a[2];\n      out[2] = a1;\n    } else {\n      out[0] = a[0];\n      out[1] = a[2];\n      out[2] = a[1];\n      out[3] = a[3];\n    }\n\n    return out;\n  }\n  /**\n   * Inverts a mat2\n   *\n   * @param {mat2} out the receiving matrix\n   * @param {ReadonlyMat2} a the source matrix\n   * @returns {mat2} out\n   */\n\n  function invert$5(out, a) {\n    var a0 = a[0],\n        a1 = a[1],\n        a2 = a[2],\n        a3 = a[3]; // Calculate the determinant\n\n    var det = a0 * a3 - a2 * a1;\n\n    if (!det) {\n      return null;\n    }\n\n    det = 1.0 / det;\n    out[0] = a3 * det;\n    out[1] = -a1 * det;\n    out[2] = -a2 * det;\n    out[3] = a0 * det;\n    return out;\n  }\n  /**\n   * Calculates the adjugate of a mat2\n   *\n   * @param {mat2} out the receiving matrix\n   * @param {ReadonlyMat2} a the source matrix\n   * @returns {mat2} out\n   */\n\n  function adjoint$2(out, a) {\n    // Caching this value is necessary if out == a\n    var a0 = a[0];\n    out[0] = a[3];\n    out[1] = -a[1];\n    out[2] = -a[2];\n    out[3] = a0;\n    return out;\n  }\n  /**\n   * Calculates the determinant of a mat2\n   *\n   * @param {ReadonlyMat2} a the source matrix\n   * @returns {Number} determinant of a\n   */\n\n  function determinant$3(a) {\n    return a[0] * a[3] - a[2] * a[1];\n  }\n  /**\n   * Multiplies two mat2's\n   *\n   * @param {mat2} out the receiving matrix\n   * @param {ReadonlyMat2} a the first operand\n   * @param {ReadonlyMat2} b the second operand\n   * @returns {mat2} out\n   */\n\n  function multiply$8(out, a, b) {\n    var a0 = a[0],\n        a1 = a[1],\n        a2 = a[2],\n        a3 = a[3];\n    var b0 = b[0],\n        b1 = b[1],\n        b2 = b[2],\n        b3 = b[3];\n    out[0] = a0 * b0 + a2 * b1;\n    out[1] = a1 * b0 + a3 * b1;\n    out[2] = a0 * b2 + a2 * b3;\n    out[3] = a1 * b2 + a3 * b3;\n    return out;\n  }\n  /**\n   * Rotates a mat2 by the given angle\n   *\n   * @param {mat2} out the receiving matrix\n   * @param {ReadonlyMat2} a the matrix to rotate\n   * @param {Number} rad the angle to rotate the matrix by\n   * @returns {mat2} out\n   */\n\n  function rotate$4(out, a, rad) {\n    var a0 = a[0],\n        a1 = a[1],\n        a2 = a[2],\n        a3 = a[3];\n    var s = Math.sin(rad);\n    var c = Math.cos(rad);\n    out[0] = a0 * c + a2 * s;\n    out[1] = a1 * c + a3 * s;\n    out[2] = a0 * -s + a2 * c;\n    out[3] = a1 * -s + a3 * c;\n    return out;\n  }\n  /**\n   * Scales the mat2 by the dimensions in the given vec2\n   *\n   * @param {mat2} out the receiving matrix\n   * @param {ReadonlyMat2} a the matrix to rotate\n   * @param {ReadonlyVec2} v the vec2 to scale the matrix by\n   * @returns {mat2} out\n   **/\n\n  function scale$8(out, a, v) {\n    var a0 = a[0],\n        a1 = a[1],\n        a2 = a[2],\n        a3 = a[3];\n    var v0 = v[0],\n        v1 = v[1];\n    out[0] = a0 * v0;\n    out[1] = a1 * v0;\n    out[2] = a2 * v1;\n    out[3] = a3 * v1;\n    return out;\n  }\n  /**\n   * Creates a matrix from a given angle\n   * This is equivalent to (but much faster than):\n   *\n   *     mat2.identity(dest);\n   *     mat2.rotate(dest, dest, rad);\n   *\n   * @param {mat2} out mat2 receiving operation result\n   * @param {Number} rad the angle to rotate the matrix by\n   * @returns {mat2} out\n   */\n\n  function fromRotation$4(out, rad) {\n    var s = Math.sin(rad);\n    var c = Math.cos(rad);\n    out[0] = c;\n    out[1] = s;\n    out[2] = -s;\n    out[3] = c;\n    return out;\n  }\n  /**\n   * Creates a matrix from a vector scaling\n   * This is equivalent to (but much faster than):\n   *\n   *     mat2.identity(dest);\n   *     mat2.scale(dest, dest, vec);\n   *\n   * @param {mat2} out mat2 receiving operation result\n   * @param {ReadonlyVec2} v Scaling vector\n   * @returns {mat2} out\n   */\n\n  function fromScaling$3(out, v) {\n    out[0] = v[0];\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = v[1];\n    return out;\n  }\n  /**\n   * Returns a string representation of a mat2\n   *\n   * @param {ReadonlyMat2} a matrix to represent as a string\n   * @returns {String} string representation of the matrix\n   */\n\n  function str$8(a) {\n    return \"mat2(\" + a[0] + \", \" + a[1] + \", \" + a[2] + \", \" + a[3] + \")\";\n  }\n  /**\n   * Returns Frobenius norm of a mat2\n   *\n   * @param {ReadonlyMat2} a the matrix to calculate Frobenius norm of\n   * @returns {Number} Frobenius norm\n   */\n\n  function frob$3(a) {\n    return Math.hypot(a[0], a[1], a[2], a[3]);\n  }\n  /**\n   * Returns L, D and U matrices (Lower triangular, Diagonal and Upper triangular) by factorizing the input matrix\n   * @param {ReadonlyMat2} L the lower triangular matrix\n   * @param {ReadonlyMat2} D the diagonal matrix\n   * @param {ReadonlyMat2} U the upper triangular matrix\n   * @param {ReadonlyMat2} a the input matrix to factorize\n   */\n\n  function LDU(L, D, U, a) {\n    L[2] = a[2] / a[0];\n    U[0] = a[0];\n    U[1] = a[1];\n    U[3] = a[3] - L[2] * U[1];\n    return [L, D, U];\n  }\n  /**\n   * Adds two mat2's\n   *\n   * @param {mat2} out the receiving matrix\n   * @param {ReadonlyMat2} a the first operand\n   * @param {ReadonlyMat2} b the second operand\n   * @returns {mat2} out\n   */\n\n  function add$8(out, a, b) {\n    out[0] = a[0] + b[0];\n    out[1] = a[1] + b[1];\n    out[2] = a[2] + b[2];\n    out[3] = a[3] + b[3];\n    return out;\n  }\n  /**\n   * Subtracts matrix b from matrix a\n   *\n   * @param {mat2} out the receiving matrix\n   * @param {ReadonlyMat2} a the first operand\n   * @param {ReadonlyMat2} b the second operand\n   * @returns {mat2} out\n   */\n\n  function subtract$6(out, a, b) {\n    out[0] = a[0] - b[0];\n    out[1] = a[1] - b[1];\n    out[2] = a[2] - b[2];\n    out[3] = a[3] - b[3];\n    return out;\n  }\n  /**\n   * Returns whether the matrices have exactly the same elements in the same position (when compared with ===)\n   *\n   * @param {ReadonlyMat2} a The first matrix.\n   * @param {ReadonlyMat2} b The second matrix.\n   * @returns {Boolean} True if the matrices are equal, false otherwise.\n   */\n\n  function exactEquals$8(a, b) {\n    return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];\n  }\n  /**\n   * Returns whether the matrices have approximately the same elements in the same position.\n   *\n   * @param {ReadonlyMat2} a The first matrix.\n   * @param {ReadonlyMat2} b The second matrix.\n   * @returns {Boolean} True if the matrices are equal, false otherwise.\n   */\n\n  function equals$8(a, b) {\n    var a0 = a[0],\n        a1 = a[1],\n        a2 = a[2],\n        a3 = a[3];\n    var b0 = b[0],\n        b1 = b[1],\n        b2 = b[2],\n        b3 = b[3];\n    return Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && Math.abs(a2 - b2) <= EPSILON * Math.max(1.0, Math.abs(a2), Math.abs(b2)) && Math.abs(a3 - b3) <= EPSILON * Math.max(1.0, Math.abs(a3), Math.abs(b3));\n  }\n  /**\n   * Multiply each element of the matrix by a scalar.\n   *\n   * @param {mat2} out the receiving matrix\n   * @param {ReadonlyMat2} a the matrix to scale\n   * @param {Number} b amount to scale the matrix's elements by\n   * @returns {mat2} out\n   */\n\n  function multiplyScalar$3(out, a, b) {\n    out[0] = a[0] * b;\n    out[1] = a[1] * b;\n    out[2] = a[2] * b;\n    out[3] = a[3] * b;\n    return out;\n  }\n  /**\n   * Adds two mat2's after multiplying each element of the second operand by a scalar value.\n   *\n   * @param {mat2} out the receiving vector\n   * @param {ReadonlyMat2} a the first operand\n   * @param {ReadonlyMat2} b the second operand\n   * @param {Number} scale the amount to scale b's elements by before adding\n   * @returns {mat2} out\n   */\n\n  function multiplyScalarAndAdd$3(out, a, b, scale) {\n    out[0] = a[0] + b[0] * scale;\n    out[1] = a[1] + b[1] * scale;\n    out[2] = a[2] + b[2] * scale;\n    out[3] = a[3] + b[3] * scale;\n    return out;\n  }\n  /**\n   * Alias for {@link mat2.multiply}\n   * @function\n   */\n\n  var mul$8 = multiply$8;\n  /**\n   * Alias for {@link mat2.subtract}\n   * @function\n   */\n\n  var sub$6 = subtract$6;\n\n  var mat2 = /*#__PURE__*/Object.freeze({\n    __proto__: null,\n    create: create$8,\n    clone: clone$8,\n    copy: copy$8,\n    identity: identity$5,\n    fromValues: fromValues$8,\n    set: set$8,\n    transpose: transpose$2,\n    invert: invert$5,\n    adjoint: adjoint$2,\n    determinant: determinant$3,\n    multiply: multiply$8,\n    rotate: rotate$4,\n    scale: scale$8,\n    fromRotation: fromRotation$4,\n    fromScaling: fromScaling$3,\n    str: str$8,\n    frob: frob$3,\n    LDU: LDU,\n    add: add$8,\n    subtract: subtract$6,\n    exactEquals: exactEquals$8,\n    equals: equals$8,\n    multiplyScalar: multiplyScalar$3,\n    multiplyScalarAndAdd: multiplyScalarAndAdd$3,\n    mul: mul$8,\n    sub: sub$6\n  });\n\n  /**\n   * 2x3 Matrix\n   * @module mat2d\n   * @description\n   * A mat2d contains six elements defined as:\n   * <pre>\n   * [a, b,\n   *  c, d,\n   *  tx, ty]\n   * </pre>\n   * This is a short form for the 3x3 matrix:\n   * <pre>\n   * [a, b, 0,\n   *  c, d, 0,\n   *  tx, ty, 1]\n   * </pre>\n   * The last column is ignored so the array is shorter and operations are faster.\n   */\n\n  /**\n   * Creates a new identity mat2d\n   *\n   * @returns {mat2d} a new 2x3 matrix\n   */\n\n  function create$7() {\n    var out = new ARRAY_TYPE(6);\n\n    if (ARRAY_TYPE != Float32Array) {\n      out[1] = 0;\n      out[2] = 0;\n      out[4] = 0;\n      out[5] = 0;\n    }\n\n    out[0] = 1;\n    out[3] = 1;\n    return out;\n  }\n  /**\n   * Creates a new mat2d initialized with values from an existing matrix\n   *\n   * @param {ReadonlyMat2d} a matrix to clone\n   * @returns {mat2d} a new 2x3 matrix\n   */\n\n  function clone$7(a) {\n    var out = new ARRAY_TYPE(6);\n    out[0] = a[0];\n    out[1] = a[1];\n    out[2] = a[2];\n    out[3] = a[3];\n    out[4] = a[4];\n    out[5] = a[5];\n    return out;\n  }\n  /**\n   * Copy the values from one mat2d to another\n   *\n   * @param {mat2d} out the receiving matrix\n   * @param {ReadonlyMat2d} a the source matrix\n   * @returns {mat2d} out\n   */\n\n  function copy$7(out, a) {\n    out[0] = a[0];\n    out[1] = a[1];\n    out[2] = a[2];\n    out[3] = a[3];\n    out[4] = a[4];\n    out[5] = a[5];\n    return out;\n  }\n  /**\n   * Set a mat2d to the identity matrix\n   *\n   * @param {mat2d} out the receiving matrix\n   * @returns {mat2d} out\n   */\n\n  function identity$4(out) {\n    out[0] = 1;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 1;\n    out[4] = 0;\n    out[5] = 0;\n    return out;\n  }\n  /**\n   * Create a new mat2d with the given values\n   *\n   * @param {Number} a Component A (index 0)\n   * @param {Number} b Component B (index 1)\n   * @param {Number} c Component C (index 2)\n   * @param {Number} d Component D (index 3)\n   * @param {Number} tx Component TX (index 4)\n   * @param {Number} ty Component TY (index 5)\n   * @returns {mat2d} A new mat2d\n   */\n\n  function fromValues$7(a, b, c, d, tx, ty) {\n    var out = new ARRAY_TYPE(6);\n    out[0] = a;\n    out[1] = b;\n    out[2] = c;\n    out[3] = d;\n    out[4] = tx;\n    out[5] = ty;\n    return out;\n  }\n  /**\n   * Set the components of a mat2d to the given values\n   *\n   * @param {mat2d} out the receiving matrix\n   * @param {Number} a Component A (index 0)\n   * @param {Number} b Component B (index 1)\n   * @param {Number} c Component C (index 2)\n   * @param {Number} d Component D (index 3)\n   * @param {Number} tx Component TX (index 4)\n   * @param {Number} ty Component TY (index 5)\n   * @returns {mat2d} out\n   */\n\n  function set$7(out, a, b, c, d, tx, ty) {\n    out[0] = a;\n    out[1] = b;\n    out[2] = c;\n    out[3] = d;\n    out[4] = tx;\n    out[5] = ty;\n    return out;\n  }\n  /**\n   * Inverts a mat2d\n   *\n   * @param {mat2d} out the receiving matrix\n   * @param {ReadonlyMat2d} a the source matrix\n   * @returns {mat2d} out\n   */\n\n  function invert$4(out, a) {\n    var aa = a[0],\n        ab = a[1],\n        ac = a[2],\n        ad = a[3];\n    var atx = a[4],\n        aty = a[5];\n    var det = aa * ad - ab * ac;\n\n    if (!det) {\n      return null;\n    }\n\n    det = 1.0 / det;\n    out[0] = ad * det;\n    out[1] = -ab * det;\n    out[2] = -ac * det;\n    out[3] = aa * det;\n    out[4] = (ac * aty - ad * atx) * det;\n    out[5] = (ab * atx - aa * aty) * det;\n    return out;\n  }\n  /**\n   * Calculates the determinant of a mat2d\n   *\n   * @param {ReadonlyMat2d} a the source matrix\n   * @returns {Number} determinant of a\n   */\n\n  function determinant$2(a) {\n    return a[0] * a[3] - a[1] * a[2];\n  }\n  /**\n   * Multiplies two mat2d's\n   *\n   * @param {mat2d} out the receiving matrix\n   * @param {ReadonlyMat2d} a the first operand\n   * @param {ReadonlyMat2d} b the second operand\n   * @returns {mat2d} out\n   */\n\n  function multiply$7(out, a, b) {\n    var a0 = a[0],\n        a1 = a[1],\n        a2 = a[2],\n        a3 = a[3],\n        a4 = a[4],\n        a5 = a[5];\n    var b0 = b[0],\n        b1 = b[1],\n        b2 = b[2],\n        b3 = b[3],\n        b4 = b[4],\n        b5 = b[5];\n    out[0] = a0 * b0 + a2 * b1;\n    out[1] = a1 * b0 + a3 * b1;\n    out[2] = a0 * b2 + a2 * b3;\n    out[3] = a1 * b2 + a3 * b3;\n    out[4] = a0 * b4 + a2 * b5 + a4;\n    out[5] = a1 * b4 + a3 * b5 + a5;\n    return out;\n  }\n  /**\n   * Rotates a mat2d by the given angle\n   *\n   * @param {mat2d} out the receiving matrix\n   * @param {ReadonlyMat2d} a the matrix to rotate\n   * @param {Number} rad the angle to rotate the matrix by\n   * @returns {mat2d} out\n   */\n\n  function rotate$3(out, a, rad) {\n    var a0 = a[0],\n        a1 = a[1],\n        a2 = a[2],\n        a3 = a[3],\n        a4 = a[4],\n        a5 = a[5];\n    var s = Math.sin(rad);\n    var c = Math.cos(rad);\n    out[0] = a0 * c + a2 * s;\n    out[1] = a1 * c + a3 * s;\n    out[2] = a0 * -s + a2 * c;\n    out[3] = a1 * -s + a3 * c;\n    out[4] = a4;\n    out[5] = a5;\n    return out;\n  }\n  /**\n   * Scales the mat2d by the dimensions in the given vec2\n   *\n   * @param {mat2d} out the receiving matrix\n   * @param {ReadonlyMat2d} a the matrix to translate\n   * @param {ReadonlyVec2} v the vec2 to scale the matrix by\n   * @returns {mat2d} out\n   **/\n\n  function scale$7(out, a, v) {\n    var a0 = a[0],\n        a1 = a[1],\n        a2 = a[2],\n        a3 = a[3],\n        a4 = a[4],\n        a5 = a[5];\n    var v0 = v[0],\n        v1 = v[1];\n    out[0] = a0 * v0;\n    out[1] = a1 * v0;\n    out[2] = a2 * v1;\n    out[3] = a3 * v1;\n    out[4] = a4;\n    out[5] = a5;\n    return out;\n  }\n  /**\n   * Translates the mat2d by the dimensions in the given vec2\n   *\n   * @param {mat2d} out the receiving matrix\n   * @param {ReadonlyMat2d} a the matrix to translate\n   * @param {ReadonlyVec2} v the vec2 to translate the matrix by\n   * @returns {mat2d} out\n   **/\n\n  function translate$3(out, a, v) {\n    var a0 = a[0],\n        a1 = a[1],\n        a2 = a[2],\n        a3 = a[3],\n        a4 = a[4],\n        a5 = a[5];\n    var v0 = v[0],\n        v1 = v[1];\n    out[0] = a0;\n    out[1] = a1;\n    out[2] = a2;\n    out[3] = a3;\n    out[4] = a0 * v0 + a2 * v1 + a4;\n    out[5] = a1 * v0 + a3 * v1 + a5;\n    return out;\n  }\n  /**\n   * Creates a matrix from a given angle\n   * This is equivalent to (but much faster than):\n   *\n   *     mat2d.identity(dest);\n   *     mat2d.rotate(dest, dest, rad);\n   *\n   * @param {mat2d} out mat2d receiving operation result\n   * @param {Number} rad the angle to rotate the matrix by\n   * @returns {mat2d} out\n   */\n\n  function fromRotation$3(out, rad) {\n    var s = Math.sin(rad),\n        c = Math.cos(rad);\n    out[0] = c;\n    out[1] = s;\n    out[2] = -s;\n    out[3] = c;\n    out[4] = 0;\n    out[5] = 0;\n    return out;\n  }\n  /**\n   * Creates a matrix from a vector scaling\n   * This is equivalent to (but much faster than):\n   *\n   *     mat2d.identity(dest);\n   *     mat2d.scale(dest, dest, vec);\n   *\n   * @param {mat2d} out mat2d receiving operation result\n   * @param {ReadonlyVec2} v Scaling vector\n   * @returns {mat2d} out\n   */\n\n  function fromScaling$2(out, v) {\n    out[0] = v[0];\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = v[1];\n    out[4] = 0;\n    out[5] = 0;\n    return out;\n  }\n  /**\n   * Creates a matrix from a vector translation\n   * This is equivalent to (but much faster than):\n   *\n   *     mat2d.identity(dest);\n   *     mat2d.translate(dest, dest, vec);\n   *\n   * @param {mat2d} out mat2d receiving operation result\n   * @param {ReadonlyVec2} v Translation vector\n   * @returns {mat2d} out\n   */\n\n  function fromTranslation$3(out, v) {\n    out[0] = 1;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 1;\n    out[4] = v[0];\n    out[5] = v[1];\n    return out;\n  }\n  /**\n   * Returns a string representation of a mat2d\n   *\n   * @param {ReadonlyMat2d} a matrix to represent as a string\n   * @returns {String} string representation of the matrix\n   */\n\n  function str$7(a) {\n    return \"mat2d(\" + a[0] + \", \" + a[1] + \", \" + a[2] + \", \" + a[3] + \", \" + a[4] + \", \" + a[5] + \")\";\n  }\n  /**\n   * Returns Frobenius norm of a mat2d\n   *\n   * @param {ReadonlyMat2d} a the matrix to calculate Frobenius norm of\n   * @returns {Number} Frobenius norm\n   */\n\n  function frob$2(a) {\n    return Math.hypot(a[0], a[1], a[2], a[3], a[4], a[5], 1);\n  }\n  /**\n   * Adds two mat2d's\n   *\n   * @param {mat2d} out the receiving matrix\n   * @param {ReadonlyMat2d} a the first operand\n   * @param {ReadonlyMat2d} b the second operand\n   * @returns {mat2d} out\n   */\n\n  function add$7(out, a, b) {\n    out[0] = a[0] + b[0];\n    out[1] = a[1] + b[1];\n    out[2] = a[2] + b[2];\n    out[3] = a[3] + b[3];\n    out[4] = a[4] + b[4];\n    out[5] = a[5] + b[5];\n    return out;\n  }\n  /**\n   * Subtracts matrix b from matrix a\n   *\n   * @param {mat2d} out the receiving matrix\n   * @param {ReadonlyMat2d} a the first operand\n   * @param {ReadonlyMat2d} b the second operand\n   * @returns {mat2d} out\n   */\n\n  function subtract$5(out, a, b) {\n    out[0] = a[0] - b[0];\n    out[1] = a[1] - b[1];\n    out[2] = a[2] - b[2];\n    out[3] = a[3] - b[3];\n    out[4] = a[4] - b[4];\n    out[5] = a[5] - b[5];\n    return out;\n  }\n  /**\n   * Multiply each element of the matrix by a scalar.\n   *\n   * @param {mat2d} out the receiving matrix\n   * @param {ReadonlyMat2d} a the matrix to scale\n   * @param {Number} b amount to scale the matrix's elements by\n   * @returns {mat2d} out\n   */\n\n  function multiplyScalar$2(out, a, b) {\n    out[0] = a[0] * b;\n    out[1] = a[1] * b;\n    out[2] = a[2] * b;\n    out[3] = a[3] * b;\n    out[4] = a[4] * b;\n    out[5] = a[5] * b;\n    return out;\n  }\n  /**\n   * Adds two mat2d's after multiplying each element of the second operand by a scalar value.\n   *\n   * @param {mat2d} out the receiving vector\n   * @param {ReadonlyMat2d} a the first operand\n   * @param {ReadonlyMat2d} b the second operand\n   * @param {Number} scale the amount to scale b's elements by before adding\n   * @returns {mat2d} out\n   */\n\n  function multiplyScalarAndAdd$2(out, a, b, scale) {\n    out[0] = a[0] + b[0] * scale;\n    out[1] = a[1] + b[1] * scale;\n    out[2] = a[2] + b[2] * scale;\n    out[3] = a[3] + b[3] * scale;\n    out[4] = a[4] + b[4] * scale;\n    out[5] = a[5] + b[5] * scale;\n    return out;\n  }\n  /**\n   * Returns whether the matrices have exactly the same elements in the same position (when compared with ===)\n   *\n   * @param {ReadonlyMat2d} a The first matrix.\n   * @param {ReadonlyMat2d} b The second matrix.\n   * @returns {Boolean} True if the matrices are equal, false otherwise.\n   */\n\n  function exactEquals$7(a, b) {\n    return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3] && a[4] === b[4] && a[5] === b[5];\n  }\n  /**\n   * Returns whether the matrices have approximately the same elements in the same position.\n   *\n   * @param {ReadonlyMat2d} a The first matrix.\n   * @param {ReadonlyMat2d} b The second matrix.\n   * @returns {Boolean} True if the matrices are equal, false otherwise.\n   */\n\n  function equals$7(a, b) {\n    var a0 = a[0],\n        a1 = a[1],\n        a2 = a[2],\n        a3 = a[3],\n        a4 = a[4],\n        a5 = a[5];\n    var b0 = b[0],\n        b1 = b[1],\n        b2 = b[2],\n        b3 = b[3],\n        b4 = b[4],\n        b5 = b[5];\n    return Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && Math.abs(a2 - b2) <= EPSILON * Math.max(1.0, Math.abs(a2), Math.abs(b2)) && Math.abs(a3 - b3) <= EPSILON * Math.max(1.0, Math.abs(a3), Math.abs(b3)) && Math.abs(a4 - b4) <= EPSILON * Math.max(1.0, Math.abs(a4), Math.abs(b4)) && Math.abs(a5 - b5) <= EPSILON * Math.max(1.0, Math.abs(a5), Math.abs(b5));\n  }\n  /**\n   * Alias for {@link mat2d.multiply}\n   * @function\n   */\n\n  var mul$7 = multiply$7;\n  /**\n   * Alias for {@link mat2d.subtract}\n   * @function\n   */\n\n  var sub$5 = subtract$5;\n\n  var mat2d = /*#__PURE__*/Object.freeze({\n    __proto__: null,\n    create: create$7,\n    clone: clone$7,\n    copy: copy$7,\n    identity: identity$4,\n    fromValues: fromValues$7,\n    set: set$7,\n    invert: invert$4,\n    determinant: determinant$2,\n    multiply: multiply$7,\n    rotate: rotate$3,\n    scale: scale$7,\n    translate: translate$3,\n    fromRotation: fromRotation$3,\n    fromScaling: fromScaling$2,\n    fromTranslation: fromTranslation$3,\n    str: str$7,\n    frob: frob$2,\n    add: add$7,\n    subtract: subtract$5,\n    multiplyScalar: multiplyScalar$2,\n    multiplyScalarAndAdd: multiplyScalarAndAdd$2,\n    exactEquals: exactEquals$7,\n    equals: equals$7,\n    mul: mul$7,\n    sub: sub$5\n  });\n\n  /**\n   * 3x3 Matrix\n   * @module mat3\n   */\n\n  /**\n   * Creates a new identity mat3\n   *\n   * @returns {mat3} a new 3x3 matrix\n   */\n\n  function create$6() {\n    var out = new ARRAY_TYPE(9);\n\n    if (ARRAY_TYPE != Float32Array) {\n      out[1] = 0;\n      out[2] = 0;\n      out[3] = 0;\n      out[5] = 0;\n      out[6] = 0;\n      out[7] = 0;\n    }\n\n    out[0] = 1;\n    out[4] = 1;\n    out[8] = 1;\n    return out;\n  }\n  /**\n   * Copies the upper-left 3x3 values into the given mat3.\n   *\n   * @param {mat3} out the receiving 3x3 matrix\n   * @param {ReadonlyMat4} a   the source 4x4 matrix\n   * @returns {mat3} out\n   */\n\n  function fromMat4$1(out, a) {\n    out[0] = a[0];\n    out[1] = a[1];\n    out[2] = a[2];\n    out[3] = a[4];\n    out[4] = a[5];\n    out[5] = a[6];\n    out[6] = a[8];\n    out[7] = a[9];\n    out[8] = a[10];\n    return out;\n  }\n  /**\n   * Creates a new mat3 initialized with values from an existing matrix\n   *\n   * @param {ReadonlyMat3} a matrix to clone\n   * @returns {mat3} a new 3x3 matrix\n   */\n\n  function clone$6(a) {\n    var out = new ARRAY_TYPE(9);\n    out[0] = a[0];\n    out[1] = a[1];\n    out[2] = a[2];\n    out[3] = a[3];\n    out[4] = a[4];\n    out[5] = a[5];\n    out[6] = a[6];\n    out[7] = a[7];\n    out[8] = a[8];\n    return out;\n  }\n  /**\n   * Copy the values from one mat3 to another\n   *\n   * @param {mat3} out the receiving matrix\n   * @param {ReadonlyMat3} a the source matrix\n   * @returns {mat3} out\n   */\n\n  function copy$6(out, a) {\n    out[0] = a[0];\n    out[1] = a[1];\n    out[2] = a[2];\n    out[3] = a[3];\n    out[4] = a[4];\n    out[5] = a[5];\n    out[6] = a[6];\n    out[7] = a[7];\n    out[8] = a[8];\n    return out;\n  }\n  /**\n   * Create a new mat3 with the given values\n   *\n   * @param {Number} m00 Component in column 0, row 0 position (index 0)\n   * @param {Number} m01 Component in column 0, row 1 position (index 1)\n   * @param {Number} m02 Component in column 0, row 2 position (index 2)\n   * @param {Number} m10 Component in column 1, row 0 position (index 3)\n   * @param {Number} m11 Component in column 1, row 1 position (index 4)\n   * @param {Number} m12 Component in column 1, row 2 position (index 5)\n   * @param {Number} m20 Component in column 2, row 0 position (index 6)\n   * @param {Number} m21 Component in column 2, row 1 position (index 7)\n   * @param {Number} m22 Component in column 2, row 2 position (index 8)\n   * @returns {mat3} A new mat3\n   */\n\n  function fromValues$6(m00, m01, m02, m10, m11, m12, m20, m21, m22) {\n    var out = new ARRAY_TYPE(9);\n    out[0] = m00;\n    out[1] = m01;\n    out[2] = m02;\n    out[3] = m10;\n    out[4] = m11;\n    out[5] = m12;\n    out[6] = m20;\n    out[7] = m21;\n    out[8] = m22;\n    return out;\n  }\n  /**\n   * Set the components of a mat3 to the given values\n   *\n   * @param {mat3} out the receiving matrix\n   * @param {Number} m00 Component in column 0, row 0 position (index 0)\n   * @param {Number} m01 Component in column 0, row 1 position (index 1)\n   * @param {Number} m02 Component in column 0, row 2 position (index 2)\n   * @param {Number} m10 Component in column 1, row 0 position (index 3)\n   * @param {Number} m11 Component in column 1, row 1 position (index 4)\n   * @param {Number} m12 Component in column 1, row 2 position (index 5)\n   * @param {Number} m20 Component in column 2, row 0 position (index 6)\n   * @param {Number} m21 Component in column 2, row 1 position (index 7)\n   * @param {Number} m22 Component in column 2, row 2 position (index 8)\n   * @returns {mat3} out\n   */\n\n  function set$6(out, m00, m01, m02, m10, m11, m12, m20, m21, m22) {\n    out[0] = m00;\n    out[1] = m01;\n    out[2] = m02;\n    out[3] = m10;\n    out[4] = m11;\n    out[5] = m12;\n    out[6] = m20;\n    out[7] = m21;\n    out[8] = m22;\n    return out;\n  }\n  /**\n   * Set a mat3 to the identity matrix\n   *\n   * @param {mat3} out the receiving matrix\n   * @returns {mat3} out\n   */\n\n  function identity$3(out) {\n    out[0] = 1;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 0;\n    out[4] = 1;\n    out[5] = 0;\n    out[6] = 0;\n    out[7] = 0;\n    out[8] = 1;\n    return out;\n  }\n  /**\n   * Transpose the values of a mat3\n   *\n   * @param {mat3} out the receiving matrix\n   * @param {ReadonlyMat3} a the source matrix\n   * @returns {mat3} out\n   */\n\n  function transpose$1(out, a) {\n    // If we are transposing ourselves we can skip a few steps but have to cache some values\n    if (out === a) {\n      var a01 = a[1],\n          a02 = a[2],\n          a12 = a[5];\n      out[1] = a[3];\n      out[2] = a[6];\n      out[3] = a01;\n      out[5] = a[7];\n      out[6] = a02;\n      out[7] = a12;\n    } else {\n      out[0] = a[0];\n      out[1] = a[3];\n      out[2] = a[6];\n      out[3] = a[1];\n      out[4] = a[4];\n      out[5] = a[7];\n      out[6] = a[2];\n      out[7] = a[5];\n      out[8] = a[8];\n    }\n\n    return out;\n  }\n  /**\n   * Inverts a mat3\n   *\n   * @param {mat3} out the receiving matrix\n   * @param {ReadonlyMat3} a the source matrix\n   * @returns {mat3} out\n   */\n\n  function invert$3(out, a) {\n    var a00 = a[0],\n        a01 = a[1],\n        a02 = a[2];\n    var a10 = a[3],\n        a11 = a[4],\n        a12 = a[5];\n    var a20 = a[6],\n        a21 = a[7],\n        a22 = a[8];\n    var b01 = a22 * a11 - a12 * a21;\n    var b11 = -a22 * a10 + a12 * a20;\n    var b21 = a21 * a10 - a11 * a20; // Calculate the determinant\n\n    var det = a00 * b01 + a01 * b11 + a02 * b21;\n\n    if (!det) {\n      return null;\n    }\n\n    det = 1.0 / det;\n    out[0] = b01 * det;\n    out[1] = (-a22 * a01 + a02 * a21) * det;\n    out[2] = (a12 * a01 - a02 * a11) * det;\n    out[3] = b11 * det;\n    out[4] = (a22 * a00 - a02 * a20) * det;\n    out[5] = (-a12 * a00 + a02 * a10) * det;\n    out[6] = b21 * det;\n    out[7] = (-a21 * a00 + a01 * a20) * det;\n    out[8] = (a11 * a00 - a01 * a10) * det;\n    return out;\n  }\n  /**\n   * Calculates the adjugate of a mat3\n   *\n   * @param {mat3} out the receiving matrix\n   * @param {ReadonlyMat3} a the source matrix\n   * @returns {mat3} out\n   */\n\n  function adjoint$1(out, a) {\n    var a00 = a[0],\n        a01 = a[1],\n        a02 = a[2];\n    var a10 = a[3],\n        a11 = a[4],\n        a12 = a[5];\n    var a20 = a[6],\n        a21 = a[7],\n        a22 = a[8];\n    out[0] = a11 * a22 - a12 * a21;\n    out[1] = a02 * a21 - a01 * a22;\n    out[2] = a01 * a12 - a02 * a11;\n    out[3] = a12 * a20 - a10 * a22;\n    out[4] = a00 * a22 - a02 * a20;\n    out[5] = a02 * a10 - a00 * a12;\n    out[6] = a10 * a21 - a11 * a20;\n    out[7] = a01 * a20 - a00 * a21;\n    out[8] = a00 * a11 - a01 * a10;\n    return out;\n  }\n  /**\n   * Calculates the determinant of a mat3\n   *\n   * @param {ReadonlyMat3} a the source matrix\n   * @returns {Number} determinant of a\n   */\n\n  function determinant$1(a) {\n    var a00 = a[0],\n        a01 = a[1],\n        a02 = a[2];\n    var a10 = a[3],\n        a11 = a[4],\n        a12 = a[5];\n    var a20 = a[6],\n        a21 = a[7],\n        a22 = a[8];\n    return a00 * (a22 * a11 - a12 * a21) + a01 * (-a22 * a10 + a12 * a20) + a02 * (a21 * a10 - a11 * a20);\n  }\n  /**\n   * Multiplies two mat3's\n   *\n   * @param {mat3} out the receiving matrix\n   * @param {ReadonlyMat3} a the first operand\n   * @param {ReadonlyMat3} b the second operand\n   * @returns {mat3} out\n   */\n\n  function multiply$6(out, a, b) {\n    var a00 = a[0],\n        a01 = a[1],\n        a02 = a[2];\n    var a10 = a[3],\n        a11 = a[4],\n        a12 = a[5];\n    var a20 = a[6],\n        a21 = a[7],\n        a22 = a[8];\n    var b00 = b[0],\n        b01 = b[1],\n        b02 = b[2];\n    var b10 = b[3],\n        b11 = b[4],\n        b12 = b[5];\n    var b20 = b[6],\n        b21 = b[7],\n        b22 = b[8];\n    out[0] = b00 * a00 + b01 * a10 + b02 * a20;\n    out[1] = b00 * a01 + b01 * a11 + b02 * a21;\n    out[2] = b00 * a02 + b01 * a12 + b02 * a22;\n    out[3] = b10 * a00 + b11 * a10 + b12 * a20;\n    out[4] = b10 * a01 + b11 * a11 + b12 * a21;\n    out[5] = b10 * a02 + b11 * a12 + b12 * a22;\n    out[6] = b20 * a00 + b21 * a10 + b22 * a20;\n    out[7] = b20 * a01 + b21 * a11 + b22 * a21;\n    out[8] = b20 * a02 + b21 * a12 + b22 * a22;\n    return out;\n  }\n  /**\n   * Translate a mat3 by the given vector\n   *\n   * @param {mat3} out the receiving matrix\n   * @param {ReadonlyMat3} a the matrix to translate\n   * @param {ReadonlyVec2} v vector to translate by\n   * @returns {mat3} out\n   */\n\n  function translate$2(out, a, v) {\n    var a00 = a[0],\n        a01 = a[1],\n        a02 = a[2],\n        a10 = a[3],\n        a11 = a[4],\n        a12 = a[5],\n        a20 = a[6],\n        a21 = a[7],\n        a22 = a[8],\n        x = v[0],\n        y = v[1];\n    out[0] = a00;\n    out[1] = a01;\n    out[2] = a02;\n    out[3] = a10;\n    out[4] = a11;\n    out[5] = a12;\n    out[6] = x * a00 + y * a10 + a20;\n    out[7] = x * a01 + y * a11 + a21;\n    out[8] = x * a02 + y * a12 + a22;\n    return out;\n  }\n  /**\n   * Rotates a mat3 by the given angle\n   *\n   * @param {mat3} out the receiving matrix\n   * @param {ReadonlyMat3} a the matrix to rotate\n   * @param {Number} rad the angle to rotate the matrix by\n   * @returns {mat3} out\n   */\n\n  function rotate$2(out, a, rad) {\n    var a00 = a[0],\n        a01 = a[1],\n        a02 = a[2],\n        a10 = a[3],\n        a11 = a[4],\n        a12 = a[5],\n        a20 = a[6],\n        a21 = a[7],\n        a22 = a[8],\n        s = Math.sin(rad),\n        c = Math.cos(rad);\n    out[0] = c * a00 + s * a10;\n    out[1] = c * a01 + s * a11;\n    out[2] = c * a02 + s * a12;\n    out[3] = c * a10 - s * a00;\n    out[4] = c * a11 - s * a01;\n    out[5] = c * a12 - s * a02;\n    out[6] = a20;\n    out[7] = a21;\n    out[8] = a22;\n    return out;\n  }\n  /**\n   * Scales the mat3 by the dimensions in the given vec2\n   *\n   * @param {mat3} out the receiving matrix\n   * @param {ReadonlyMat3} a the matrix to rotate\n   * @param {ReadonlyVec2} v the vec2 to scale the matrix by\n   * @returns {mat3} out\n   **/\n\n  function scale$6(out, a, v) {\n    var x = v[0],\n        y = v[1];\n    out[0] = x * a[0];\n    out[1] = x * a[1];\n    out[2] = x * a[2];\n    out[3] = y * a[3];\n    out[4] = y * a[4];\n    out[5] = y * a[5];\n    out[6] = a[6];\n    out[7] = a[7];\n    out[8] = a[8];\n    return out;\n  }\n  /**\n   * Creates a matrix from a vector translation\n   * This is equivalent to (but much faster than):\n   *\n   *     mat3.identity(dest);\n   *     mat3.translate(dest, dest, vec);\n   *\n   * @param {mat3} out mat3 receiving operation result\n   * @param {ReadonlyVec2} v Translation vector\n   * @returns {mat3} out\n   */\n\n  function fromTranslation$2(out, v) {\n    out[0] = 1;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 0;\n    out[4] = 1;\n    out[5] = 0;\n    out[6] = v[0];\n    out[7] = v[1];\n    out[8] = 1;\n    return out;\n  }\n  /**\n   * Creates a matrix from a given angle\n   * This is equivalent to (but much faster than):\n   *\n   *     mat3.identity(dest);\n   *     mat3.rotate(dest, dest, rad);\n   *\n   * @param {mat3} out mat3 receiving operation result\n   * @param {Number} rad the angle to rotate the matrix by\n   * @returns {mat3} out\n   */\n\n  function fromRotation$2(out, rad) {\n    var s = Math.sin(rad),\n        c = Math.cos(rad);\n    out[0] = c;\n    out[1] = s;\n    out[2] = 0;\n    out[3] = -s;\n    out[4] = c;\n    out[5] = 0;\n    out[6] = 0;\n    out[7] = 0;\n    out[8] = 1;\n    return out;\n  }\n  /**\n   * Creates a matrix from a vector scaling\n   * This is equivalent to (but much faster than):\n   *\n   *     mat3.identity(dest);\n   *     mat3.scale(dest, dest, vec);\n   *\n   * @param {mat3} out mat3 receiving operation result\n   * @param {ReadonlyVec2} v Scaling vector\n   * @returns {mat3} out\n   */\n\n  function fromScaling$1(out, v) {\n    out[0] = v[0];\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 0;\n    out[4] = v[1];\n    out[5] = 0;\n    out[6] = 0;\n    out[7] = 0;\n    out[8] = 1;\n    return out;\n  }\n  /**\n   * Copies the values from a mat2d into a mat3\n   *\n   * @param {mat3} out the receiving matrix\n   * @param {ReadonlyMat2d} a the matrix to copy\n   * @returns {mat3} out\n   **/\n\n  function fromMat2d(out, a) {\n    out[0] = a[0];\n    out[1] = a[1];\n    out[2] = 0;\n    out[3] = a[2];\n    out[4] = a[3];\n    out[5] = 0;\n    out[6] = a[4];\n    out[7] = a[5];\n    out[8] = 1;\n    return out;\n  }\n  /**\n   * Calculates a 3x3 matrix from the given quaternion\n   *\n   * @param {mat3} out mat3 receiving operation result\n   * @param {ReadonlyQuat} q Quaternion to create matrix from\n   *\n   * @returns {mat3} out\n   */\n\n  function fromQuat$1(out, q) {\n    var x = q[0],\n        y = q[1],\n        z = q[2],\n        w = q[3];\n    var x2 = x + x;\n    var y2 = y + y;\n    var z2 = z + z;\n    var xx = x * x2;\n    var yx = y * x2;\n    var yy = y * y2;\n    var zx = z * x2;\n    var zy = z * y2;\n    var zz = z * z2;\n    var wx = w * x2;\n    var wy = w * y2;\n    var wz = w * z2;\n    out[0] = 1 - yy - zz;\n    out[3] = yx - wz;\n    out[6] = zx + wy;\n    out[1] = yx + wz;\n    out[4] = 1 - xx - zz;\n    out[7] = zy - wx;\n    out[2] = zx - wy;\n    out[5] = zy + wx;\n    out[8] = 1 - xx - yy;\n    return out;\n  }\n  /**\n   * Calculates a 3x3 normal matrix (transpose inverse) from the 4x4 matrix\n   *\n   * @param {mat3} out mat3 receiving operation result\n   * @param {ReadonlyMat4} a Mat4 to derive the normal matrix from\n   *\n   * @returns {mat3} out\n   */\n\n  function normalFromMat4(out, a) {\n    var a00 = a[0],\n        a01 = a[1],\n        a02 = a[2],\n        a03 = a[3];\n    var a10 = a[4],\n        a11 = a[5],\n        a12 = a[6],\n        a13 = a[7];\n    var a20 = a[8],\n        a21 = a[9],\n        a22 = a[10],\n        a23 = a[11];\n    var a30 = a[12],\n        a31 = a[13],\n        a32 = a[14],\n        a33 = a[15];\n    var b00 = a00 * a11 - a01 * a10;\n    var b01 = a00 * a12 - a02 * a10;\n    var b02 = a00 * a13 - a03 * a10;\n    var b03 = a01 * a12 - a02 * a11;\n    var b04 = a01 * a13 - a03 * a11;\n    var b05 = a02 * a13 - a03 * a12;\n    var b06 = a20 * a31 - a21 * a30;\n    var b07 = a20 * a32 - a22 * a30;\n    var b08 = a20 * a33 - a23 * a30;\n    var b09 = a21 * a32 - a22 * a31;\n    var b10 = a21 * a33 - a23 * a31;\n    var b11 = a22 * a33 - a23 * a32; // Calculate the determinant\n\n    var det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;\n\n    if (!det) {\n      return null;\n    }\n\n    det = 1.0 / det;\n    out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det;\n    out[1] = (a12 * b08 - a10 * b11 - a13 * b07) * det;\n    out[2] = (a10 * b10 - a11 * b08 + a13 * b06) * det;\n    out[3] = (a02 * b10 - a01 * b11 - a03 * b09) * det;\n    out[4] = (a00 * b11 - a02 * b08 + a03 * b07) * det;\n    out[5] = (a01 * b08 - a00 * b10 - a03 * b06) * det;\n    out[6] = (a31 * b05 - a32 * b04 + a33 * b03) * det;\n    out[7] = (a32 * b02 - a30 * b05 - a33 * b01) * det;\n    out[8] = (a30 * b04 - a31 * b02 + a33 * b00) * det;\n    return out;\n  }\n  /**\n   * Generates a 2D projection matrix with the given bounds\n   *\n   * @param {mat3} out mat3 frustum matrix will be written into\n   * @param {number} width Width of your gl context\n   * @param {number} height Height of gl context\n   * @returns {mat3} out\n   */\n\n  function projection(out, width, height) {\n    out[0] = 2 / width;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 0;\n    out[4] = -2 / height;\n    out[5] = 0;\n    out[6] = -1;\n    out[7] = 1;\n    out[8] = 1;\n    return out;\n  }\n  /**\n   * Returns a string representation of a mat3\n   *\n   * @param {ReadonlyMat3} a matrix to represent as a string\n   * @returns {String} string representation of the matrix\n   */\n\n  function str$6(a) {\n    return \"mat3(\" + a[0] + \", \" + a[1] + \", \" + a[2] + \", \" + a[3] + \", \" + a[4] + \", \" + a[5] + \", \" + a[6] + \", \" + a[7] + \", \" + a[8] + \")\";\n  }\n  /**\n   * Returns Frobenius norm of a mat3\n   *\n   * @param {ReadonlyMat3} a the matrix to calculate Frobenius norm of\n   * @returns {Number} Frobenius norm\n   */\n\n  function frob$1(a) {\n    return Math.hypot(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8]);\n  }\n  /**\n   * Adds two mat3's\n   *\n   * @param {mat3} out the receiving matrix\n   * @param {ReadonlyMat3} a the first operand\n   * @param {ReadonlyMat3} b the second operand\n   * @returns {mat3} out\n   */\n\n  function add$6(out, a, b) {\n    out[0] = a[0] + b[0];\n    out[1] = a[1] + b[1];\n    out[2] = a[2] + b[2];\n    out[3] = a[3] + b[3];\n    out[4] = a[4] + b[4];\n    out[5] = a[5] + b[5];\n    out[6] = a[6] + b[6];\n    out[7] = a[7] + b[7];\n    out[8] = a[8] + b[8];\n    return out;\n  }\n  /**\n   * Subtracts matrix b from matrix a\n   *\n   * @param {mat3} out the receiving matrix\n   * @param {ReadonlyMat3} a the first operand\n   * @param {ReadonlyMat3} b the second operand\n   * @returns {mat3} out\n   */\n\n  function subtract$4(out, a, b) {\n    out[0] = a[0] - b[0];\n    out[1] = a[1] - b[1];\n    out[2] = a[2] - b[2];\n    out[3] = a[3] - b[3];\n    out[4] = a[4] - b[4];\n    out[5] = a[5] - b[5];\n    out[6] = a[6] - b[6];\n    out[7] = a[7] - b[7];\n    out[8] = a[8] - b[8];\n    return out;\n  }\n  /**\n   * Multiply each element of the matrix by a scalar.\n   *\n   * @param {mat3} out the receiving matrix\n   * @param {ReadonlyMat3} a the matrix to scale\n   * @param {Number} b amount to scale the matrix's elements by\n   * @returns {mat3} out\n   */\n\n  function multiplyScalar$1(out, a, b) {\n    out[0] = a[0] * b;\n    out[1] = a[1] * b;\n    out[2] = a[2] * b;\n    out[3] = a[3] * b;\n    out[4] = a[4] * b;\n    out[5] = a[5] * b;\n    out[6] = a[6] * b;\n    out[7] = a[7] * b;\n    out[8] = a[8] * b;\n    return out;\n  }\n  /**\n   * Adds two mat3's after multiplying each element of the second operand by a scalar value.\n   *\n   * @param {mat3} out the receiving vector\n   * @param {ReadonlyMat3} a the first operand\n   * @param {ReadonlyMat3} b the second operand\n   * @param {Number} scale the amount to scale b's elements by before adding\n   * @returns {mat3} out\n   */\n\n  function multiplyScalarAndAdd$1(out, a, b, scale) {\n    out[0] = a[0] + b[0] * scale;\n    out[1] = a[1] + b[1] * scale;\n    out[2] = a[2] + b[2] * scale;\n    out[3] = a[3] + b[3] * scale;\n    out[4] = a[4] + b[4] * scale;\n    out[5] = a[5] + b[5] * scale;\n    out[6] = a[6] + b[6] * scale;\n    out[7] = a[7] + b[7] * scale;\n    out[8] = a[8] + b[8] * scale;\n    return out;\n  }\n  /**\n   * Returns whether the matrices have exactly the same elements in the same position (when compared with ===)\n   *\n   * @param {ReadonlyMat3} a The first matrix.\n   * @param {ReadonlyMat3} b The second matrix.\n   * @returns {Boolean} True if the matrices are equal, false otherwise.\n   */\n\n  function exactEquals$6(a, b) {\n    return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3] && a[4] === b[4] && a[5] === b[5] && a[6] === b[6] && a[7] === b[7] && a[8] === b[8];\n  }\n  /**\n   * Returns whether the matrices have approximately the same elements in the same position.\n   *\n   * @param {ReadonlyMat3} a The first matrix.\n   * @param {ReadonlyMat3} b The second matrix.\n   * @returns {Boolean} True if the matrices are equal, false otherwise.\n   */\n\n  function equals$6(a, b) {\n    var a0 = a[0],\n        a1 = a[1],\n        a2 = a[2],\n        a3 = a[3],\n        a4 = a[4],\n        a5 = a[5],\n        a6 = a[6],\n        a7 = a[7],\n        a8 = a[8];\n    var b0 = b[0],\n        b1 = b[1],\n        b2 = b[2],\n        b3 = b[3],\n        b4 = b[4],\n        b5 = b[5],\n        b6 = b[6],\n        b7 = b[7],\n        b8 = b[8];\n    return Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && Math.abs(a2 - b2) <= EPSILON * Math.max(1.0, Math.abs(a2), Math.abs(b2)) && Math.abs(a3 - b3) <= EPSILON * Math.max(1.0, Math.abs(a3), Math.abs(b3)) && Math.abs(a4 - b4) <= EPSILON * Math.max(1.0, Math.abs(a4), Math.abs(b4)) && Math.abs(a5 - b5) <= EPSILON * Math.max(1.0, Math.abs(a5), Math.abs(b5)) && Math.abs(a6 - b6) <= EPSILON * Math.max(1.0, Math.abs(a6), Math.abs(b6)) && Math.abs(a7 - b7) <= EPSILON * Math.max(1.0, Math.abs(a7), Math.abs(b7)) && Math.abs(a8 - b8) <= EPSILON * Math.max(1.0, Math.abs(a8), Math.abs(b8));\n  }\n  /**\n   * Alias for {@link mat3.multiply}\n   * @function\n   */\n\n  var mul$6 = multiply$6;\n  /**\n   * Alias for {@link mat3.subtract}\n   * @function\n   */\n\n  var sub$4 = subtract$4;\n\n  var mat3 = /*#__PURE__*/Object.freeze({\n    __proto__: null,\n    create: create$6,\n    fromMat4: fromMat4$1,\n    clone: clone$6,\n    copy: copy$6,\n    fromValues: fromValues$6,\n    set: set$6,\n    identity: identity$3,\n    transpose: transpose$1,\n    invert: invert$3,\n    adjoint: adjoint$1,\n    determinant: determinant$1,\n    multiply: multiply$6,\n    translate: translate$2,\n    rotate: rotate$2,\n    scale: scale$6,\n    fromTranslation: fromTranslation$2,\n    fromRotation: fromRotation$2,\n    fromScaling: fromScaling$1,\n    fromMat2d: fromMat2d,\n    fromQuat: fromQuat$1,\n    normalFromMat4: normalFromMat4,\n    projection: projection,\n    str: str$6,\n    frob: frob$1,\n    add: add$6,\n    subtract: subtract$4,\n    multiplyScalar: multiplyScalar$1,\n    multiplyScalarAndAdd: multiplyScalarAndAdd$1,\n    exactEquals: exactEquals$6,\n    equals: equals$6,\n    mul: mul$6,\n    sub: sub$4\n  });\n\n  /**\n   * 4x4 Matrix<br>Format: column-major, when typed out it looks like row-major<br>The matrices are being post multiplied.\n   * @module mat4\n   */\n\n  /**\n   * Creates a new identity mat4\n   *\n   * @returns {mat4} a new 4x4 matrix\n   */\n\n  function create$5() {\n    var out = new ARRAY_TYPE(16);\n\n    if (ARRAY_TYPE != Float32Array) {\n      out[1] = 0;\n      out[2] = 0;\n      out[3] = 0;\n      out[4] = 0;\n      out[6] = 0;\n      out[7] = 0;\n      out[8] = 0;\n      out[9] = 0;\n      out[11] = 0;\n      out[12] = 0;\n      out[13] = 0;\n      out[14] = 0;\n    }\n\n    out[0] = 1;\n    out[5] = 1;\n    out[10] = 1;\n    out[15] = 1;\n    return out;\n  }\n  /**\n   * Creates a new mat4 initialized with values from an existing matrix\n   *\n   * @param {ReadonlyMat4} a matrix to clone\n   * @returns {mat4} a new 4x4 matrix\n   */\n\n  function clone$5(a) {\n    var out = new ARRAY_TYPE(16);\n    out[0] = a[0];\n    out[1] = a[1];\n    out[2] = a[2];\n    out[3] = a[3];\n    out[4] = a[4];\n    out[5] = a[5];\n    out[6] = a[6];\n    out[7] = a[7];\n    out[8] = a[8];\n    out[9] = a[9];\n    out[10] = a[10];\n    out[11] = a[11];\n    out[12] = a[12];\n    out[13] = a[13];\n    out[14] = a[14];\n    out[15] = a[15];\n    return out;\n  }\n  /**\n   * Copy the values from one mat4 to another\n   *\n   * @param {mat4} out the receiving matrix\n   * @param {ReadonlyMat4} a the source matrix\n   * @returns {mat4} out\n   */\n\n  function copy$5(out, a) {\n    out[0] = a[0];\n    out[1] = a[1];\n    out[2] = a[2];\n    out[3] = a[3];\n    out[4] = a[4];\n    out[5] = a[5];\n    out[6] = a[6];\n    out[7] = a[7];\n    out[8] = a[8];\n    out[9] = a[9];\n    out[10] = a[10];\n    out[11] = a[11];\n    out[12] = a[12];\n    out[13] = a[13];\n    out[14] = a[14];\n    out[15] = a[15];\n    return out;\n  }\n  /**\n   * Create a new mat4 with the given values\n   *\n   * @param {Number} m00 Component in column 0, row 0 position (index 0)\n   * @param {Number} m01 Component in column 0, row 1 position (index 1)\n   * @param {Number} m02 Component in column 0, row 2 position (index 2)\n   * @param {Number} m03 Component in column 0, row 3 position (index 3)\n   * @param {Number} m10 Component in column 1, row 0 position (index 4)\n   * @param {Number} m11 Component in column 1, row 1 position (index 5)\n   * @param {Number} m12 Component in column 1, row 2 position (index 6)\n   * @param {Number} m13 Component in column 1, row 3 position (index 7)\n   * @param {Number} m20 Component in column 2, row 0 position (index 8)\n   * @param {Number} m21 Component in column 2, row 1 position (index 9)\n   * @param {Number} m22 Component in column 2, row 2 position (index 10)\n   * @param {Number} m23 Component in column 2, row 3 position (index 11)\n   * @param {Number} m30 Component in column 3, row 0 position (index 12)\n   * @param {Number} m31 Component in column 3, row 1 position (index 13)\n   * @param {Number} m32 Component in column 3, row 2 position (index 14)\n   * @param {Number} m33 Component in column 3, row 3 position (index 15)\n   * @returns {mat4} A new mat4\n   */\n\n  function fromValues$5(m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, m30, m31, m32, m33) {\n    var out = new ARRAY_TYPE(16);\n    out[0] = m00;\n    out[1] = m01;\n    out[2] = m02;\n    out[3] = m03;\n    out[4] = m10;\n    out[5] = m11;\n    out[6] = m12;\n    out[7] = m13;\n    out[8] = m20;\n    out[9] = m21;\n    out[10] = m22;\n    out[11] = m23;\n    out[12] = m30;\n    out[13] = m31;\n    out[14] = m32;\n    out[15] = m33;\n    return out;\n  }\n  /**\n   * Set the components of a mat4 to the given values\n   *\n   * @param {mat4} out the receiving matrix\n   * @param {Number} m00 Component in column 0, row 0 position (index 0)\n   * @param {Number} m01 Component in column 0, row 1 position (index 1)\n   * @param {Number} m02 Component in column 0, row 2 position (index 2)\n   * @param {Number} m03 Component in column 0, row 3 position (index 3)\n   * @param {Number} m10 Component in column 1, row 0 position (index 4)\n   * @param {Number} m11 Component in column 1, row 1 position (index 5)\n   * @param {Number} m12 Component in column 1, row 2 position (index 6)\n   * @param {Number} m13 Component in column 1, row 3 position (index 7)\n   * @param {Number} m20 Component in column 2, row 0 position (index 8)\n   * @param {Number} m21 Component in column 2, row 1 position (index 9)\n   * @param {Number} m22 Component in column 2, row 2 position (index 10)\n   * @param {Number} m23 Component in column 2, row 3 position (index 11)\n   * @param {Number} m30 Component in column 3, row 0 position (index 12)\n   * @param {Number} m31 Component in column 3, row 1 position (index 13)\n   * @param {Number} m32 Component in column 3, row 2 position (index 14)\n   * @param {Number} m33 Component in column 3, row 3 position (index 15)\n   * @returns {mat4} out\n   */\n\n  function set$5(out, m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, m30, m31, m32, m33) {\n    out[0] = m00;\n    out[1] = m01;\n    out[2] = m02;\n    out[3] = m03;\n    out[4] = m10;\n    out[5] = m11;\n    out[6] = m12;\n    out[7] = m13;\n    out[8] = m20;\n    out[9] = m21;\n    out[10] = m22;\n    out[11] = m23;\n    out[12] = m30;\n    out[13] = m31;\n    out[14] = m32;\n    out[15] = m33;\n    return out;\n  }\n  /**\n   * Set a mat4 to the identity matrix\n   *\n   * @param {mat4} out the receiving matrix\n   * @returns {mat4} out\n   */\n\n  function identity$2(out) {\n    out[0] = 1;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 0;\n    out[4] = 0;\n    out[5] = 1;\n    out[6] = 0;\n    out[7] = 0;\n    out[8] = 0;\n    out[9] = 0;\n    out[10] = 1;\n    out[11] = 0;\n    out[12] = 0;\n    out[13] = 0;\n    out[14] = 0;\n    out[15] = 1;\n    return out;\n  }\n  /**\n   * Transpose the values of a mat4\n   *\n   * @param {mat4} out the receiving matrix\n   * @param {ReadonlyMat4} a the source matrix\n   * @returns {mat4} out\n   */\n\n  function transpose(out, a) {\n    // If we are transposing ourselves we can skip a few steps but have to cache some values\n    if (out === a) {\n      var a01 = a[1],\n          a02 = a[2],\n          a03 = a[3];\n      var a12 = a[6],\n          a13 = a[7];\n      var a23 = a[11];\n      out[1] = a[4];\n      out[2] = a[8];\n      out[3] = a[12];\n      out[4] = a01;\n      out[6] = a[9];\n      out[7] = a[13];\n      out[8] = a02;\n      out[9] = a12;\n      out[11] = a[14];\n      out[12] = a03;\n      out[13] = a13;\n      out[14] = a23;\n    } else {\n      out[0] = a[0];\n      out[1] = a[4];\n      out[2] = a[8];\n      out[3] = a[12];\n      out[4] = a[1];\n      out[5] = a[5];\n      out[6] = a[9];\n      out[7] = a[13];\n      out[8] = a[2];\n      out[9] = a[6];\n      out[10] = a[10];\n      out[11] = a[14];\n      out[12] = a[3];\n      out[13] = a[7];\n      out[14] = a[11];\n      out[15] = a[15];\n    }\n\n    return out;\n  }\n  /**\n   * Inverts a mat4\n   *\n   * @param {mat4} out the receiving matrix\n   * @param {ReadonlyMat4} a the source matrix\n   * @returns {mat4} out\n   */\n\n  function invert$2(out, a) {\n    var a00 = a[0],\n        a01 = a[1],\n        a02 = a[2],\n        a03 = a[3];\n    var a10 = a[4],\n        a11 = a[5],\n        a12 = a[6],\n        a13 = a[7];\n    var a20 = a[8],\n        a21 = a[9],\n        a22 = a[10],\n        a23 = a[11];\n    var a30 = a[12],\n        a31 = a[13],\n        a32 = a[14],\n        a33 = a[15];\n    var b00 = a00 * a11 - a01 * a10;\n    var b01 = a00 * a12 - a02 * a10;\n    var b02 = a00 * a13 - a03 * a10;\n    var b03 = a01 * a12 - a02 * a11;\n    var b04 = a01 * a13 - a03 * a11;\n    var b05 = a02 * a13 - a03 * a12;\n    var b06 = a20 * a31 - a21 * a30;\n    var b07 = a20 * a32 - a22 * a30;\n    var b08 = a20 * a33 - a23 * a30;\n    var b09 = a21 * a32 - a22 * a31;\n    var b10 = a21 * a33 - a23 * a31;\n    var b11 = a22 * a33 - a23 * a32; // Calculate the determinant\n\n    var det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;\n\n    if (!det) {\n      return null;\n    }\n\n    det = 1.0 / det;\n    out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det;\n    out[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det;\n    out[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det;\n    out[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det;\n    out[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det;\n    out[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det;\n    out[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det;\n    out[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det;\n    out[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det;\n    out[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det;\n    out[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det;\n    out[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det;\n    out[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det;\n    out[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det;\n    out[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det;\n    out[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det;\n    return out;\n  }\n  /**\n   * Calculates the adjugate of a mat4\n   *\n   * @param {mat4} out the receiving matrix\n   * @param {ReadonlyMat4} a the source matrix\n   * @returns {mat4} out\n   */\n\n  function adjoint(out, a) {\n    var a00 = a[0],\n        a01 = a[1],\n        a02 = a[2],\n        a03 = a[3];\n    var a10 = a[4],\n        a11 = a[5],\n        a12 = a[6],\n        a13 = a[7];\n    var a20 = a[8],\n        a21 = a[9],\n        a22 = a[10],\n        a23 = a[11];\n    var a30 = a[12],\n        a31 = a[13],\n        a32 = a[14],\n        a33 = a[15];\n    var b00 = a00 * a11 - a01 * a10;\n    var b01 = a00 * a12 - a02 * a10;\n    var b02 = a00 * a13 - a03 * a10;\n    var b03 = a01 * a12 - a02 * a11;\n    var b04 = a01 * a13 - a03 * a11;\n    var b05 = a02 * a13 - a03 * a12;\n    var b06 = a20 * a31 - a21 * a30;\n    var b07 = a20 * a32 - a22 * a30;\n    var b08 = a20 * a33 - a23 * a30;\n    var b09 = a21 * a32 - a22 * a31;\n    var b10 = a21 * a33 - a23 * a31;\n    var b11 = a22 * a33 - a23 * a32;\n    out[0] = a11 * b11 - a12 * b10 + a13 * b09;\n    out[1] = a02 * b10 - a01 * b11 - a03 * b09;\n    out[2] = a31 * b05 - a32 * b04 + a33 * b03;\n    out[3] = a22 * b04 - a21 * b05 - a23 * b03;\n    out[4] = a12 * b08 - a10 * b11 - a13 * b07;\n    out[5] = a00 * b11 - a02 * b08 + a03 * b07;\n    out[6] = a32 * b02 - a30 * b05 - a33 * b01;\n    out[7] = a20 * b05 - a22 * b02 + a23 * b01;\n    out[8] = a10 * b10 - a11 * b08 + a13 * b06;\n    out[9] = a01 * b08 - a00 * b10 - a03 * b06;\n    out[10] = a30 * b04 - a31 * b02 + a33 * b00;\n    out[11] = a21 * b02 - a20 * b04 - a23 * b00;\n    out[12] = a11 * b07 - a10 * b09 - a12 * b06;\n    out[13] = a00 * b09 - a01 * b07 + a02 * b06;\n    out[14] = a31 * b01 - a30 * b03 - a32 * b00;\n    out[15] = a20 * b03 - a21 * b01 + a22 * b00;\n    return out;\n  }\n  /**\n   * Calculates the determinant of a mat4\n   *\n   * @param {ReadonlyMat4} a the source matrix\n   * @returns {Number} determinant of a\n   */\n\n  function determinant(a) {\n    var a00 = a[0],\n        a01 = a[1],\n        a02 = a[2],\n        a03 = a[3];\n    var a10 = a[4],\n        a11 = a[5],\n        a12 = a[6],\n        a13 = a[7];\n    var a20 = a[8],\n        a21 = a[9],\n        a22 = a[10],\n        a23 = a[11];\n    var a30 = a[12],\n        a31 = a[13],\n        a32 = a[14],\n        a33 = a[15];\n    var b0 = a00 * a11 - a01 * a10;\n    var b1 = a00 * a12 - a02 * a10;\n    var b2 = a01 * a12 - a02 * a11;\n    var b3 = a20 * a31 - a21 * a30;\n    var b4 = a20 * a32 - a22 * a30;\n    var b5 = a21 * a32 - a22 * a31;\n    var b6 = a00 * b5 - a01 * b4 + a02 * b3;\n    var b7 = a10 * b5 - a11 * b4 + a12 * b3;\n    var b8 = a20 * b2 - a21 * b1 + a22 * b0;\n    var b9 = a30 * b2 - a31 * b1 + a32 * b0; // Calculate the determinant\n\n    return a13 * b6 - a03 * b7 + a33 * b8 - a23 * b9;\n  }\n  /**\n   * Multiplies two mat4s\n   *\n   * @param {mat4} out the receiving matrix\n   * @param {ReadonlyMat4} a the first operand\n   * @param {ReadonlyMat4} b the second operand\n   * @returns {mat4} out\n   */\n\n  function multiply$5(out, a, b) {\n    var a00 = a[0],\n        a01 = a[1],\n        a02 = a[2],\n        a03 = a[3];\n    var a10 = a[4],\n        a11 = a[5],\n        a12 = a[6],\n        a13 = a[7];\n    var a20 = a[8],\n        a21 = a[9],\n        a22 = a[10],\n        a23 = a[11];\n    var a30 = a[12],\n        a31 = a[13],\n        a32 = a[14],\n        a33 = a[15]; // Cache only the current line of the second matrix\n\n    var b0 = b[0],\n        b1 = b[1],\n        b2 = b[2],\n        b3 = b[3];\n    out[0] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;\n    out[1] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;\n    out[2] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;\n    out[3] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;\n    b0 = b[4];\n    b1 = b[5];\n    b2 = b[6];\n    b3 = b[7];\n    out[4] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;\n    out[5] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;\n    out[6] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;\n    out[7] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;\n    b0 = b[8];\n    b1 = b[9];\n    b2 = b[10];\n    b3 = b[11];\n    out[8] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;\n    out[9] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;\n    out[10] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;\n    out[11] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;\n    b0 = b[12];\n    b1 = b[13];\n    b2 = b[14];\n    b3 = b[15];\n    out[12] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;\n    out[13] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;\n    out[14] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;\n    out[15] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;\n    return out;\n  }\n  /**\n   * Translate a mat4 by the given vector\n   *\n   * @param {mat4} out the receiving matrix\n   * @param {ReadonlyMat4} a the matrix to translate\n   * @param {ReadonlyVec3} v vector to translate by\n   * @returns {mat4} out\n   */\n\n  function translate$1(out, a, v) {\n    var x = v[0],\n        y = v[1],\n        z = v[2];\n    var a00, a01, a02, a03;\n    var a10, a11, a12, a13;\n    var a20, a21, a22, a23;\n\n    if (a === out) {\n      out[12] = a[0] * x + a[4] * y + a[8] * z + a[12];\n      out[13] = a[1] * x + a[5] * y + a[9] * z + a[13];\n      out[14] = a[2] * x + a[6] * y + a[10] * z + a[14];\n      out[15] = a[3] * x + a[7] * y + a[11] * z + a[15];\n    } else {\n      a00 = a[0];\n      a01 = a[1];\n      a02 = a[2];\n      a03 = a[3];\n      a10 = a[4];\n      a11 = a[5];\n      a12 = a[6];\n      a13 = a[7];\n      a20 = a[8];\n      a21 = a[9];\n      a22 = a[10];\n      a23 = a[11];\n      out[0] = a00;\n      out[1] = a01;\n      out[2] = a02;\n      out[3] = a03;\n      out[4] = a10;\n      out[5] = a11;\n      out[6] = a12;\n      out[7] = a13;\n      out[8] = a20;\n      out[9] = a21;\n      out[10] = a22;\n      out[11] = a23;\n      out[12] = a00 * x + a10 * y + a20 * z + a[12];\n      out[13] = a01 * x + a11 * y + a21 * z + a[13];\n      out[14] = a02 * x + a12 * y + a22 * z + a[14];\n      out[15] = a03 * x + a13 * y + a23 * z + a[15];\n    }\n\n    return out;\n  }\n  /**\n   * Scales the mat4 by the dimensions in the given vec3 not using vectorization\n   *\n   * @param {mat4} out the receiving matrix\n   * @param {ReadonlyMat4} a the matrix to scale\n   * @param {ReadonlyVec3} v the vec3 to scale the matrix by\n   * @returns {mat4} out\n   **/\n\n  function scale$5(out, a, v) {\n    var x = v[0],\n        y = v[1],\n        z = v[2];\n    out[0] = a[0] * x;\n    out[1] = a[1] * x;\n    out[2] = a[2] * x;\n    out[3] = a[3] * x;\n    out[4] = a[4] * y;\n    out[5] = a[5] * y;\n    out[6] = a[6] * y;\n    out[7] = a[7] * y;\n    out[8] = a[8] * z;\n    out[9] = a[9] * z;\n    out[10] = a[10] * z;\n    out[11] = a[11] * z;\n    out[12] = a[12];\n    out[13] = a[13];\n    out[14] = a[14];\n    out[15] = a[15];\n    return out;\n  }\n  /**\n   * Rotates a mat4 by the given angle around the given axis\n   *\n   * @param {mat4} out the receiving matrix\n   * @param {ReadonlyMat4} a the matrix to rotate\n   * @param {Number} rad the angle to rotate the matrix by\n   * @param {ReadonlyVec3} axis the axis to rotate around\n   * @returns {mat4} out\n   */\n\n  function rotate$1(out, a, rad, axis) {\n    var x = axis[0],\n        y = axis[1],\n        z = axis[2];\n    var len = Math.hypot(x, y, z);\n    var s, c, t;\n    var a00, a01, a02, a03;\n    var a10, a11, a12, a13;\n    var a20, a21, a22, a23;\n    var b00, b01, b02;\n    var b10, b11, b12;\n    var b20, b21, b22;\n\n    if (len < EPSILON) {\n      return null;\n    }\n\n    len = 1 / len;\n    x *= len;\n    y *= len;\n    z *= len;\n    s = Math.sin(rad);\n    c = Math.cos(rad);\n    t = 1 - c;\n    a00 = a[0];\n    a01 = a[1];\n    a02 = a[2];\n    a03 = a[3];\n    a10 = a[4];\n    a11 = a[5];\n    a12 = a[6];\n    a13 = a[7];\n    a20 = a[8];\n    a21 = a[9];\n    a22 = a[10];\n    a23 = a[11]; // Construct the elements of the rotation matrix\n\n    b00 = x * x * t + c;\n    b01 = y * x * t + z * s;\n    b02 = z * x * t - y * s;\n    b10 = x * y * t - z * s;\n    b11 = y * y * t + c;\n    b12 = z * y * t + x * s;\n    b20 = x * z * t + y * s;\n    b21 = y * z * t - x * s;\n    b22 = z * z * t + c; // Perform rotation-specific matrix multiplication\n\n    out[0] = a00 * b00 + a10 * b01 + a20 * b02;\n    out[1] = a01 * b00 + a11 * b01 + a21 * b02;\n    out[2] = a02 * b00 + a12 * b01 + a22 * b02;\n    out[3] = a03 * b00 + a13 * b01 + a23 * b02;\n    out[4] = a00 * b10 + a10 * b11 + a20 * b12;\n    out[5] = a01 * b10 + a11 * b11 + a21 * b12;\n    out[6] = a02 * b10 + a12 * b11 + a22 * b12;\n    out[7] = a03 * b10 + a13 * b11 + a23 * b12;\n    out[8] = a00 * b20 + a10 * b21 + a20 * b22;\n    out[9] = a01 * b20 + a11 * b21 + a21 * b22;\n    out[10] = a02 * b20 + a12 * b21 + a22 * b22;\n    out[11] = a03 * b20 + a13 * b21 + a23 * b22;\n\n    if (a !== out) {\n      // If the source and destination differ, copy the unchanged last row\n      out[12] = a[12];\n      out[13] = a[13];\n      out[14] = a[14];\n      out[15] = a[15];\n    }\n\n    return out;\n  }\n  /**\n   * Rotates a matrix by the given angle around the X axis\n   *\n   * @param {mat4} out the receiving matrix\n   * @param {ReadonlyMat4} a the matrix to rotate\n   * @param {Number} rad the angle to rotate the matrix by\n   * @returns {mat4} out\n   */\n\n  function rotateX$3(out, a, rad) {\n    var s = Math.sin(rad);\n    var c = Math.cos(rad);\n    var a10 = a[4];\n    var a11 = a[5];\n    var a12 = a[6];\n    var a13 = a[7];\n    var a20 = a[8];\n    var a21 = a[9];\n    var a22 = a[10];\n    var a23 = a[11];\n\n    if (a !== out) {\n      // If the source and destination differ, copy the unchanged rows\n      out[0] = a[0];\n      out[1] = a[1];\n      out[2] = a[2];\n      out[3] = a[3];\n      out[12] = a[12];\n      out[13] = a[13];\n      out[14] = a[14];\n      out[15] = a[15];\n    } // Perform axis-specific matrix multiplication\n\n\n    out[4] = a10 * c + a20 * s;\n    out[5] = a11 * c + a21 * s;\n    out[6] = a12 * c + a22 * s;\n    out[7] = a13 * c + a23 * s;\n    out[8] = a20 * c - a10 * s;\n    out[9] = a21 * c - a11 * s;\n    out[10] = a22 * c - a12 * s;\n    out[11] = a23 * c - a13 * s;\n    return out;\n  }\n  /**\n   * Rotates a matrix by the given angle around the Y axis\n   *\n   * @param {mat4} out the receiving matrix\n   * @param {ReadonlyMat4} a the matrix to rotate\n   * @param {Number} rad the angle to rotate the matrix by\n   * @returns {mat4} out\n   */\n\n  function rotateY$3(out, a, rad) {\n    var s = Math.sin(rad);\n    var c = Math.cos(rad);\n    var a00 = a[0];\n    var a01 = a[1];\n    var a02 = a[2];\n    var a03 = a[3];\n    var a20 = a[8];\n    var a21 = a[9];\n    var a22 = a[10];\n    var a23 = a[11];\n\n    if (a !== out) {\n      // If the source and destination differ, copy the unchanged rows\n      out[4] = a[4];\n      out[5] = a[5];\n      out[6] = a[6];\n      out[7] = a[7];\n      out[12] = a[12];\n      out[13] = a[13];\n      out[14] = a[14];\n      out[15] = a[15];\n    } // Perform axis-specific matrix multiplication\n\n\n    out[0] = a00 * c - a20 * s;\n    out[1] = a01 * c - a21 * s;\n    out[2] = a02 * c - a22 * s;\n    out[3] = a03 * c - a23 * s;\n    out[8] = a00 * s + a20 * c;\n    out[9] = a01 * s + a21 * c;\n    out[10] = a02 * s + a22 * c;\n    out[11] = a03 * s + a23 * c;\n    return out;\n  }\n  /**\n   * Rotates a matrix by the given angle around the Z axis\n   *\n   * @param {mat4} out the receiving matrix\n   * @param {ReadonlyMat4} a the matrix to rotate\n   * @param {Number} rad the angle to rotate the matrix by\n   * @returns {mat4} out\n   */\n\n  function rotateZ$3(out, a, rad) {\n    var s = Math.sin(rad);\n    var c = Math.cos(rad);\n    var a00 = a[0];\n    var a01 = a[1];\n    var a02 = a[2];\n    var a03 = a[3];\n    var a10 = a[4];\n    var a11 = a[5];\n    var a12 = a[6];\n    var a13 = a[7];\n\n    if (a !== out) {\n      // If the source and destination differ, copy the unchanged last row\n      out[8] = a[8];\n      out[9] = a[9];\n      out[10] = a[10];\n      out[11] = a[11];\n      out[12] = a[12];\n      out[13] = a[13];\n      out[14] = a[14];\n      out[15] = a[15];\n    } // Perform axis-specific matrix multiplication\n\n\n    out[0] = a00 * c + a10 * s;\n    out[1] = a01 * c + a11 * s;\n    out[2] = a02 * c + a12 * s;\n    out[3] = a03 * c + a13 * s;\n    out[4] = a10 * c - a00 * s;\n    out[5] = a11 * c - a01 * s;\n    out[6] = a12 * c - a02 * s;\n    out[7] = a13 * c - a03 * s;\n    return out;\n  }\n  /**\n   * Creates a matrix from a vector translation\n   * This is equivalent to (but much faster than):\n   *\n   *     mat4.identity(dest);\n   *     mat4.translate(dest, dest, vec);\n   *\n   * @param {mat4} out mat4 receiving operation result\n   * @param {ReadonlyVec3} v Translation vector\n   * @returns {mat4} out\n   */\n\n  function fromTranslation$1(out, v) {\n    out[0] = 1;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 0;\n    out[4] = 0;\n    out[5] = 1;\n    out[6] = 0;\n    out[7] = 0;\n    out[8] = 0;\n    out[9] = 0;\n    out[10] = 1;\n    out[11] = 0;\n    out[12] = v[0];\n    out[13] = v[1];\n    out[14] = v[2];\n    out[15] = 1;\n    return out;\n  }\n  /**\n   * Creates a matrix from a vector scaling\n   * This is equivalent to (but much faster than):\n   *\n   *     mat4.identity(dest);\n   *     mat4.scale(dest, dest, vec);\n   *\n   * @param {mat4} out mat4 receiving operation result\n   * @param {ReadonlyVec3} v Scaling vector\n   * @returns {mat4} out\n   */\n\n  function fromScaling(out, v) {\n    out[0] = v[0];\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 0;\n    out[4] = 0;\n    out[5] = v[1];\n    out[6] = 0;\n    out[7] = 0;\n    out[8] = 0;\n    out[9] = 0;\n    out[10] = v[2];\n    out[11] = 0;\n    out[12] = 0;\n    out[13] = 0;\n    out[14] = 0;\n    out[15] = 1;\n    return out;\n  }\n  /**\n   * Creates a matrix from a given angle around a given axis\n   * This is equivalent to (but much faster than):\n   *\n   *     mat4.identity(dest);\n   *     mat4.rotate(dest, dest, rad, axis);\n   *\n   * @param {mat4} out mat4 receiving operation result\n   * @param {Number} rad the angle to rotate the matrix by\n   * @param {ReadonlyVec3} axis the axis to rotate around\n   * @returns {mat4} out\n   */\n\n  function fromRotation$1(out, rad, axis) {\n    var x = axis[0],\n        y = axis[1],\n        z = axis[2];\n    var len = Math.hypot(x, y, z);\n    var s, c, t;\n\n    if (len < EPSILON) {\n      return null;\n    }\n\n    len = 1 / len;\n    x *= len;\n    y *= len;\n    z *= len;\n    s = Math.sin(rad);\n    c = Math.cos(rad);\n    t = 1 - c; // Perform rotation-specific matrix multiplication\n\n    out[0] = x * x * t + c;\n    out[1] = y * x * t + z * s;\n    out[2] = z * x * t - y * s;\n    out[3] = 0;\n    out[4] = x * y * t - z * s;\n    out[5] = y * y * t + c;\n    out[6] = z * y * t + x * s;\n    out[7] = 0;\n    out[8] = x * z * t + y * s;\n    out[9] = y * z * t - x * s;\n    out[10] = z * z * t + c;\n    out[11] = 0;\n    out[12] = 0;\n    out[13] = 0;\n    out[14] = 0;\n    out[15] = 1;\n    return out;\n  }\n  /**\n   * Creates a matrix from the given angle around the X axis\n   * This is equivalent to (but much faster than):\n   *\n   *     mat4.identity(dest);\n   *     mat4.rotateX(dest, dest, rad);\n   *\n   * @param {mat4} out mat4 receiving operation result\n   * @param {Number} rad the angle to rotate the matrix by\n   * @returns {mat4} out\n   */\n\n  function fromXRotation(out, rad) {\n    var s = Math.sin(rad);\n    var c = Math.cos(rad); // Perform axis-specific matrix multiplication\n\n    out[0] = 1;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 0;\n    out[4] = 0;\n    out[5] = c;\n    out[6] = s;\n    out[7] = 0;\n    out[8] = 0;\n    out[9] = -s;\n    out[10] = c;\n    out[11] = 0;\n    out[12] = 0;\n    out[13] = 0;\n    out[14] = 0;\n    out[15] = 1;\n    return out;\n  }\n  /**\n   * Creates a matrix from the given angle around the Y axis\n   * This is equivalent to (but much faster than):\n   *\n   *     mat4.identity(dest);\n   *     mat4.rotateY(dest, dest, rad);\n   *\n   * @param {mat4} out mat4 receiving operation result\n   * @param {Number} rad the angle to rotate the matrix by\n   * @returns {mat4} out\n   */\n\n  function fromYRotation(out, rad) {\n    var s = Math.sin(rad);\n    var c = Math.cos(rad); // Perform axis-specific matrix multiplication\n\n    out[0] = c;\n    out[1] = 0;\n    out[2] = -s;\n    out[3] = 0;\n    out[4] = 0;\n    out[5] = 1;\n    out[6] = 0;\n    out[7] = 0;\n    out[8] = s;\n    out[9] = 0;\n    out[10] = c;\n    out[11] = 0;\n    out[12] = 0;\n    out[13] = 0;\n    out[14] = 0;\n    out[15] = 1;\n    return out;\n  }\n  /**\n   * Creates a matrix from the given angle around the Z axis\n   * This is equivalent to (but much faster than):\n   *\n   *     mat4.identity(dest);\n   *     mat4.rotateZ(dest, dest, rad);\n   *\n   * @param {mat4} out mat4 receiving operation result\n   * @param {Number} rad the angle to rotate the matrix by\n   * @returns {mat4} out\n   */\n\n  function fromZRotation(out, rad) {\n    var s = Math.sin(rad);\n    var c = Math.cos(rad); // Perform axis-specific matrix multiplication\n\n    out[0] = c;\n    out[1] = s;\n    out[2] = 0;\n    out[3] = 0;\n    out[4] = -s;\n    out[5] = c;\n    out[6] = 0;\n    out[7] = 0;\n    out[8] = 0;\n    out[9] = 0;\n    out[10] = 1;\n    out[11] = 0;\n    out[12] = 0;\n    out[13] = 0;\n    out[14] = 0;\n    out[15] = 1;\n    return out;\n  }\n  /**\n   * Creates a matrix from a quaternion rotation and vector translation\n   * This is equivalent to (but much faster than):\n   *\n   *     mat4.identity(dest);\n   *     mat4.translate(dest, vec);\n   *     let quatMat = mat4.create();\n   *     quat4.toMat4(quat, quatMat);\n   *     mat4.multiply(dest, quatMat);\n   *\n   * @param {mat4} out mat4 receiving operation result\n   * @param {quat4} q Rotation quaternion\n   * @param {ReadonlyVec3} v Translation vector\n   * @returns {mat4} out\n   */\n\n  function fromRotationTranslation$1(out, q, v) {\n    // Quaternion math\n    var x = q[0],\n        y = q[1],\n        z = q[2],\n        w = q[3];\n    var x2 = x + x;\n    var y2 = y + y;\n    var z2 = z + z;\n    var xx = x * x2;\n    var xy = x * y2;\n    var xz = x * z2;\n    var yy = y * y2;\n    var yz = y * z2;\n    var zz = z * z2;\n    var wx = w * x2;\n    var wy = w * y2;\n    var wz = w * z2;\n    out[0] = 1 - (yy + zz);\n    out[1] = xy + wz;\n    out[2] = xz - wy;\n    out[3] = 0;\n    out[4] = xy - wz;\n    out[5] = 1 - (xx + zz);\n    out[6] = yz + wx;\n    out[7] = 0;\n    out[8] = xz + wy;\n    out[9] = yz - wx;\n    out[10] = 1 - (xx + yy);\n    out[11] = 0;\n    out[12] = v[0];\n    out[13] = v[1];\n    out[14] = v[2];\n    out[15] = 1;\n    return out;\n  }\n  /**\n   * Creates a new mat4 from a dual quat.\n   *\n   * @param {mat4} out Matrix\n   * @param {ReadonlyQuat2} a Dual Quaternion\n   * @returns {mat4} mat4 receiving operation result\n   */\n\n  function fromQuat2(out, a) {\n    var translation = new ARRAY_TYPE(3);\n    var bx = -a[0],\n        by = -a[1],\n        bz = -a[2],\n        bw = a[3],\n        ax = a[4],\n        ay = a[5],\n        az = a[6],\n        aw = a[7];\n    var magnitude = bx * bx + by * by + bz * bz + bw * bw; //Only scale if it makes sense\n\n    if (magnitude > 0) {\n      translation[0] = (ax * bw + aw * bx + ay * bz - az * by) * 2 / magnitude;\n      translation[1] = (ay * bw + aw * by + az * bx - ax * bz) * 2 / magnitude;\n      translation[2] = (az * bw + aw * bz + ax * by - ay * bx) * 2 / magnitude;\n    } else {\n      translation[0] = (ax * bw + aw * bx + ay * bz - az * by) * 2;\n      translation[1] = (ay * bw + aw * by + az * bx - ax * bz) * 2;\n      translation[2] = (az * bw + aw * bz + ax * by - ay * bx) * 2;\n    }\n\n    fromRotationTranslation$1(out, a, translation);\n    return out;\n  }\n  /**\n   * Returns the translation vector component of a transformation\n   *  matrix. If a matrix is built with fromRotationTranslation,\n   *  the returned vector will be the same as the translation vector\n   *  originally supplied.\n   * @param  {vec3} out Vector to receive translation component\n   * @param  {ReadonlyMat4} mat Matrix to be decomposed (input)\n   * @return {vec3} out\n   */\n\n  function getTranslation$1(out, mat) {\n    out[0] = mat[12];\n    out[1] = mat[13];\n    out[2] = mat[14];\n    return out;\n  }\n  /**\n   * Returns the scaling factor component of a transformation\n   *  matrix. If a matrix is built with fromRotationTranslationScale\n   *  with a normalized Quaternion paramter, the returned vector will be\n   *  the same as the scaling vector\n   *  originally supplied.\n   * @param  {vec3} out Vector to receive scaling factor component\n   * @param  {ReadonlyMat4} mat Matrix to be decomposed (input)\n   * @return {vec3} out\n   */\n\n  function getScaling(out, mat) {\n    var m11 = mat[0];\n    var m12 = mat[1];\n    var m13 = mat[2];\n    var m21 = mat[4];\n    var m22 = mat[5];\n    var m23 = mat[6];\n    var m31 = mat[8];\n    var m32 = mat[9];\n    var m33 = mat[10];\n    out[0] = Math.hypot(m11, m12, m13);\n    out[1] = Math.hypot(m21, m22, m23);\n    out[2] = Math.hypot(m31, m32, m33);\n    return out;\n  }\n  /**\n   * Returns a quaternion representing the rotational component\n   *  of a transformation matrix. If a matrix is built with\n   *  fromRotationTranslation, the returned quaternion will be the\n   *  same as the quaternion originally supplied.\n   * @param {quat} out Quaternion to receive the rotation component\n   * @param {ReadonlyMat4} mat Matrix to be decomposed (input)\n   * @return {quat} out\n   */\n\n  function getRotation(out, mat) {\n    var scaling = new ARRAY_TYPE(3);\n    getScaling(scaling, mat);\n    var is1 = 1 / scaling[0];\n    var is2 = 1 / scaling[1];\n    var is3 = 1 / scaling[2];\n    var sm11 = mat[0] * is1;\n    var sm12 = mat[1] * is2;\n    var sm13 = mat[2] * is3;\n    var sm21 = mat[4] * is1;\n    var sm22 = mat[5] * is2;\n    var sm23 = mat[6] * is3;\n    var sm31 = mat[8] * is1;\n    var sm32 = mat[9] * is2;\n    var sm33 = mat[10] * is3;\n    var trace = sm11 + sm22 + sm33;\n    var S = 0;\n\n    if (trace > 0) {\n      S = Math.sqrt(trace + 1.0) * 2;\n      out[3] = 0.25 * S;\n      out[0] = (sm23 - sm32) / S;\n      out[1] = (sm31 - sm13) / S;\n      out[2] = (sm12 - sm21) / S;\n    } else if (sm11 > sm22 && sm11 > sm33) {\n      S = Math.sqrt(1.0 + sm11 - sm22 - sm33) * 2;\n      out[3] = (sm23 - sm32) / S;\n      out[0] = 0.25 * S;\n      out[1] = (sm12 + sm21) / S;\n      out[2] = (sm31 + sm13) / S;\n    } else if (sm22 > sm33) {\n      S = Math.sqrt(1.0 + sm22 - sm11 - sm33) * 2;\n      out[3] = (sm31 - sm13) / S;\n      out[0] = (sm12 + sm21) / S;\n      out[1] = 0.25 * S;\n      out[2] = (sm23 + sm32) / S;\n    } else {\n      S = Math.sqrt(1.0 + sm33 - sm11 - sm22) * 2;\n      out[3] = (sm12 - sm21) / S;\n      out[0] = (sm31 + sm13) / S;\n      out[1] = (sm23 + sm32) / S;\n      out[2] = 0.25 * S;\n    }\n\n    return out;\n  }\n  /**\n   * Decomposes a transformation matrix into its rotation, translation\n   * and scale components. Returns only the rotation component\n   * @param  {quat} out_r Quaternion to receive the rotation component\n   * @param  {vec3} out_t Vector to receive the translation vector\n   * @param  {vec3} out_s Vector to receive the scaling factor\n   * @param  {ReadonlyMat4} mat Matrix to be decomposed (input)\n   * @returns {quat} out_r\n   */\n\n  function decompose(out_r, out_t, out_s, mat) {\n    out_t[0] = mat[12];\n    out_t[1] = mat[13];\n    out_t[2] = mat[14];\n    var m11 = mat[0];\n    var m12 = mat[1];\n    var m13 = mat[2];\n    var m21 = mat[4];\n    var m22 = mat[5];\n    var m23 = mat[6];\n    var m31 = mat[8];\n    var m32 = mat[9];\n    var m33 = mat[10];\n    out_s[0] = Math.hypot(m11, m12, m13);\n    out_s[1] = Math.hypot(m21, m22, m23);\n    out_s[2] = Math.hypot(m31, m32, m33);\n    var is1 = 1 / out_s[0];\n    var is2 = 1 / out_s[1];\n    var is3 = 1 / out_s[2];\n    var sm11 = m11 * is1;\n    var sm12 = m12 * is2;\n    var sm13 = m13 * is3;\n    var sm21 = m21 * is1;\n    var sm22 = m22 * is2;\n    var sm23 = m23 * is3;\n    var sm31 = m31 * is1;\n    var sm32 = m32 * is2;\n    var sm33 = m33 * is3;\n    var trace = sm11 + sm22 + sm33;\n    var S = 0;\n\n    if (trace > 0) {\n      S = Math.sqrt(trace + 1.0) * 2;\n      out_r[3] = 0.25 * S;\n      out_r[0] = (sm23 - sm32) / S;\n      out_r[1] = (sm31 - sm13) / S;\n      out_r[2] = (sm12 - sm21) / S;\n    } else if (sm11 > sm22 && sm11 > sm33) {\n      S = Math.sqrt(1.0 + sm11 - sm22 - sm33) * 2;\n      out_r[3] = (sm23 - sm32) / S;\n      out_r[0] = 0.25 * S;\n      out_r[1] = (sm12 + sm21) / S;\n      out_r[2] = (sm31 + sm13) / S;\n    } else if (sm22 > sm33) {\n      S = Math.sqrt(1.0 + sm22 - sm11 - sm33) * 2;\n      out_r[3] = (sm31 - sm13) / S;\n      out_r[0] = (sm12 + sm21) / S;\n      out_r[1] = 0.25 * S;\n      out_r[2] = (sm23 + sm32) / S;\n    } else {\n      S = Math.sqrt(1.0 + sm33 - sm11 - sm22) * 2;\n      out_r[3] = (sm12 - sm21) / S;\n      out_r[0] = (sm31 + sm13) / S;\n      out_r[1] = (sm23 + sm32) / S;\n      out_r[2] = 0.25 * S;\n    }\n\n    return out_r;\n  }\n  /**\n   * Creates a matrix from a quaternion rotation, vector translation and vector scale\n   * This is equivalent to (but much faster than):\n   *\n   *     mat4.identity(dest);\n   *     mat4.translate(dest, vec);\n   *     let quatMat = mat4.create();\n   *     quat4.toMat4(quat, quatMat);\n   *     mat4.multiply(dest, quatMat);\n   *     mat4.scale(dest, scale)\n   *\n   * @param {mat4} out mat4 receiving operation result\n   * @param {quat4} q Rotation quaternion\n   * @param {ReadonlyVec3} v Translation vector\n   * @param {ReadonlyVec3} s Scaling vector\n   * @returns {mat4} out\n   */\n\n  function fromRotationTranslationScale(out, q, v, s) {\n    // Quaternion math\n    var x = q[0],\n        y = q[1],\n        z = q[2],\n        w = q[3];\n    var x2 = x + x;\n    var y2 = y + y;\n    var z2 = z + z;\n    var xx = x * x2;\n    var xy = x * y2;\n    var xz = x * z2;\n    var yy = y * y2;\n    var yz = y * z2;\n    var zz = z * z2;\n    var wx = w * x2;\n    var wy = w * y2;\n    var wz = w * z2;\n    var sx = s[0];\n    var sy = s[1];\n    var sz = s[2];\n    out[0] = (1 - (yy + zz)) * sx;\n    out[1] = (xy + wz) * sx;\n    out[2] = (xz - wy) * sx;\n    out[3] = 0;\n    out[4] = (xy - wz) * sy;\n    out[5] = (1 - (xx + zz)) * sy;\n    out[6] = (yz + wx) * sy;\n    out[7] = 0;\n    out[8] = (xz + wy) * sz;\n    out[9] = (yz - wx) * sz;\n    out[10] = (1 - (xx + yy)) * sz;\n    out[11] = 0;\n    out[12] = v[0];\n    out[13] = v[1];\n    out[14] = v[2];\n    out[15] = 1;\n    return out;\n  }\n  /**\n   * Creates a matrix from a quaternion rotation, vector translation and vector scale, rotating and scaling around the given origin\n   * This is equivalent to (but much faster than):\n   *\n   *     mat4.identity(dest);\n   *     mat4.translate(dest, vec);\n   *     mat4.translate(dest, origin);\n   *     let quatMat = mat4.create();\n   *     quat4.toMat4(quat, quatMat);\n   *     mat4.multiply(dest, quatMat);\n   *     mat4.scale(dest, scale)\n   *     mat4.translate(dest, negativeOrigin);\n   *\n   * @param {mat4} out mat4 receiving operation result\n   * @param {quat4} q Rotation quaternion\n   * @param {ReadonlyVec3} v Translation vector\n   * @param {ReadonlyVec3} s Scaling vector\n   * @param {ReadonlyVec3} o The origin vector around which to scale and rotate\n   * @returns {mat4} out\n   */\n\n  function fromRotationTranslationScaleOrigin(out, q, v, s, o) {\n    // Quaternion math\n    var x = q[0],\n        y = q[1],\n        z = q[2],\n        w = q[3];\n    var x2 = x + x;\n    var y2 = y + y;\n    var z2 = z + z;\n    var xx = x * x2;\n    var xy = x * y2;\n    var xz = x * z2;\n    var yy = y * y2;\n    var yz = y * z2;\n    var zz = z * z2;\n    var wx = w * x2;\n    var wy = w * y2;\n    var wz = w * z2;\n    var sx = s[0];\n    var sy = s[1];\n    var sz = s[2];\n    var ox = o[0];\n    var oy = o[1];\n    var oz = o[2];\n    var out0 = (1 - (yy + zz)) * sx;\n    var out1 = (xy + wz) * sx;\n    var out2 = (xz - wy) * sx;\n    var out4 = (xy - wz) * sy;\n    var out5 = (1 - (xx + zz)) * sy;\n    var out6 = (yz + wx) * sy;\n    var out8 = (xz + wy) * sz;\n    var out9 = (yz - wx) * sz;\n    var out10 = (1 - (xx + yy)) * sz;\n    out[0] = out0;\n    out[1] = out1;\n    out[2] = out2;\n    out[3] = 0;\n    out[4] = out4;\n    out[5] = out5;\n    out[6] = out6;\n    out[7] = 0;\n    out[8] = out8;\n    out[9] = out9;\n    out[10] = out10;\n    out[11] = 0;\n    out[12] = v[0] + ox - (out0 * ox + out4 * oy + out8 * oz);\n    out[13] = v[1] + oy - (out1 * ox + out5 * oy + out9 * oz);\n    out[14] = v[2] + oz - (out2 * ox + out6 * oy + out10 * oz);\n    out[15] = 1;\n    return out;\n  }\n  /**\n   * Calculates a 4x4 matrix from the given quaternion\n   *\n   * @param {mat4} out mat4 receiving operation result\n   * @param {ReadonlyQuat} q Quaternion to create matrix from\n   *\n   * @returns {mat4} out\n   */\n\n  function fromQuat(out, q) {\n    var x = q[0],\n        y = q[1],\n        z = q[2],\n        w = q[3];\n    var x2 = x + x;\n    var y2 = y + y;\n    var z2 = z + z;\n    var xx = x * x2;\n    var yx = y * x2;\n    var yy = y * y2;\n    var zx = z * x2;\n    var zy = z * y2;\n    var zz = z * z2;\n    var wx = w * x2;\n    var wy = w * y2;\n    var wz = w * z2;\n    out[0] = 1 - yy - zz;\n    out[1] = yx + wz;\n    out[2] = zx - wy;\n    out[3] = 0;\n    out[4] = yx - wz;\n    out[5] = 1 - xx - zz;\n    out[6] = zy + wx;\n    out[7] = 0;\n    out[8] = zx + wy;\n    out[9] = zy - wx;\n    out[10] = 1 - xx - yy;\n    out[11] = 0;\n    out[12] = 0;\n    out[13] = 0;\n    out[14] = 0;\n    out[15] = 1;\n    return out;\n  }\n  /**\n   * Generates a frustum matrix with the given bounds\n   *\n   * @param {mat4} out mat4 frustum matrix will be written into\n   * @param {Number} left Left bound of the frustum\n   * @param {Number} right Right bound of the frustum\n   * @param {Number} bottom Bottom bound of the frustum\n   * @param {Number} top Top bound of the frustum\n   * @param {Number} near Near bound of the frustum\n   * @param {Number} far Far bound of the frustum\n   * @returns {mat4} out\n   */\n\n  function frustum(out, left, right, bottom, top, near, far) {\n    var rl = 1 / (right - left);\n    var tb = 1 / (top - bottom);\n    var nf = 1 / (near - far);\n    out[0] = near * 2 * rl;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 0;\n    out[4] = 0;\n    out[5] = near * 2 * tb;\n    out[6] = 0;\n    out[7] = 0;\n    out[8] = (right + left) * rl;\n    out[9] = (top + bottom) * tb;\n    out[10] = (far + near) * nf;\n    out[11] = -1;\n    out[12] = 0;\n    out[13] = 0;\n    out[14] = far * near * 2 * nf;\n    out[15] = 0;\n    return out;\n  }\n  /**\n   * Generates a perspective projection matrix with the given bounds.\n   * The near/far clip planes correspond to a normalized device coordinate Z range of [-1, 1],\n   * which matches WebGL/OpenGL's clip volume.\n   * Passing null/undefined/no value for far will generate infinite projection matrix.\n   *\n   * @param {mat4} out mat4 frustum matrix will be written into\n   * @param {number} fovy Vertical field of view in radians\n   * @param {number} aspect Aspect ratio. typically viewport width/height\n   * @param {number} near Near bound of the frustum\n   * @param {number} far Far bound of the frustum, can be null or Infinity\n   * @returns {mat4} out\n   */\n\n  function perspectiveNO(out, fovy, aspect, near, far) {\n    var f = 1.0 / Math.tan(fovy / 2);\n    out[0] = f / aspect;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 0;\n    out[4] = 0;\n    out[5] = f;\n    out[6] = 0;\n    out[7] = 0;\n    out[8] = 0;\n    out[9] = 0;\n    out[11] = -1;\n    out[12] = 0;\n    out[13] = 0;\n    out[15] = 0;\n\n    if (far != null && far !== Infinity) {\n      var nf = 1 / (near - far);\n      out[10] = (far + near) * nf;\n      out[14] = 2 * far * near * nf;\n    } else {\n      out[10] = -1;\n      out[14] = -2 * near;\n    }\n\n    return out;\n  }\n  /**\n   * Alias for {@link mat4.perspectiveNO}\n   * @function\n   */\n\n  var perspective = perspectiveNO;\n  /**\n   * Generates a perspective projection matrix suitable for WebGPU with the given bounds.\n   * The near/far clip planes correspond to a normalized device coordinate Z range of [0, 1],\n   * which matches WebGPU/Vulkan/DirectX/Metal's clip volume.\n   * Passing null/undefined/no value for far will generate infinite projection matrix.\n   *\n   * @param {mat4} out mat4 frustum matrix will be written into\n   * @param {number} fovy Vertical field of view in radians\n   * @param {number} aspect Aspect ratio. typically viewport width/height\n   * @param {number} near Near bound of the frustum\n   * @param {number} far Far bound of the frustum, can be null or Infinity\n   * @returns {mat4} out\n   */\n\n  function perspectiveZO(out, fovy, aspect, near, far) {\n    var f = 1.0 / Math.tan(fovy / 2);\n    out[0] = f / aspect;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 0;\n    out[4] = 0;\n    out[5] = f;\n    out[6] = 0;\n    out[7] = 0;\n    out[8] = 0;\n    out[9] = 0;\n    out[11] = -1;\n    out[12] = 0;\n    out[13] = 0;\n    out[15] = 0;\n\n    if (far != null && far !== Infinity) {\n      var nf = 1 / (near - far);\n      out[10] = far * nf;\n      out[14] = far * near * nf;\n    } else {\n      out[10] = -1;\n      out[14] = -near;\n    }\n\n    return out;\n  }\n  /**\n   * Generates a perspective projection matrix with the given field of view.\n   * This is primarily useful for generating projection matrices to be used\n   * with the still experiemental WebVR API.\n   *\n   * @param {mat4} out mat4 frustum matrix will be written into\n   * @param {Object} fov Object containing the following values: upDegrees, downDegrees, leftDegrees, rightDegrees\n   * @param {number} near Near bound of the frustum\n   * @param {number} far Far bound of the frustum\n   * @returns {mat4} out\n   */\n\n  function perspectiveFromFieldOfView(out, fov, near, far) {\n    var upTan = Math.tan(fov.upDegrees * Math.PI / 180.0);\n    var downTan = Math.tan(fov.downDegrees * Math.PI / 180.0);\n    var leftTan = Math.tan(fov.leftDegrees * Math.PI / 180.0);\n    var rightTan = Math.tan(fov.rightDegrees * Math.PI / 180.0);\n    var xScale = 2.0 / (leftTan + rightTan);\n    var yScale = 2.0 / (upTan + downTan);\n    out[0] = xScale;\n    out[1] = 0.0;\n    out[2] = 0.0;\n    out[3] = 0.0;\n    out[4] = 0.0;\n    out[5] = yScale;\n    out[6] = 0.0;\n    out[7] = 0.0;\n    out[8] = -((leftTan - rightTan) * xScale * 0.5);\n    out[9] = (upTan - downTan) * yScale * 0.5;\n    out[10] = far / (near - far);\n    out[11] = -1.0;\n    out[12] = 0.0;\n    out[13] = 0.0;\n    out[14] = far * near / (near - far);\n    out[15] = 0.0;\n    return out;\n  }\n  /**\n   * Generates a orthogonal projection matrix with the given bounds.\n   * The near/far clip planes correspond to a normalized device coordinate Z range of [-1, 1],\n   * which matches WebGL/OpenGL's clip volume.\n   *\n   * @param {mat4} out mat4 frustum matrix will be written into\n   * @param {number} left Left bound of the frustum\n   * @param {number} right Right bound of the frustum\n   * @param {number} bottom Bottom bound of the frustum\n   * @param {number} top Top bound of the frustum\n   * @param {number} near Near bound of the frustum\n   * @param {number} far Far bound of the frustum\n   * @returns {mat4} out\n   */\n\n  function orthoNO(out, left, right, bottom, top, near, far) {\n    var lr = 1 / (left - right);\n    var bt = 1 / (bottom - top);\n    var nf = 1 / (near - far);\n    out[0] = -2 * lr;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 0;\n    out[4] = 0;\n    out[5] = -2 * bt;\n    out[6] = 0;\n    out[7] = 0;\n    out[8] = 0;\n    out[9] = 0;\n    out[10] = 2 * nf;\n    out[11] = 0;\n    out[12] = (left + right) * lr;\n    out[13] = (top + bottom) * bt;\n    out[14] = (far + near) * nf;\n    out[15] = 1;\n    return out;\n  }\n  /**\n   * Alias for {@link mat4.orthoNO}\n   * @function\n   */\n\n  var ortho = orthoNO;\n  /**\n   * Generates a orthogonal projection matrix with the given bounds.\n   * The near/far clip planes correspond to a normalized device coordinate Z range of [0, 1],\n   * which matches WebGPU/Vulkan/DirectX/Metal's clip volume.\n   *\n   * @param {mat4} out mat4 frustum matrix will be written into\n   * @param {number} left Left bound of the frustum\n   * @param {number} right Right bound of the frustum\n   * @param {number} bottom Bottom bound of the frustum\n   * @param {number} top Top bound of the frustum\n   * @param {number} near Near bound of the frustum\n   * @param {number} far Far bound of the frustum\n   * @returns {mat4} out\n   */\n\n  function orthoZO(out, left, right, bottom, top, near, far) {\n    var lr = 1 / (left - right);\n    var bt = 1 / (bottom - top);\n    var nf = 1 / (near - far);\n    out[0] = -2 * lr;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 0;\n    out[4] = 0;\n    out[5] = -2 * bt;\n    out[6] = 0;\n    out[7] = 0;\n    out[8] = 0;\n    out[9] = 0;\n    out[10] = nf;\n    out[11] = 0;\n    out[12] = (left + right) * lr;\n    out[13] = (top + bottom) * bt;\n    out[14] = near * nf;\n    out[15] = 1;\n    return out;\n  }\n  /**\n   * Generates a look-at matrix with the given eye position, focal point, and up axis.\n   * If you want a matrix that actually makes an object look at another object, you should use targetTo instead.\n   *\n   * @param {mat4} out mat4 frustum matrix will be written into\n   * @param {ReadonlyVec3} eye Position of the viewer\n   * @param {ReadonlyVec3} center Point the viewer is looking at\n   * @param {ReadonlyVec3} up vec3 pointing up\n   * @returns {mat4} out\n   */\n\n  function lookAt(out, eye, center, up) {\n    var x0, x1, x2, y0, y1, y2, z0, z1, z2, len;\n    var eyex = eye[0];\n    var eyey = eye[1];\n    var eyez = eye[2];\n    var upx = up[0];\n    var upy = up[1];\n    var upz = up[2];\n    var centerx = center[0];\n    var centery = center[1];\n    var centerz = center[2];\n\n    if (Math.abs(eyex - centerx) < EPSILON && Math.abs(eyey - centery) < EPSILON && Math.abs(eyez - centerz) < EPSILON) {\n      return identity$2(out);\n    }\n\n    z0 = eyex - centerx;\n    z1 = eyey - centery;\n    z2 = eyez - centerz;\n    len = 1 / Math.hypot(z0, z1, z2);\n    z0 *= len;\n    z1 *= len;\n    z2 *= len;\n    x0 = upy * z2 - upz * z1;\n    x1 = upz * z0 - upx * z2;\n    x2 = upx * z1 - upy * z0;\n    len = Math.hypot(x0, x1, x2);\n\n    if (!len) {\n      x0 = 0;\n      x1 = 0;\n      x2 = 0;\n    } else {\n      len = 1 / len;\n      x0 *= len;\n      x1 *= len;\n      x2 *= len;\n    }\n\n    y0 = z1 * x2 - z2 * x1;\n    y1 = z2 * x0 - z0 * x2;\n    y2 = z0 * x1 - z1 * x0;\n    len = Math.hypot(y0, y1, y2);\n\n    if (!len) {\n      y0 = 0;\n      y1 = 0;\n      y2 = 0;\n    } else {\n      len = 1 / len;\n      y0 *= len;\n      y1 *= len;\n      y2 *= len;\n    }\n\n    out[0] = x0;\n    out[1] = y0;\n    out[2] = z0;\n    out[3] = 0;\n    out[4] = x1;\n    out[5] = y1;\n    out[6] = z1;\n    out[7] = 0;\n    out[8] = x2;\n    out[9] = y2;\n    out[10] = z2;\n    out[11] = 0;\n    out[12] = -(x0 * eyex + x1 * eyey + x2 * eyez);\n    out[13] = -(y0 * eyex + y1 * eyey + y2 * eyez);\n    out[14] = -(z0 * eyex + z1 * eyey + z2 * eyez);\n    out[15] = 1;\n    return out;\n  }\n  /**\n   * Generates a matrix that makes something look at something else.\n   *\n   * @param {mat4} out mat4 frustum matrix will be written into\n   * @param {ReadonlyVec3} eye Position of the viewer\n   * @param {ReadonlyVec3} center Point the viewer is looking at\n   * @param {ReadonlyVec3} up vec3 pointing up\n   * @returns {mat4} out\n   */\n\n  function targetTo(out, eye, target, up) {\n    var eyex = eye[0],\n        eyey = eye[1],\n        eyez = eye[2],\n        upx = up[0],\n        upy = up[1],\n        upz = up[2];\n    var z0 = eyex - target[0],\n        z1 = eyey - target[1],\n        z2 = eyez - target[2];\n    var len = z0 * z0 + z1 * z1 + z2 * z2;\n\n    if (len > 0) {\n      len = 1 / Math.sqrt(len);\n      z0 *= len;\n      z1 *= len;\n      z2 *= len;\n    }\n\n    var x0 = upy * z2 - upz * z1,\n        x1 = upz * z0 - upx * z2,\n        x2 = upx * z1 - upy * z0;\n    len = x0 * x0 + x1 * x1 + x2 * x2;\n\n    if (len > 0) {\n      len = 1 / Math.sqrt(len);\n      x0 *= len;\n      x1 *= len;\n      x2 *= len;\n    }\n\n    out[0] = x0;\n    out[1] = x1;\n    out[2] = x2;\n    out[3] = 0;\n    out[4] = z1 * x2 - z2 * x1;\n    out[5] = z2 * x0 - z0 * x2;\n    out[6] = z0 * x1 - z1 * x0;\n    out[7] = 0;\n    out[8] = z0;\n    out[9] = z1;\n    out[10] = z2;\n    out[11] = 0;\n    out[12] = eyex;\n    out[13] = eyey;\n    out[14] = eyez;\n    out[15] = 1;\n    return out;\n  }\n  /**\n   * Returns a string representation of a mat4\n   *\n   * @param {ReadonlyMat4} a matrix to represent as a string\n   * @returns {String} string representation of the matrix\n   */\n\n  function str$5(a) {\n    return \"mat4(\" + a[0] + \", \" + a[1] + \", \" + a[2] + \", \" + a[3] + \", \" + a[4] + \", \" + a[5] + \", \" + a[6] + \", \" + a[7] + \", \" + a[8] + \", \" + a[9] + \", \" + a[10] + \", \" + a[11] + \", \" + a[12] + \", \" + a[13] + \", \" + a[14] + \", \" + a[15] + \")\";\n  }\n  /**\n   * Returns Frobenius norm of a mat4\n   *\n   * @param {ReadonlyMat4} a the matrix to calculate Frobenius norm of\n   * @returns {Number} Frobenius norm\n   */\n\n  function frob(a) {\n    return Math.hypot(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10], a[11], a[12], a[13], a[14], a[15]);\n  }\n  /**\n   * Adds two mat4's\n   *\n   * @param {mat4} out the receiving matrix\n   * @param {ReadonlyMat4} a the first operand\n   * @param {ReadonlyMat4} b the second operand\n   * @returns {mat4} out\n   */\n\n  function add$5(out, a, b) {\n    out[0] = a[0] + b[0];\n    out[1] = a[1] + b[1];\n    out[2] = a[2] + b[2];\n    out[3] = a[3] + b[3];\n    out[4] = a[4] + b[4];\n    out[5] = a[5] + b[5];\n    out[6] = a[6] + b[6];\n    out[7] = a[7] + b[7];\n    out[8] = a[8] + b[8];\n    out[9] = a[9] + b[9];\n    out[10] = a[10] + b[10];\n    out[11] = a[11] + b[11];\n    out[12] = a[12] + b[12];\n    out[13] = a[13] + b[13];\n    out[14] = a[14] + b[14];\n    out[15] = a[15] + b[15];\n    return out;\n  }\n  /**\n   * Subtracts matrix b from matrix a\n   *\n   * @param {mat4} out the receiving matrix\n   * @param {ReadonlyMat4} a the first operand\n   * @param {ReadonlyMat4} b the second operand\n   * @returns {mat4} out\n   */\n\n  function subtract$3(out, a, b) {\n    out[0] = a[0] - b[0];\n    out[1] = a[1] - b[1];\n    out[2] = a[2] - b[2];\n    out[3] = a[3] - b[3];\n    out[4] = a[4] - b[4];\n    out[5] = a[5] - b[5];\n    out[6] = a[6] - b[6];\n    out[7] = a[7] - b[7];\n    out[8] = a[8] - b[8];\n    out[9] = a[9] - b[9];\n    out[10] = a[10] - b[10];\n    out[11] = a[11] - b[11];\n    out[12] = a[12] - b[12];\n    out[13] = a[13] - b[13];\n    out[14] = a[14] - b[14];\n    out[15] = a[15] - b[15];\n    return out;\n  }\n  /**\n   * Multiply each element of the matrix by a scalar.\n   *\n   * @param {mat4} out the receiving matrix\n   * @param {ReadonlyMat4} a the matrix to scale\n   * @param {Number} b amount to scale the matrix's elements by\n   * @returns {mat4} out\n   */\n\n  function multiplyScalar(out, a, b) {\n    out[0] = a[0] * b;\n    out[1] = a[1] * b;\n    out[2] = a[2] * b;\n    out[3] = a[3] * b;\n    out[4] = a[4] * b;\n    out[5] = a[5] * b;\n    out[6] = a[6] * b;\n    out[7] = a[7] * b;\n    out[8] = a[8] * b;\n    out[9] = a[9] * b;\n    out[10] = a[10] * b;\n    out[11] = a[11] * b;\n    out[12] = a[12] * b;\n    out[13] = a[13] * b;\n    out[14] = a[14] * b;\n    out[15] = a[15] * b;\n    return out;\n  }\n  /**\n   * Adds two mat4's after multiplying each element of the second operand by a scalar value.\n   *\n   * @param {mat4} out the receiving vector\n   * @param {ReadonlyMat4} a the first operand\n   * @param {ReadonlyMat4} b the second operand\n   * @param {Number} scale the amount to scale b's elements by before adding\n   * @returns {mat4} out\n   */\n\n  function multiplyScalarAndAdd(out, a, b, scale) {\n    out[0] = a[0] + b[0] * scale;\n    out[1] = a[1] + b[1] * scale;\n    out[2] = a[2] + b[2] * scale;\n    out[3] = a[3] + b[3] * scale;\n    out[4] = a[4] + b[4] * scale;\n    out[5] = a[5] + b[5] * scale;\n    out[6] = a[6] + b[6] * scale;\n    out[7] = a[7] + b[7] * scale;\n    out[8] = a[8] + b[8] * scale;\n    out[9] = a[9] + b[9] * scale;\n    out[10] = a[10] + b[10] * scale;\n    out[11] = a[11] + b[11] * scale;\n    out[12] = a[12] + b[12] * scale;\n    out[13] = a[13] + b[13] * scale;\n    out[14] = a[14] + b[14] * scale;\n    out[15] = a[15] + b[15] * scale;\n    return out;\n  }\n  /**\n   * Returns whether the matrices have exactly the same elements in the same position (when compared with ===)\n   *\n   * @param {ReadonlyMat4} a The first matrix.\n   * @param {ReadonlyMat4} b The second matrix.\n   * @returns {Boolean} True if the matrices are equal, false otherwise.\n   */\n\n  function exactEquals$5(a, b) {\n    return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3] && a[4] === b[4] && a[5] === b[5] && a[6] === b[6] && a[7] === b[7] && a[8] === b[8] && a[9] === b[9] && a[10] === b[10] && a[11] === b[11] && a[12] === b[12] && a[13] === b[13] && a[14] === b[14] && a[15] === b[15];\n  }\n  /**\n   * Returns whether the matrices have approximately the same elements in the same position.\n   *\n   * @param {ReadonlyMat4} a The first matrix.\n   * @param {ReadonlyMat4} b The second matrix.\n   * @returns {Boolean} True if the matrices are equal, false otherwise.\n   */\n\n  function equals$5(a, b) {\n    var a0 = a[0],\n        a1 = a[1],\n        a2 = a[2],\n        a3 = a[3];\n    var a4 = a[4],\n        a5 = a[5],\n        a6 = a[6],\n        a7 = a[7];\n    var a8 = a[8],\n        a9 = a[9],\n        a10 = a[10],\n        a11 = a[11];\n    var a12 = a[12],\n        a13 = a[13],\n        a14 = a[14],\n        a15 = a[15];\n    var b0 = b[0],\n        b1 = b[1],\n        b2 = b[2],\n        b3 = b[3];\n    var b4 = b[4],\n        b5 = b[5],\n        b6 = b[6],\n        b7 = b[7];\n    var b8 = b[8],\n        b9 = b[9],\n        b10 = b[10],\n        b11 = b[11];\n    var b12 = b[12],\n        b13 = b[13],\n        b14 = b[14],\n        b15 = b[15];\n    return Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && Math.abs(a2 - b2) <= EPSILON * Math.max(1.0, Math.abs(a2), Math.abs(b2)) && Math.abs(a3 - b3) <= EPSILON * Math.max(1.0, Math.abs(a3), Math.abs(b3)) && Math.abs(a4 - b4) <= EPSILON * Math.max(1.0, Math.abs(a4), Math.abs(b4)) && Math.abs(a5 - b5) <= EPSILON * Math.max(1.0, Math.abs(a5), Math.abs(b5)) && Math.abs(a6 - b6) <= EPSILON * Math.max(1.0, Math.abs(a6), Math.abs(b6)) && Math.abs(a7 - b7) <= EPSILON * Math.max(1.0, Math.abs(a7), Math.abs(b7)) && Math.abs(a8 - b8) <= EPSILON * Math.max(1.0, Math.abs(a8), Math.abs(b8)) && Math.abs(a9 - b9) <= EPSILON * Math.max(1.0, Math.abs(a9), Math.abs(b9)) && Math.abs(a10 - b10) <= EPSILON * Math.max(1.0, Math.abs(a10), Math.abs(b10)) && Math.abs(a11 - b11) <= EPSILON * Math.max(1.0, Math.abs(a11), Math.abs(b11)) && Math.abs(a12 - b12) <= EPSILON * Math.max(1.0, Math.abs(a12), Math.abs(b12)) && Math.abs(a13 - b13) <= EPSILON * Math.max(1.0, Math.abs(a13), Math.abs(b13)) && Math.abs(a14 - b14) <= EPSILON * Math.max(1.0, Math.abs(a14), Math.abs(b14)) && Math.abs(a15 - b15) <= EPSILON * Math.max(1.0, Math.abs(a15), Math.abs(b15));\n  }\n  /**\n   * Alias for {@link mat4.multiply}\n   * @function\n   */\n\n  var mul$5 = multiply$5;\n  /**\n   * Alias for {@link mat4.subtract}\n   * @function\n   */\n\n  var sub$3 = subtract$3;\n\n  var mat4 = /*#__PURE__*/Object.freeze({\n    __proto__: null,\n    create: create$5,\n    clone: clone$5,\n    copy: copy$5,\n    fromValues: fromValues$5,\n    set: set$5,\n    identity: identity$2,\n    transpose: transpose,\n    invert: invert$2,\n    adjoint: adjoint,\n    determinant: determinant,\n    multiply: multiply$5,\n    translate: translate$1,\n    scale: scale$5,\n    rotate: rotate$1,\n    rotateX: rotateX$3,\n    rotateY: rotateY$3,\n    rotateZ: rotateZ$3,\n    fromTranslation: fromTranslation$1,\n    fromScaling: fromScaling,\n    fromRotation: fromRotation$1,\n    fromXRotation: fromXRotation,\n    fromYRotation: fromYRotation,\n    fromZRotation: fromZRotation,\n    fromRotationTranslation: fromRotationTranslation$1,\n    fromQuat2: fromQuat2,\n    getTranslation: getTranslation$1,\n    getScaling: getScaling,\n    getRotation: getRotation,\n    decompose: decompose,\n    fromRotationTranslationScale: fromRotationTranslationScale,\n    fromRotationTranslationScaleOrigin: fromRotationTranslationScaleOrigin,\n    fromQuat: fromQuat,\n    frustum: frustum,\n    perspectiveNO: perspectiveNO,\n    perspective: perspective,\n    perspectiveZO: perspectiveZO,\n    perspectiveFromFieldOfView: perspectiveFromFieldOfView,\n    orthoNO: orthoNO,\n    ortho: ortho,\n    orthoZO: orthoZO,\n    lookAt: lookAt,\n    targetTo: targetTo,\n    str: str$5,\n    frob: frob,\n    add: add$5,\n    subtract: subtract$3,\n    multiplyScalar: multiplyScalar,\n    multiplyScalarAndAdd: multiplyScalarAndAdd,\n    exactEquals: exactEquals$5,\n    equals: equals$5,\n    mul: mul$5,\n    sub: sub$3\n  });\n\n  /**\n   * 3 Dimensional Vector\n   * @module vec3\n   */\n\n  /**\n   * Creates a new, empty vec3\n   *\n   * @returns {vec3} a new 3D vector\n   */\n\n  function create$4() {\n    var out = new ARRAY_TYPE(3);\n\n    if (ARRAY_TYPE != Float32Array) {\n      out[0] = 0;\n      out[1] = 0;\n      out[2] = 0;\n    }\n\n    return out;\n  }\n  /**\n   * Creates a new vec3 initialized with values from an existing vector\n   *\n   * @param {ReadonlyVec3} a vector to clone\n   * @returns {vec3} a new 3D vector\n   */\n\n  function clone$4(a) {\n    var out = new ARRAY_TYPE(3);\n    out[0] = a[0];\n    out[1] = a[1];\n    out[2] = a[2];\n    return out;\n  }\n  /**\n   * Calculates the length of a vec3\n   *\n   * @param {ReadonlyVec3} a vector to calculate length of\n   * @returns {Number} length of a\n   */\n\n  function length$4(a) {\n    var x = a[0];\n    var y = a[1];\n    var z = a[2];\n    return Math.hypot(x, y, z);\n  }\n  /**\n   * Creates a new vec3 initialized with the given values\n   *\n   * @param {Number} x X component\n   * @param {Number} y Y component\n   * @param {Number} z Z component\n   * @returns {vec3} a new 3D vector\n   */\n\n  function fromValues$4(x, y, z) {\n    var out = new ARRAY_TYPE(3);\n    out[0] = x;\n    out[1] = y;\n    out[2] = z;\n    return out;\n  }\n  /**\n   * Copy the values from one vec3 to another\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the source vector\n   * @returns {vec3} out\n   */\n\n  function copy$4(out, a) {\n    out[0] = a[0];\n    out[1] = a[1];\n    out[2] = a[2];\n    return out;\n  }\n  /**\n   * Set the components of a vec3 to the given values\n   *\n   * @param {vec3} out the receiving vector\n   * @param {Number} x X component\n   * @param {Number} y Y component\n   * @param {Number} z Z component\n   * @returns {vec3} out\n   */\n\n  function set$4(out, x, y, z) {\n    out[0] = x;\n    out[1] = y;\n    out[2] = z;\n    return out;\n  }\n  /**\n   * Adds two vec3's\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the first operand\n   * @param {ReadonlyVec3} b the second operand\n   * @returns {vec3} out\n   */\n\n  function add$4(out, a, b) {\n    out[0] = a[0] + b[0];\n    out[1] = a[1] + b[1];\n    out[2] = a[2] + b[2];\n    return out;\n  }\n  /**\n   * Subtracts vector b from vector a\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the first operand\n   * @param {ReadonlyVec3} b the second operand\n   * @returns {vec3} out\n   */\n\n  function subtract$2(out, a, b) {\n    out[0] = a[0] - b[0];\n    out[1] = a[1] - b[1];\n    out[2] = a[2] - b[2];\n    return out;\n  }\n  /**\n   * Multiplies two vec3's\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the first operand\n   * @param {ReadonlyVec3} b the second operand\n   * @returns {vec3} out\n   */\n\n  function multiply$4(out, a, b) {\n    out[0] = a[0] * b[0];\n    out[1] = a[1] * b[1];\n    out[2] = a[2] * b[2];\n    return out;\n  }\n  /**\n   * Divides two vec3's\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the first operand\n   * @param {ReadonlyVec3} b the second operand\n   * @returns {vec3} out\n   */\n\n  function divide$2(out, a, b) {\n    out[0] = a[0] / b[0];\n    out[1] = a[1] / b[1];\n    out[2] = a[2] / b[2];\n    return out;\n  }\n  /**\n   * Math.ceil the components of a vec3\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a vector to ceil\n   * @returns {vec3} out\n   */\n\n  function ceil$2(out, a) {\n    out[0] = Math.ceil(a[0]);\n    out[1] = Math.ceil(a[1]);\n    out[2] = Math.ceil(a[2]);\n    return out;\n  }\n  /**\n   * Math.floor the components of a vec3\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a vector to floor\n   * @returns {vec3} out\n   */\n\n  function floor$2(out, a) {\n    out[0] = Math.floor(a[0]);\n    out[1] = Math.floor(a[1]);\n    out[2] = Math.floor(a[2]);\n    return out;\n  }\n  /**\n   * Returns the minimum of two vec3's\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the first operand\n   * @param {ReadonlyVec3} b the second operand\n   * @returns {vec3} out\n   */\n\n  function min$2(out, a, b) {\n    out[0] = Math.min(a[0], b[0]);\n    out[1] = Math.min(a[1], b[1]);\n    out[2] = Math.min(a[2], b[2]);\n    return out;\n  }\n  /**\n   * Returns the maximum of two vec3's\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the first operand\n   * @param {ReadonlyVec3} b the second operand\n   * @returns {vec3} out\n   */\n\n  function max$2(out, a, b) {\n    out[0] = Math.max(a[0], b[0]);\n    out[1] = Math.max(a[1], b[1]);\n    out[2] = Math.max(a[2], b[2]);\n    return out;\n  }\n  /**\n   * Math.round the components of a vec3\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a vector to round\n   * @returns {vec3} out\n   */\n\n  function round$2(out, a) {\n    out[0] = Math.round(a[0]);\n    out[1] = Math.round(a[1]);\n    out[2] = Math.round(a[2]);\n    return out;\n  }\n  /**\n   * Scales a vec3 by a scalar number\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the vector to scale\n   * @param {Number} b amount to scale the vector by\n   * @returns {vec3} out\n   */\n\n  function scale$4(out, a, b) {\n    out[0] = a[0] * b;\n    out[1] = a[1] * b;\n    out[2] = a[2] * b;\n    return out;\n  }\n  /**\n   * Adds two vec3's after scaling the second operand by a scalar value\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the first operand\n   * @param {ReadonlyVec3} b the second operand\n   * @param {Number} scale the amount to scale b by before adding\n   * @returns {vec3} out\n   */\n\n  function scaleAndAdd$2(out, a, b, scale) {\n    out[0] = a[0] + b[0] * scale;\n    out[1] = a[1] + b[1] * scale;\n    out[2] = a[2] + b[2] * scale;\n    return out;\n  }\n  /**\n   * Calculates the euclidian distance between two vec3's\n   *\n   * @param {ReadonlyVec3} a the first operand\n   * @param {ReadonlyVec3} b the second operand\n   * @returns {Number} distance between a and b\n   */\n\n  function distance$2(a, b) {\n    var x = b[0] - a[0];\n    var y = b[1] - a[1];\n    var z = b[2] - a[2];\n    return Math.hypot(x, y, z);\n  }\n  /**\n   * Calculates the squared euclidian distance between two vec3's\n   *\n   * @param {ReadonlyVec3} a the first operand\n   * @param {ReadonlyVec3} b the second operand\n   * @returns {Number} squared distance between a and b\n   */\n\n  function squaredDistance$2(a, b) {\n    var x = b[0] - a[0];\n    var y = b[1] - a[1];\n    var z = b[2] - a[2];\n    return x * x + y * y + z * z;\n  }\n  /**\n   * Calculates the squared length of a vec3\n   *\n   * @param {ReadonlyVec3} a vector to calculate squared length of\n   * @returns {Number} squared length of a\n   */\n\n  function squaredLength$4(a) {\n    var x = a[0];\n    var y = a[1];\n    var z = a[2];\n    return x * x + y * y + z * z;\n  }\n  /**\n   * Negates the components of a vec3\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a vector to negate\n   * @returns {vec3} out\n   */\n\n  function negate$2(out, a) {\n    out[0] = -a[0];\n    out[1] = -a[1];\n    out[2] = -a[2];\n    return out;\n  }\n  /**\n   * Returns the inverse of the components of a vec3\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a vector to invert\n   * @returns {vec3} out\n   */\n\n  function inverse$2(out, a) {\n    out[0] = 1.0 / a[0];\n    out[1] = 1.0 / a[1];\n    out[2] = 1.0 / a[2];\n    return out;\n  }\n  /**\n   * Normalize a vec3\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a vector to normalize\n   * @returns {vec3} out\n   */\n\n  function normalize$4(out, a) {\n    var x = a[0];\n    var y = a[1];\n    var z = a[2];\n    var len = x * x + y * y + z * z;\n\n    if (len > 0) {\n      //TODO: evaluate use of glm_invsqrt here?\n      len = 1 / Math.sqrt(len);\n    }\n\n    out[0] = a[0] * len;\n    out[1] = a[1] * len;\n    out[2] = a[2] * len;\n    return out;\n  }\n  /**\n   * Calculates the dot product of two vec3's\n   *\n   * @param {ReadonlyVec3} a the first operand\n   * @param {ReadonlyVec3} b the second operand\n   * @returns {Number} dot product of a and b\n   */\n\n  function dot$4(a, b) {\n    return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];\n  }\n  /**\n   * Computes the cross product of two vec3's\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the first operand\n   * @param {ReadonlyVec3} b the second operand\n   * @returns {vec3} out\n   */\n\n  function cross$2(out, a, b) {\n    var ax = a[0],\n        ay = a[1],\n        az = a[2];\n    var bx = b[0],\n        by = b[1],\n        bz = b[2];\n    out[0] = ay * bz - az * by;\n    out[1] = az * bx - ax * bz;\n    out[2] = ax * by - ay * bx;\n    return out;\n  }\n  /**\n   * Performs a linear interpolation between two vec3's\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the first operand\n   * @param {ReadonlyVec3} b the second operand\n   * @param {Number} t interpolation amount, in the range [0-1], between the two inputs\n   * @returns {vec3} out\n   */\n\n  function lerp$4(out, a, b, t) {\n    var ax = a[0];\n    var ay = a[1];\n    var az = a[2];\n    out[0] = ax + t * (b[0] - ax);\n    out[1] = ay + t * (b[1] - ay);\n    out[2] = az + t * (b[2] - az);\n    return out;\n  }\n  /**\n   * Performs a spherical linear interpolation between two vec3's\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the first operand\n   * @param {ReadonlyVec3} b the second operand\n   * @param {Number} t interpolation amount, in the range [0-1], between the two inputs\n   * @returns {vec3} out\n   */\n\n  function slerp$1(out, a, b, t) {\n    var angle = Math.acos(Math.min(Math.max(dot$4(a, b), -1), 1));\n    var sinTotal = Math.sin(angle);\n    var ratioA = Math.sin((1 - t) * angle) / sinTotal;\n    var ratioB = Math.sin(t * angle) / sinTotal;\n    out[0] = ratioA * a[0] + ratioB * b[0];\n    out[1] = ratioA * a[1] + ratioB * b[1];\n    out[2] = ratioA * a[2] + ratioB * b[2];\n    return out;\n  }\n  /**\n   * Performs a hermite interpolation with two control points\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the first operand\n   * @param {ReadonlyVec3} b the second operand\n   * @param {ReadonlyVec3} c the third operand\n   * @param {ReadonlyVec3} d the fourth operand\n   * @param {Number} t interpolation amount, in the range [0-1], between the two inputs\n   * @returns {vec3} out\n   */\n\n  function hermite(out, a, b, c, d, t) {\n    var factorTimes2 = t * t;\n    var factor1 = factorTimes2 * (2 * t - 3) + 1;\n    var factor2 = factorTimes2 * (t - 2) + t;\n    var factor3 = factorTimes2 * (t - 1);\n    var factor4 = factorTimes2 * (3 - 2 * t);\n    out[0] = a[0] * factor1 + b[0] * factor2 + c[0] * factor3 + d[0] * factor4;\n    out[1] = a[1] * factor1 + b[1] * factor2 + c[1] * factor3 + d[1] * factor4;\n    out[2] = a[2] * factor1 + b[2] * factor2 + c[2] * factor3 + d[2] * factor4;\n    return out;\n  }\n  /**\n   * Performs a bezier interpolation with two control points\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the first operand\n   * @param {ReadonlyVec3} b the second operand\n   * @param {ReadonlyVec3} c the third operand\n   * @param {ReadonlyVec3} d the fourth operand\n   * @param {Number} t interpolation amount, in the range [0-1], between the two inputs\n   * @returns {vec3} out\n   */\n\n  function bezier(out, a, b, c, d, t) {\n    var inverseFactor = 1 - t;\n    var inverseFactorTimesTwo = inverseFactor * inverseFactor;\n    var factorTimes2 = t * t;\n    var factor1 = inverseFactorTimesTwo * inverseFactor;\n    var factor2 = 3 * t * inverseFactorTimesTwo;\n    var factor3 = 3 * factorTimes2 * inverseFactor;\n    var factor4 = factorTimes2 * t;\n    out[0] = a[0] * factor1 + b[0] * factor2 + c[0] * factor3 + d[0] * factor4;\n    out[1] = a[1] * factor1 + b[1] * factor2 + c[1] * factor3 + d[1] * factor4;\n    out[2] = a[2] * factor1 + b[2] * factor2 + c[2] * factor3 + d[2] * factor4;\n    return out;\n  }\n  /**\n   * Generates a random vector with the given scale\n   *\n   * @param {vec3} out the receiving vector\n   * @param {Number} [scale] Length of the resulting vector. If omitted, a unit vector will be returned\n   * @returns {vec3} out\n   */\n\n  function random$3(out, scale) {\n    scale = scale === undefined ? 1.0 : scale;\n    var r = RANDOM() * 2.0 * Math.PI;\n    var z = RANDOM() * 2.0 - 1.0;\n    var zScale = Math.sqrt(1.0 - z * z) * scale;\n    out[0] = Math.cos(r) * zScale;\n    out[1] = Math.sin(r) * zScale;\n    out[2] = z * scale;\n    return out;\n  }\n  /**\n   * Transforms the vec3 with a mat4.\n   * 4th vector component is implicitly '1'\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the vector to transform\n   * @param {ReadonlyMat4} m matrix to transform with\n   * @returns {vec3} out\n   */\n\n  function transformMat4$2(out, a, m) {\n    var x = a[0],\n        y = a[1],\n        z = a[2];\n    var w = m[3] * x + m[7] * y + m[11] * z + m[15];\n    w = w || 1.0;\n    out[0] = (m[0] * x + m[4] * y + m[8] * z + m[12]) / w;\n    out[1] = (m[1] * x + m[5] * y + m[9] * z + m[13]) / w;\n    out[2] = (m[2] * x + m[6] * y + m[10] * z + m[14]) / w;\n    return out;\n  }\n  /**\n   * Transforms the vec3 with a mat3.\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the vector to transform\n   * @param {ReadonlyMat3} m the 3x3 matrix to transform with\n   * @returns {vec3} out\n   */\n\n  function transformMat3$1(out, a, m) {\n    var x = a[0],\n        y = a[1],\n        z = a[2];\n    out[0] = x * m[0] + y * m[3] + z * m[6];\n    out[1] = x * m[1] + y * m[4] + z * m[7];\n    out[2] = x * m[2] + y * m[5] + z * m[8];\n    return out;\n  }\n  /**\n   * Transforms the vec3 with a quat\n   * Can also be used for dual quaternions. (Multiply it with the real part)\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec3} a the vector to transform\n   * @param {ReadonlyQuat} q quaternion to transform with\n   * @returns {vec3} out\n   */\n\n  function transformQuat$1(out, a, q) {\n    // benchmarks: https://jsperf.com/quaternion-transform-vec3-implementations-fixed\n    var qx = q[0],\n        qy = q[1],\n        qz = q[2],\n        qw = q[3];\n    var x = a[0],\n        y = a[1],\n        z = a[2]; // var qvec = [qx, qy, qz];\n    // var uv = vec3.cross([], qvec, a);\n\n    var uvx = qy * z - qz * y,\n        uvy = qz * x - qx * z,\n        uvz = qx * y - qy * x; // var uuv = vec3.cross([], qvec, uv);\n\n    var uuvx = qy * uvz - qz * uvy,\n        uuvy = qz * uvx - qx * uvz,\n        uuvz = qx * uvy - qy * uvx; // vec3.scale(uv, uv, 2 * w);\n\n    var w2 = qw * 2;\n    uvx *= w2;\n    uvy *= w2;\n    uvz *= w2; // vec3.scale(uuv, uuv, 2);\n\n    uuvx *= 2;\n    uuvy *= 2;\n    uuvz *= 2; // return vec3.add(out, a, vec3.add(out, uv, uuv));\n\n    out[0] = x + uvx + uuvx;\n    out[1] = y + uvy + uuvy;\n    out[2] = z + uvz + uuvz;\n    return out;\n  }\n  /**\n   * Rotate a 3D vector around the x-axis\n   * @param {vec3} out The receiving vec3\n   * @param {ReadonlyVec3} a The vec3 point to rotate\n   * @param {ReadonlyVec3} b The origin of the rotation\n   * @param {Number} rad The angle of rotation in radians\n   * @returns {vec3} out\n   */\n\n  function rotateX$2(out, a, b, rad) {\n    var p = [],\n        r = []; //Translate point to the origin\n\n    p[0] = a[0] - b[0];\n    p[1] = a[1] - b[1];\n    p[2] = a[2] - b[2]; //perform rotation\n\n    r[0] = p[0];\n    r[1] = p[1] * Math.cos(rad) - p[2] * Math.sin(rad);\n    r[2] = p[1] * Math.sin(rad) + p[2] * Math.cos(rad); //translate to correct position\n\n    out[0] = r[0] + b[0];\n    out[1] = r[1] + b[1];\n    out[2] = r[2] + b[2];\n    return out;\n  }\n  /**\n   * Rotate a 3D vector around the y-axis\n   * @param {vec3} out The receiving vec3\n   * @param {ReadonlyVec3} a The vec3 point to rotate\n   * @param {ReadonlyVec3} b The origin of the rotation\n   * @param {Number} rad The angle of rotation in radians\n   * @returns {vec3} out\n   */\n\n  function rotateY$2(out, a, b, rad) {\n    var p = [],\n        r = []; //Translate point to the origin\n\n    p[0] = a[0] - b[0];\n    p[1] = a[1] - b[1];\n    p[2] = a[2] - b[2]; //perform rotation\n\n    r[0] = p[2] * Math.sin(rad) + p[0] * Math.cos(rad);\n    r[1] = p[1];\n    r[2] = p[2] * Math.cos(rad) - p[0] * Math.sin(rad); //translate to correct position\n\n    out[0] = r[0] + b[0];\n    out[1] = r[1] + b[1];\n    out[2] = r[2] + b[2];\n    return out;\n  }\n  /**\n   * Rotate a 3D vector around the z-axis\n   * @param {vec3} out The receiving vec3\n   * @param {ReadonlyVec3} a The vec3 point to rotate\n   * @param {ReadonlyVec3} b The origin of the rotation\n   * @param {Number} rad The angle of rotation in radians\n   * @returns {vec3} out\n   */\n\n  function rotateZ$2(out, a, b, rad) {\n    var p = [],\n        r = []; //Translate point to the origin\n\n    p[0] = a[0] - b[0];\n    p[1] = a[1] - b[1];\n    p[2] = a[2] - b[2]; //perform rotation\n\n    r[0] = p[0] * Math.cos(rad) - p[1] * Math.sin(rad);\n    r[1] = p[0] * Math.sin(rad) + p[1] * Math.cos(rad);\n    r[2] = p[2]; //translate to correct position\n\n    out[0] = r[0] + b[0];\n    out[1] = r[1] + b[1];\n    out[2] = r[2] + b[2];\n    return out;\n  }\n  /**\n   * Get the angle between two 3D vectors\n   * @param {ReadonlyVec3} a The first operand\n   * @param {ReadonlyVec3} b The second operand\n   * @returns {Number} The angle in radians\n   */\n\n  function angle$1(a, b) {\n    var ax = a[0],\n        ay = a[1],\n        az = a[2],\n        bx = b[0],\n        by = b[1],\n        bz = b[2],\n        mag = Math.sqrt((ax * ax + ay * ay + az * az) * (bx * bx + by * by + bz * bz)),\n        cosine = mag && dot$4(a, b) / mag;\n    return Math.acos(Math.min(Math.max(cosine, -1), 1));\n  }\n  /**\n   * Set the components of a vec3 to zero\n   *\n   * @param {vec3} out the receiving vector\n   * @returns {vec3} out\n   */\n\n  function zero$2(out) {\n    out[0] = 0.0;\n    out[1] = 0.0;\n    out[2] = 0.0;\n    return out;\n  }\n  /**\n   * Returns a string representation of a vector\n   *\n   * @param {ReadonlyVec3} a vector to represent as a string\n   * @returns {String} string representation of the vector\n   */\n\n  function str$4(a) {\n    return \"vec3(\" + a[0] + \", \" + a[1] + \", \" + a[2] + \")\";\n  }\n  /**\n   * Returns whether the vectors have exactly the same elements in the same position (when compared with ===)\n   *\n   * @param {ReadonlyVec3} a The first vector.\n   * @param {ReadonlyVec3} b The second vector.\n   * @returns {Boolean} True if the vectors are equal, false otherwise.\n   */\n\n  function exactEquals$4(a, b) {\n    return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];\n  }\n  /**\n   * Returns whether the vectors have approximately the same elements in the same position.\n   *\n   * @param {ReadonlyVec3} a The first vector.\n   * @param {ReadonlyVec3} b The second vector.\n   * @returns {Boolean} True if the vectors are equal, false otherwise.\n   */\n\n  function equals$4(a, b) {\n    var a0 = a[0],\n        a1 = a[1],\n        a2 = a[2];\n    var b0 = b[0],\n        b1 = b[1],\n        b2 = b[2];\n    return Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && Math.abs(a2 - b2) <= EPSILON * Math.max(1.0, Math.abs(a2), Math.abs(b2));\n  }\n  /**\n   * Alias for {@link vec3.subtract}\n   * @function\n   */\n\n  var sub$2 = subtract$2;\n  /**\n   * Alias for {@link vec3.multiply}\n   * @function\n   */\n\n  var mul$4 = multiply$4;\n  /**\n   * Alias for {@link vec3.divide}\n   * @function\n   */\n\n  var div$2 = divide$2;\n  /**\n   * Alias for {@link vec3.distance}\n   * @function\n   */\n\n  var dist$2 = distance$2;\n  /**\n   * Alias for {@link vec3.squaredDistance}\n   * @function\n   */\n\n  var sqrDist$2 = squaredDistance$2;\n  /**\n   * Alias for {@link vec3.length}\n   * @function\n   */\n\n  var len$4 = length$4;\n  /**\n   * Alias for {@link vec3.squaredLength}\n   * @function\n   */\n\n  var sqrLen$4 = squaredLength$4;\n  /**\n   * Perform some operation over an array of vec3s.\n   *\n   * @param {Array} a the array of vectors to iterate over\n   * @param {Number} stride Number of elements between the start of each vec3. If 0 assumes tightly packed\n   * @param {Number} offset Number of elements to skip at the beginning of the array\n   * @param {Number} count Number of vec3s to iterate over. If 0 iterates over entire array\n   * @param {Function} fn Function to call for each vector in the array\n   * @param {Object} [arg] additional argument to pass to fn\n   * @returns {Array} a\n   * @function\n   */\n\n  var forEach$2 = function () {\n    var vec = create$4();\n    return function (a, stride, offset, count, fn, arg) {\n      var i, l;\n\n      if (!stride) {\n        stride = 3;\n      }\n\n      if (!offset) {\n        offset = 0;\n      }\n\n      if (count) {\n        l = Math.min(count * stride + offset, a.length);\n      } else {\n        l = a.length;\n      }\n\n      for (i = offset; i < l; i += stride) {\n        vec[0] = a[i];\n        vec[1] = a[i + 1];\n        vec[2] = a[i + 2];\n        fn(vec, vec, arg);\n        a[i] = vec[0];\n        a[i + 1] = vec[1];\n        a[i + 2] = vec[2];\n      }\n\n      return a;\n    };\n  }();\n\n  var vec3 = /*#__PURE__*/Object.freeze({\n    __proto__: null,\n    create: create$4,\n    clone: clone$4,\n    length: length$4,\n    fromValues: fromValues$4,\n    copy: copy$4,\n    set: set$4,\n    add: add$4,\n    subtract: subtract$2,\n    multiply: multiply$4,\n    divide: divide$2,\n    ceil: ceil$2,\n    floor: floor$2,\n    min: min$2,\n    max: max$2,\n    round: round$2,\n    scale: scale$4,\n    scaleAndAdd: scaleAndAdd$2,\n    distance: distance$2,\n    squaredDistance: squaredDistance$2,\n    squaredLength: squaredLength$4,\n    negate: negate$2,\n    inverse: inverse$2,\n    normalize: normalize$4,\n    dot: dot$4,\n    cross: cross$2,\n    lerp: lerp$4,\n    slerp: slerp$1,\n    hermite: hermite,\n    bezier: bezier,\n    random: random$3,\n    transformMat4: transformMat4$2,\n    transformMat3: transformMat3$1,\n    transformQuat: transformQuat$1,\n    rotateX: rotateX$2,\n    rotateY: rotateY$2,\n    rotateZ: rotateZ$2,\n    angle: angle$1,\n    zero: zero$2,\n    str: str$4,\n    exactEquals: exactEquals$4,\n    equals: equals$4,\n    sub: sub$2,\n    mul: mul$4,\n    div: div$2,\n    dist: dist$2,\n    sqrDist: sqrDist$2,\n    len: len$4,\n    sqrLen: sqrLen$4,\n    forEach: forEach$2\n  });\n\n  /**\n   * 4 Dimensional Vector\n   * @module vec4\n   */\n\n  /**\n   * Creates a new, empty vec4\n   *\n   * @returns {vec4} a new 4D vector\n   */\n\n  function create$3() {\n    var out = new ARRAY_TYPE(4);\n\n    if (ARRAY_TYPE != Float32Array) {\n      out[0] = 0;\n      out[1] = 0;\n      out[2] = 0;\n      out[3] = 0;\n    }\n\n    return out;\n  }\n  /**\n   * Creates a new vec4 initialized with values from an existing vector\n   *\n   * @param {ReadonlyVec4} a vector to clone\n   * @returns {vec4} a new 4D vector\n   */\n\n  function clone$3(a) {\n    var out = new ARRAY_TYPE(4);\n    out[0] = a[0];\n    out[1] = a[1];\n    out[2] = a[2];\n    out[3] = a[3];\n    return out;\n  }\n  /**\n   * Creates a new vec4 initialized with the given values\n   *\n   * @param {Number} x X component\n   * @param {Number} y Y component\n   * @param {Number} z Z component\n   * @param {Number} w W component\n   * @returns {vec4} a new 4D vector\n   */\n\n  function fromValues$3(x, y, z, w) {\n    var out = new ARRAY_TYPE(4);\n    out[0] = x;\n    out[1] = y;\n    out[2] = z;\n    out[3] = w;\n    return out;\n  }\n  /**\n   * Copy the values from one vec4 to another\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a the source vector\n   * @returns {vec4} out\n   */\n\n  function copy$3(out, a) {\n    out[0] = a[0];\n    out[1] = a[1];\n    out[2] = a[2];\n    out[3] = a[3];\n    return out;\n  }\n  /**\n   * Set the components of a vec4 to the given values\n   *\n   * @param {vec4} out the receiving vector\n   * @param {Number} x X component\n   * @param {Number} y Y component\n   * @param {Number} z Z component\n   * @param {Number} w W component\n   * @returns {vec4} out\n   */\n\n  function set$3(out, x, y, z, w) {\n    out[0] = x;\n    out[1] = y;\n    out[2] = z;\n    out[3] = w;\n    return out;\n  }\n  /**\n   * Adds two vec4's\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a the first operand\n   * @param {ReadonlyVec4} b the second operand\n   * @returns {vec4} out\n   */\n\n  function add$3(out, a, b) {\n    out[0] = a[0] + b[0];\n    out[1] = a[1] + b[1];\n    out[2] = a[2] + b[2];\n    out[3] = a[3] + b[3];\n    return out;\n  }\n  /**\n   * Subtracts vector b from vector a\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a the first operand\n   * @param {ReadonlyVec4} b the second operand\n   * @returns {vec4} out\n   */\n\n  function subtract$1(out, a, b) {\n    out[0] = a[0] - b[0];\n    out[1] = a[1] - b[1];\n    out[2] = a[2] - b[2];\n    out[3] = a[3] - b[3];\n    return out;\n  }\n  /**\n   * Multiplies two vec4's\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a the first operand\n   * @param {ReadonlyVec4} b the second operand\n   * @returns {vec4} out\n   */\n\n  function multiply$3(out, a, b) {\n    out[0] = a[0] * b[0];\n    out[1] = a[1] * b[1];\n    out[2] = a[2] * b[2];\n    out[3] = a[3] * b[3];\n    return out;\n  }\n  /**\n   * Divides two vec4's\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a the first operand\n   * @param {ReadonlyVec4} b the second operand\n   * @returns {vec4} out\n   */\n\n  function divide$1(out, a, b) {\n    out[0] = a[0] / b[0];\n    out[1] = a[1] / b[1];\n    out[2] = a[2] / b[2];\n    out[3] = a[3] / b[3];\n    return out;\n  }\n  /**\n   * Math.ceil the components of a vec4\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a vector to ceil\n   * @returns {vec4} out\n   */\n\n  function ceil$1(out, a) {\n    out[0] = Math.ceil(a[0]);\n    out[1] = Math.ceil(a[1]);\n    out[2] = Math.ceil(a[2]);\n    out[3] = Math.ceil(a[3]);\n    return out;\n  }\n  /**\n   * Math.floor the components of a vec4\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a vector to floor\n   * @returns {vec4} out\n   */\n\n  function floor$1(out, a) {\n    out[0] = Math.floor(a[0]);\n    out[1] = Math.floor(a[1]);\n    out[2] = Math.floor(a[2]);\n    out[3] = Math.floor(a[3]);\n    return out;\n  }\n  /**\n   * Returns the minimum of two vec4's\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a the first operand\n   * @param {ReadonlyVec4} b the second operand\n   * @returns {vec4} out\n   */\n\n  function min$1(out, a, b) {\n    out[0] = Math.min(a[0], b[0]);\n    out[1] = Math.min(a[1], b[1]);\n    out[2] = Math.min(a[2], b[2]);\n    out[3] = Math.min(a[3], b[3]);\n    return out;\n  }\n  /**\n   * Returns the maximum of two vec4's\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a the first operand\n   * @param {ReadonlyVec4} b the second operand\n   * @returns {vec4} out\n   */\n\n  function max$1(out, a, b) {\n    out[0] = Math.max(a[0], b[0]);\n    out[1] = Math.max(a[1], b[1]);\n    out[2] = Math.max(a[2], b[2]);\n    out[3] = Math.max(a[3], b[3]);\n    return out;\n  }\n  /**\n   * Math.round the components of a vec4\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a vector to round\n   * @returns {vec4} out\n   */\n\n  function round$1(out, a) {\n    out[0] = Math.round(a[0]);\n    out[1] = Math.round(a[1]);\n    out[2] = Math.round(a[2]);\n    out[3] = Math.round(a[3]);\n    return out;\n  }\n  /**\n   * Scales a vec4 by a scalar number\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a the vector to scale\n   * @param {Number} b amount to scale the vector by\n   * @returns {vec4} out\n   */\n\n  function scale$3(out, a, b) {\n    out[0] = a[0] * b;\n    out[1] = a[1] * b;\n    out[2] = a[2] * b;\n    out[3] = a[3] * b;\n    return out;\n  }\n  /**\n   * Adds two vec4's after scaling the second operand by a scalar value\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a the first operand\n   * @param {ReadonlyVec4} b the second operand\n   * @param {Number} scale the amount to scale b by before adding\n   * @returns {vec4} out\n   */\n\n  function scaleAndAdd$1(out, a, b, scale) {\n    out[0] = a[0] + b[0] * scale;\n    out[1] = a[1] + b[1] * scale;\n    out[2] = a[2] + b[2] * scale;\n    out[3] = a[3] + b[3] * scale;\n    return out;\n  }\n  /**\n   * Calculates the euclidian distance between two vec4's\n   *\n   * @param {ReadonlyVec4} a the first operand\n   * @param {ReadonlyVec4} b the second operand\n   * @returns {Number} distance between a and b\n   */\n\n  function distance$1(a, b) {\n    var x = b[0] - a[0];\n    var y = b[1] - a[1];\n    var z = b[2] - a[2];\n    var w = b[3] - a[3];\n    return Math.hypot(x, y, z, w);\n  }\n  /**\n   * Calculates the squared euclidian distance between two vec4's\n   *\n   * @param {ReadonlyVec4} a the first operand\n   * @param {ReadonlyVec4} b the second operand\n   * @returns {Number} squared distance between a and b\n   */\n\n  function squaredDistance$1(a, b) {\n    var x = b[0] - a[0];\n    var y = b[1] - a[1];\n    var z = b[2] - a[2];\n    var w = b[3] - a[3];\n    return x * x + y * y + z * z + w * w;\n  }\n  /**\n   * Calculates the length of a vec4\n   *\n   * @param {ReadonlyVec4} a vector to calculate length of\n   * @returns {Number} length of a\n   */\n\n  function length$3(a) {\n    var x = a[0];\n    var y = a[1];\n    var z = a[2];\n    var w = a[3];\n    return Math.hypot(x, y, z, w);\n  }\n  /**\n   * Calculates the squared length of a vec4\n   *\n   * @param {ReadonlyVec4} a vector to calculate squared length of\n   * @returns {Number} squared length of a\n   */\n\n  function squaredLength$3(a) {\n    var x = a[0];\n    var y = a[1];\n    var z = a[2];\n    var w = a[3];\n    return x * x + y * y + z * z + w * w;\n  }\n  /**\n   * Negates the components of a vec4\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a vector to negate\n   * @returns {vec4} out\n   */\n\n  function negate$1(out, a) {\n    out[0] = -a[0];\n    out[1] = -a[1];\n    out[2] = -a[2];\n    out[3] = -a[3];\n    return out;\n  }\n  /**\n   * Returns the inverse of the components of a vec4\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a vector to invert\n   * @returns {vec4} out\n   */\n\n  function inverse$1(out, a) {\n    out[0] = 1.0 / a[0];\n    out[1] = 1.0 / a[1];\n    out[2] = 1.0 / a[2];\n    out[3] = 1.0 / a[3];\n    return out;\n  }\n  /**\n   * Normalize a vec4\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a vector to normalize\n   * @returns {vec4} out\n   */\n\n  function normalize$3(out, a) {\n    var x = a[0];\n    var y = a[1];\n    var z = a[2];\n    var w = a[3];\n    var len = x * x + y * y + z * z + w * w;\n\n    if (len > 0) {\n      len = 1 / Math.sqrt(len);\n    }\n\n    out[0] = x * len;\n    out[1] = y * len;\n    out[2] = z * len;\n    out[3] = w * len;\n    return out;\n  }\n  /**\n   * Calculates the dot product of two vec4's\n   *\n   * @param {ReadonlyVec4} a the first operand\n   * @param {ReadonlyVec4} b the second operand\n   * @returns {Number} dot product of a and b\n   */\n\n  function dot$3(a, b) {\n    return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];\n  }\n  /**\n   * Returns the cross-product of three vectors in a 4-dimensional space\n   *\n   * @param {ReadonlyVec4} result the receiving vector\n   * @param {ReadonlyVec4} U the first vector\n   * @param {ReadonlyVec4} V the second vector\n   * @param {ReadonlyVec4} W the third vector\n   * @returns {vec4} result\n   */\n\n  function cross$1(out, u, v, w) {\n    var A = v[0] * w[1] - v[1] * w[0],\n        B = v[0] * w[2] - v[2] * w[0],\n        C = v[0] * w[3] - v[3] * w[0],\n        D = v[1] * w[2] - v[2] * w[1],\n        E = v[1] * w[3] - v[3] * w[1],\n        F = v[2] * w[3] - v[3] * w[2];\n    var G = u[0];\n    var H = u[1];\n    var I = u[2];\n    var J = u[3];\n    out[0] = H * F - I * E + J * D;\n    out[1] = -(G * F) + I * C - J * B;\n    out[2] = G * E - H * C + J * A;\n    out[3] = -(G * D) + H * B - I * A;\n    return out;\n  }\n  /**\n   * Performs a linear interpolation between two vec4's\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a the first operand\n   * @param {ReadonlyVec4} b the second operand\n   * @param {Number} t interpolation amount, in the range [0-1], between the two inputs\n   * @returns {vec4} out\n   */\n\n  function lerp$3(out, a, b, t) {\n    var ax = a[0];\n    var ay = a[1];\n    var az = a[2];\n    var aw = a[3];\n    out[0] = ax + t * (b[0] - ax);\n    out[1] = ay + t * (b[1] - ay);\n    out[2] = az + t * (b[2] - az);\n    out[3] = aw + t * (b[3] - aw);\n    return out;\n  }\n  /**\n   * Generates a random vector with the given scale\n   *\n   * @param {vec4} out the receiving vector\n   * @param {Number} [scale] Length of the resulting vector. If omitted, a unit vector will be returned\n   * @returns {vec4} out\n   */\n\n  function random$2(out, scale) {\n    scale = scale === undefined ? 1.0 : scale; // Marsaglia, George. Choosing a Point from the Surface of a\n    // Sphere. Ann. Math. Statist. 43 (1972), no. 2, 645--646.\n    // http://projecteuclid.org/euclid.aoms/1177692644;\n\n    var v1, v2, v3, v4;\n    var s1, s2;\n\n    do {\n      v1 = RANDOM() * 2 - 1;\n      v2 = RANDOM() * 2 - 1;\n      s1 = v1 * v1 + v2 * v2;\n    } while (s1 >= 1);\n\n    do {\n      v3 = RANDOM() * 2 - 1;\n      v4 = RANDOM() * 2 - 1;\n      s2 = v3 * v3 + v4 * v4;\n    } while (s2 >= 1);\n\n    var d = Math.sqrt((1 - s1) / s2);\n    out[0] = scale * v1;\n    out[1] = scale * v2;\n    out[2] = scale * v3 * d;\n    out[3] = scale * v4 * d;\n    return out;\n  }\n  /**\n   * Transforms the vec4 with a mat4.\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a the vector to transform\n   * @param {ReadonlyMat4} m matrix to transform with\n   * @returns {vec4} out\n   */\n\n  function transformMat4$1(out, a, m) {\n    var x = a[0],\n        y = a[1],\n        z = a[2],\n        w = a[3];\n    out[0] = m[0] * x + m[4] * y + m[8] * z + m[12] * w;\n    out[1] = m[1] * x + m[5] * y + m[9] * z + m[13] * w;\n    out[2] = m[2] * x + m[6] * y + m[10] * z + m[14] * w;\n    out[3] = m[3] * x + m[7] * y + m[11] * z + m[15] * w;\n    return out;\n  }\n  /**\n   * Transforms the vec4 with a quat\n   *\n   * @param {vec4} out the receiving vector\n   * @param {ReadonlyVec4} a the vector to transform\n   * @param {ReadonlyQuat} q quaternion to transform with\n   * @returns {vec4} out\n   */\n\n  function transformQuat(out, a, q) {\n    var x = a[0],\n        y = a[1],\n        z = a[2];\n    var qx = q[0],\n        qy = q[1],\n        qz = q[2],\n        qw = q[3]; // calculate quat * vec\n\n    var ix = qw * x + qy * z - qz * y;\n    var iy = qw * y + qz * x - qx * z;\n    var iz = qw * z + qx * y - qy * x;\n    var iw = -qx * x - qy * y - qz * z; // calculate result * inverse quat\n\n    out[0] = ix * qw + iw * -qx + iy * -qz - iz * -qy;\n    out[1] = iy * qw + iw * -qy + iz * -qx - ix * -qz;\n    out[2] = iz * qw + iw * -qz + ix * -qy - iy * -qx;\n    out[3] = a[3];\n    return out;\n  }\n  /**\n   * Set the components of a vec4 to zero\n   *\n   * @param {vec4} out the receiving vector\n   * @returns {vec4} out\n   */\n\n  function zero$1(out) {\n    out[0] = 0.0;\n    out[1] = 0.0;\n    out[2] = 0.0;\n    out[3] = 0.0;\n    return out;\n  }\n  /**\n   * Returns a string representation of a vector\n   *\n   * @param {ReadonlyVec4} a vector to represent as a string\n   * @returns {String} string representation of the vector\n   */\n\n  function str$3(a) {\n    return \"vec4(\" + a[0] + \", \" + a[1] + \", \" + a[2] + \", \" + a[3] + \")\";\n  }\n  /**\n   * Returns whether the vectors have exactly the same elements in the same position (when compared with ===)\n   *\n   * @param {ReadonlyVec4} a The first vector.\n   * @param {ReadonlyVec4} b The second vector.\n   * @returns {Boolean} True if the vectors are equal, false otherwise.\n   */\n\n  function exactEquals$3(a, b) {\n    return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];\n  }\n  /**\n   * Returns whether the vectors have approximately the same elements in the same position.\n   *\n   * @param {ReadonlyVec4} a The first vector.\n   * @param {ReadonlyVec4} b The second vector.\n   * @returns {Boolean} True if the vectors are equal, false otherwise.\n   */\n\n  function equals$3(a, b) {\n    var a0 = a[0],\n        a1 = a[1],\n        a2 = a[2],\n        a3 = a[3];\n    var b0 = b[0],\n        b1 = b[1],\n        b2 = b[2],\n        b3 = b[3];\n    return Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && Math.abs(a2 - b2) <= EPSILON * Math.max(1.0, Math.abs(a2), Math.abs(b2)) && Math.abs(a3 - b3) <= EPSILON * Math.max(1.0, Math.abs(a3), Math.abs(b3));\n  }\n  /**\n   * Alias for {@link vec4.subtract}\n   * @function\n   */\n\n  var sub$1 = subtract$1;\n  /**\n   * Alias for {@link vec4.multiply}\n   * @function\n   */\n\n  var mul$3 = multiply$3;\n  /**\n   * Alias for {@link vec4.divide}\n   * @function\n   */\n\n  var div$1 = divide$1;\n  /**\n   * Alias for {@link vec4.distance}\n   * @function\n   */\n\n  var dist$1 = distance$1;\n  /**\n   * Alias for {@link vec4.squaredDistance}\n   * @function\n   */\n\n  var sqrDist$1 = squaredDistance$1;\n  /**\n   * Alias for {@link vec4.length}\n   * @function\n   */\n\n  var len$3 = length$3;\n  /**\n   * Alias for {@link vec4.squaredLength}\n   * @function\n   */\n\n  var sqrLen$3 = squaredLength$3;\n  /**\n   * Perform some operation over an array of vec4s.\n   *\n   * @param {Array} a the array of vectors to iterate over\n   * @param {Number} stride Number of elements between the start of each vec4. If 0 assumes tightly packed\n   * @param {Number} offset Number of elements to skip at the beginning of the array\n   * @param {Number} count Number of vec4s to iterate over. If 0 iterates over entire array\n   * @param {Function} fn Function to call for each vector in the array\n   * @param {Object} [arg] additional argument to pass to fn\n   * @returns {Array} a\n   * @function\n   */\n\n  var forEach$1 = function () {\n    var vec = create$3();\n    return function (a, stride, offset, count, fn, arg) {\n      var i, l;\n\n      if (!stride) {\n        stride = 4;\n      }\n\n      if (!offset) {\n        offset = 0;\n      }\n\n      if (count) {\n        l = Math.min(count * stride + offset, a.length);\n      } else {\n        l = a.length;\n      }\n\n      for (i = offset; i < l; i += stride) {\n        vec[0] = a[i];\n        vec[1] = a[i + 1];\n        vec[2] = a[i + 2];\n        vec[3] = a[i + 3];\n        fn(vec, vec, arg);\n        a[i] = vec[0];\n        a[i + 1] = vec[1];\n        a[i + 2] = vec[2];\n        a[i + 3] = vec[3];\n      }\n\n      return a;\n    };\n  }();\n\n  var vec4 = /*#__PURE__*/Object.freeze({\n    __proto__: null,\n    create: create$3,\n    clone: clone$3,\n    fromValues: fromValues$3,\n    copy: copy$3,\n    set: set$3,\n    add: add$3,\n    subtract: subtract$1,\n    multiply: multiply$3,\n    divide: divide$1,\n    ceil: ceil$1,\n    floor: floor$1,\n    min: min$1,\n    max: max$1,\n    round: round$1,\n    scale: scale$3,\n    scaleAndAdd: scaleAndAdd$1,\n    distance: distance$1,\n    squaredDistance: squaredDistance$1,\n    length: length$3,\n    squaredLength: squaredLength$3,\n    negate: negate$1,\n    inverse: inverse$1,\n    normalize: normalize$3,\n    dot: dot$3,\n    cross: cross$1,\n    lerp: lerp$3,\n    random: random$2,\n    transformMat4: transformMat4$1,\n    transformQuat: transformQuat,\n    zero: zero$1,\n    str: str$3,\n    exactEquals: exactEquals$3,\n    equals: equals$3,\n    sub: sub$1,\n    mul: mul$3,\n    div: div$1,\n    dist: dist$1,\n    sqrDist: sqrDist$1,\n    len: len$3,\n    sqrLen: sqrLen$3,\n    forEach: forEach$1\n  });\n\n  /**\n   * Quaternion in the format XYZW\n   * @module quat\n   */\n\n  /**\n   * Creates a new identity quat\n   *\n   * @returns {quat} a new quaternion\n   */\n\n  function create$2() {\n    var out = new ARRAY_TYPE(4);\n\n    if (ARRAY_TYPE != Float32Array) {\n      out[0] = 0;\n      out[1] = 0;\n      out[2] = 0;\n    }\n\n    out[3] = 1;\n    return out;\n  }\n  /**\n   * Set a quat to the identity quaternion\n   *\n   * @param {quat} out the receiving quaternion\n   * @returns {quat} out\n   */\n\n  function identity$1(out) {\n    out[0] = 0;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 1;\n    return out;\n  }\n  /**\n   * Sets a quat from the given angle and rotation axis,\n   * then returns it.\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {ReadonlyVec3} axis the axis around which to rotate\n   * @param {Number} rad the angle in radians\n   * @returns {quat} out\n   **/\n\n  function setAxisAngle(out, axis, rad) {\n    rad = rad * 0.5;\n    var s = Math.sin(rad);\n    out[0] = s * axis[0];\n    out[1] = s * axis[1];\n    out[2] = s * axis[2];\n    out[3] = Math.cos(rad);\n    return out;\n  }\n  /**\n   * Gets the rotation axis and angle for a given\n   *  quaternion. If a quaternion is created with\n   *  setAxisAngle, this method will return the same\n   *  values as providied in the original parameter list\n   *  OR functionally equivalent values.\n   * Example: The quaternion formed by axis [0, 0, 1] and\n   *  angle -90 is the same as the quaternion formed by\n   *  [0, 0, 1] and 270. This method favors the latter.\n   * @param  {vec3} out_axis  Vector receiving the axis of rotation\n   * @param  {ReadonlyQuat} q     Quaternion to be decomposed\n   * @return {Number}     Angle, in radians, of the rotation\n   */\n\n  function getAxisAngle(out_axis, q) {\n    var rad = Math.acos(q[3]) * 2.0;\n    var s = Math.sin(rad / 2.0);\n\n    if (s > EPSILON) {\n      out_axis[0] = q[0] / s;\n      out_axis[1] = q[1] / s;\n      out_axis[2] = q[2] / s;\n    } else {\n      // If s is zero, return any axis (no rotation - axis does not matter)\n      out_axis[0] = 1;\n      out_axis[1] = 0;\n      out_axis[2] = 0;\n    }\n\n    return rad;\n  }\n  /**\n   * Gets the angular distance between two unit quaternions\n   *\n   * @param  {ReadonlyQuat} a     Origin unit quaternion\n   * @param  {ReadonlyQuat} b     Destination unit quaternion\n   * @return {Number}     Angle, in radians, between the two quaternions\n   */\n\n  function getAngle(a, b) {\n    var dotproduct = dot$2(a, b);\n    return Math.acos(2 * dotproduct * dotproduct - 1);\n  }\n  /**\n   * Multiplies two quat's\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {ReadonlyQuat} a the first operand\n   * @param {ReadonlyQuat} b the second operand\n   * @returns {quat} out\n   */\n\n  function multiply$2(out, a, b) {\n    var ax = a[0],\n        ay = a[1],\n        az = a[2],\n        aw = a[3];\n    var bx = b[0],\n        by = b[1],\n        bz = b[2],\n        bw = b[3];\n    out[0] = ax * bw + aw * bx + ay * bz - az * by;\n    out[1] = ay * bw + aw * by + az * bx - ax * bz;\n    out[2] = az * bw + aw * bz + ax * by - ay * bx;\n    out[3] = aw * bw - ax * bx - ay * by - az * bz;\n    return out;\n  }\n  /**\n   * Rotates a quaternion by the given angle about the X axis\n   *\n   * @param {quat} out quat receiving operation result\n   * @param {ReadonlyQuat} a quat to rotate\n   * @param {number} rad angle (in radians) to rotate\n   * @returns {quat} out\n   */\n\n  function rotateX$1(out, a, rad) {\n    rad *= 0.5;\n    var ax = a[0],\n        ay = a[1],\n        az = a[2],\n        aw = a[3];\n    var bx = Math.sin(rad),\n        bw = Math.cos(rad);\n    out[0] = ax * bw + aw * bx;\n    out[1] = ay * bw + az * bx;\n    out[2] = az * bw - ay * bx;\n    out[3] = aw * bw - ax * bx;\n    return out;\n  }\n  /**\n   * Rotates a quaternion by the given angle about the Y axis\n   *\n   * @param {quat} out quat receiving operation result\n   * @param {ReadonlyQuat} a quat to rotate\n   * @param {number} rad angle (in radians) to rotate\n   * @returns {quat} out\n   */\n\n  function rotateY$1(out, a, rad) {\n    rad *= 0.5;\n    var ax = a[0],\n        ay = a[1],\n        az = a[2],\n        aw = a[3];\n    var by = Math.sin(rad),\n        bw = Math.cos(rad);\n    out[0] = ax * bw - az * by;\n    out[1] = ay * bw + aw * by;\n    out[2] = az * bw + ax * by;\n    out[3] = aw * bw - ay * by;\n    return out;\n  }\n  /**\n   * Rotates a quaternion by the given angle about the Z axis\n   *\n   * @param {quat} out quat receiving operation result\n   * @param {ReadonlyQuat} a quat to rotate\n   * @param {number} rad angle (in radians) to rotate\n   * @returns {quat} out\n   */\n\n  function rotateZ$1(out, a, rad) {\n    rad *= 0.5;\n    var ax = a[0],\n        ay = a[1],\n        az = a[2],\n        aw = a[3];\n    var bz = Math.sin(rad),\n        bw = Math.cos(rad);\n    out[0] = ax * bw + ay * bz;\n    out[1] = ay * bw - ax * bz;\n    out[2] = az * bw + aw * bz;\n    out[3] = aw * bw - az * bz;\n    return out;\n  }\n  /**\n   * Calculates the W component of a quat from the X, Y, and Z components.\n   * Assumes that quaternion is 1 unit in length.\n   * Any existing W component will be ignored.\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {ReadonlyQuat} a quat to calculate W component of\n   * @returns {quat} out\n   */\n\n  function calculateW(out, a) {\n    var x = a[0],\n        y = a[1],\n        z = a[2];\n    out[0] = x;\n    out[1] = y;\n    out[2] = z;\n    out[3] = Math.sqrt(Math.abs(1.0 - x * x - y * y - z * z));\n    return out;\n  }\n  /**\n   * Calculate the exponential of a unit quaternion.\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {ReadonlyQuat} a quat to calculate the exponential of\n   * @returns {quat} out\n   */\n\n  function exp(out, a) {\n    var x = a[0],\n        y = a[1],\n        z = a[2],\n        w = a[3];\n    var r = Math.sqrt(x * x + y * y + z * z);\n    var et = Math.exp(w);\n    var s = r > 0 ? et * Math.sin(r) / r : 0;\n    out[0] = x * s;\n    out[1] = y * s;\n    out[2] = z * s;\n    out[3] = et * Math.cos(r);\n    return out;\n  }\n  /**\n   * Calculate the natural logarithm of a unit quaternion.\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {ReadonlyQuat} a quat to calculate the exponential of\n   * @returns {quat} out\n   */\n\n  function ln(out, a) {\n    var x = a[0],\n        y = a[1],\n        z = a[2],\n        w = a[3];\n    var r = Math.sqrt(x * x + y * y + z * z);\n    var t = r > 0 ? Math.atan2(r, w) / r : 0;\n    out[0] = x * t;\n    out[1] = y * t;\n    out[2] = z * t;\n    out[3] = 0.5 * Math.log(x * x + y * y + z * z + w * w);\n    return out;\n  }\n  /**\n   * Calculate the scalar power of a unit quaternion.\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {ReadonlyQuat} a quat to calculate the exponential of\n   * @param {Number} b amount to scale the quaternion by\n   * @returns {quat} out\n   */\n\n  function pow(out, a, b) {\n    ln(out, a);\n    scale$2(out, out, b);\n    exp(out, out);\n    return out;\n  }\n  /**\n   * Performs a spherical linear interpolation between two quat\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {ReadonlyQuat} a the first operand\n   * @param {ReadonlyQuat} b the second operand\n   * @param {Number} t interpolation amount, in the range [0-1], between the two inputs\n   * @returns {quat} out\n   */\n\n  function slerp(out, a, b, t) {\n    // benchmarks:\n    //    http://jsperf.com/quaternion-slerp-implementations\n    var ax = a[0],\n        ay = a[1],\n        az = a[2],\n        aw = a[3];\n    var bx = b[0],\n        by = b[1],\n        bz = b[2],\n        bw = b[3];\n    var omega, cosom, sinom, scale0, scale1; // calc cosine\n\n    cosom = ax * bx + ay * by + az * bz + aw * bw; // adjust signs (if necessary)\n\n    if (cosom < 0.0) {\n      cosom = -cosom;\n      bx = -bx;\n      by = -by;\n      bz = -bz;\n      bw = -bw;\n    } // calculate coefficients\n\n\n    if (1.0 - cosom > EPSILON) {\n      // standard case (slerp)\n      omega = Math.acos(cosom);\n      sinom = Math.sin(omega);\n      scale0 = Math.sin((1.0 - t) * omega) / sinom;\n      scale1 = Math.sin(t * omega) / sinom;\n    } else {\n      // \"from\" and \"to\" quaternions are very close\n      //  ... so we can do a linear interpolation\n      scale0 = 1.0 - t;\n      scale1 = t;\n    } // calculate final values\n\n\n    out[0] = scale0 * ax + scale1 * bx;\n    out[1] = scale0 * ay + scale1 * by;\n    out[2] = scale0 * az + scale1 * bz;\n    out[3] = scale0 * aw + scale1 * bw;\n    return out;\n  }\n  /**\n   * Generates a random unit quaternion\n   *\n   * @param {quat} out the receiving quaternion\n   * @returns {quat} out\n   */\n\n  function random$1(out) {\n    // Implementation of http://planning.cs.uiuc.edu/node198.html\n    // TODO: Calling random 3 times is probably not the fastest solution\n    var u1 = RANDOM();\n    var u2 = RANDOM();\n    var u3 = RANDOM();\n    var sqrt1MinusU1 = Math.sqrt(1 - u1);\n    var sqrtU1 = Math.sqrt(u1);\n    out[0] = sqrt1MinusU1 * Math.sin(2.0 * Math.PI * u2);\n    out[1] = sqrt1MinusU1 * Math.cos(2.0 * Math.PI * u2);\n    out[2] = sqrtU1 * Math.sin(2.0 * Math.PI * u3);\n    out[3] = sqrtU1 * Math.cos(2.0 * Math.PI * u3);\n    return out;\n  }\n  /**\n   * Calculates the inverse of a quat\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {ReadonlyQuat} a quat to calculate inverse of\n   * @returns {quat} out\n   */\n\n  function invert$1(out, a) {\n    var a0 = a[0],\n        a1 = a[1],\n        a2 = a[2],\n        a3 = a[3];\n    var dot = a0 * a0 + a1 * a1 + a2 * a2 + a3 * a3;\n    var invDot = dot ? 1.0 / dot : 0; // TODO: Would be faster to return [0,0,0,0] immediately if dot == 0\n\n    out[0] = -a0 * invDot;\n    out[1] = -a1 * invDot;\n    out[2] = -a2 * invDot;\n    out[3] = a3 * invDot;\n    return out;\n  }\n  /**\n   * Calculates the conjugate of a quat\n   * If the quaternion is normalized, this function is faster than quat.inverse and produces the same result.\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {ReadonlyQuat} a quat to calculate conjugate of\n   * @returns {quat} out\n   */\n\n  function conjugate$1(out, a) {\n    out[0] = -a[0];\n    out[1] = -a[1];\n    out[2] = -a[2];\n    out[3] = a[3];\n    return out;\n  }\n  /**\n   * Creates a quaternion from the given 3x3 rotation matrix.\n   *\n   * NOTE: The resultant quaternion is not normalized, so you should be sure\n   * to renormalize the quaternion yourself where necessary.\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {ReadonlyMat3} m rotation matrix\n   * @returns {quat} out\n   * @function\n   */\n\n  function fromMat3(out, m) {\n    // Algorithm in Ken Shoemake's article in 1987 SIGGRAPH course notes\n    // article \"Quaternion Calculus and Fast Animation\".\n    var fTrace = m[0] + m[4] + m[8];\n    var fRoot;\n\n    if (fTrace > 0.0) {\n      // |w| > 1/2, may as well choose w > 1/2\n      fRoot = Math.sqrt(fTrace + 1.0); // 2w\n\n      out[3] = 0.5 * fRoot;\n      fRoot = 0.5 / fRoot; // 1/(4w)\n\n      out[0] = (m[5] - m[7]) * fRoot;\n      out[1] = (m[6] - m[2]) * fRoot;\n      out[2] = (m[1] - m[3]) * fRoot;\n    } else {\n      // |w| <= 1/2\n      var i = 0;\n      if (m[4] > m[0]) i = 1;\n      if (m[8] > m[i * 3 + i]) i = 2;\n      var j = (i + 1) % 3;\n      var k = (i + 2) % 3;\n      fRoot = Math.sqrt(m[i * 3 + i] - m[j * 3 + j] - m[k * 3 + k] + 1.0);\n      out[i] = 0.5 * fRoot;\n      fRoot = 0.5 / fRoot;\n      out[3] = (m[j * 3 + k] - m[k * 3 + j]) * fRoot;\n      out[j] = (m[j * 3 + i] + m[i * 3 + j]) * fRoot;\n      out[k] = (m[k * 3 + i] + m[i * 3 + k]) * fRoot;\n    }\n\n    return out;\n  }\n  /**\n   * Creates a quaternion from the given euler angle x, y, z using the provided intrinsic order for the conversion.\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {x} x Angle to rotate around X axis in degrees.\n   * @param {y} y Angle to rotate around Y axis in degrees.\n   * @param {z} z Angle to rotate around Z axis in degrees.\n   * @param {'zyx'|'xyz'|'yxz'|'yzx'|'zxy'|'zyx'} order Intrinsic order for conversion, default is zyx.\n   * @returns {quat} out\n   * @function\n   */\n\n  function fromEuler(out, x, y, z) {\n    var order = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : ANGLE_ORDER;\n    var halfToRad = Math.PI / 360;\n    x *= halfToRad;\n    z *= halfToRad;\n    y *= halfToRad;\n    var sx = Math.sin(x);\n    var cx = Math.cos(x);\n    var sy = Math.sin(y);\n    var cy = Math.cos(y);\n    var sz = Math.sin(z);\n    var cz = Math.cos(z);\n\n    switch (order) {\n      case \"xyz\":\n        out[0] = sx * cy * cz + cx * sy * sz;\n        out[1] = cx * sy * cz - sx * cy * sz;\n        out[2] = cx * cy * sz + sx * sy * cz;\n        out[3] = cx * cy * cz - sx * sy * sz;\n        break;\n\n      case \"xzy\":\n        out[0] = sx * cy * cz - cx * sy * sz;\n        out[1] = cx * sy * cz - sx * cy * sz;\n        out[2] = cx * cy * sz + sx * sy * cz;\n        out[3] = cx * cy * cz + sx * sy * sz;\n        break;\n\n      case \"yxz\":\n        out[0] = sx * cy * cz + cx * sy * sz;\n        out[1] = cx * sy * cz - sx * cy * sz;\n        out[2] = cx * cy * sz - sx * sy * cz;\n        out[3] = cx * cy * cz + sx * sy * sz;\n        break;\n\n      case \"yzx\":\n        out[0] = sx * cy * cz + cx * sy * sz;\n        out[1] = cx * sy * cz + sx * cy * sz;\n        out[2] = cx * cy * sz - sx * sy * cz;\n        out[3] = cx * cy * cz - sx * sy * sz;\n        break;\n\n      case \"zxy\":\n        out[0] = sx * cy * cz - cx * sy * sz;\n        out[1] = cx * sy * cz + sx * cy * sz;\n        out[2] = cx * cy * sz + sx * sy * cz;\n        out[3] = cx * cy * cz - sx * sy * sz;\n        break;\n\n      case \"zyx\":\n        out[0] = sx * cy * cz - cx * sy * sz;\n        out[1] = cx * sy * cz + sx * cy * sz;\n        out[2] = cx * cy * sz - sx * sy * cz;\n        out[3] = cx * cy * cz + sx * sy * sz;\n        break;\n\n      default:\n        throw new Error('Unknown angle order ' + order);\n    }\n\n    return out;\n  }\n  /**\n   * Returns a string representation of a quaternion\n   *\n   * @param {ReadonlyQuat} a vector to represent as a string\n   * @returns {String} string representation of the vector\n   */\n\n  function str$2(a) {\n    return \"quat(\" + a[0] + \", \" + a[1] + \", \" + a[2] + \", \" + a[3] + \")\";\n  }\n  /**\n   * Creates a new quat initialized with values from an existing quaternion\n   *\n   * @param {ReadonlyQuat} a quaternion to clone\n   * @returns {quat} a new quaternion\n   * @function\n   */\n\n  var clone$2 = clone$3;\n  /**\n   * Creates a new quat initialized with the given values\n   *\n   * @param {Number} x X component\n   * @param {Number} y Y component\n   * @param {Number} z Z component\n   * @param {Number} w W component\n   * @returns {quat} a new quaternion\n   * @function\n   */\n\n  var fromValues$2 = fromValues$3;\n  /**\n   * Copy the values from one quat to another\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {ReadonlyQuat} a the source quaternion\n   * @returns {quat} out\n   * @function\n   */\n\n  var copy$2 = copy$3;\n  /**\n   * Set the components of a quat to the given values\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {Number} x X component\n   * @param {Number} y Y component\n   * @param {Number} z Z component\n   * @param {Number} w W component\n   * @returns {quat} out\n   * @function\n   */\n\n  var set$2 = set$3;\n  /**\n   * Adds two quat's\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {ReadonlyQuat} a the first operand\n   * @param {ReadonlyQuat} b the second operand\n   * @returns {quat} out\n   * @function\n   */\n\n  var add$2 = add$3;\n  /**\n   * Alias for {@link quat.multiply}\n   * @function\n   */\n\n  var mul$2 = multiply$2;\n  /**\n   * Scales a quat by a scalar number\n   *\n   * @param {quat} out the receiving vector\n   * @param {ReadonlyQuat} a the vector to scale\n   * @param {Number} b amount to scale the vector by\n   * @returns {quat} out\n   * @function\n   */\n\n  var scale$2 = scale$3;\n  /**\n   * Calculates the dot product of two quat's\n   *\n   * @param {ReadonlyQuat} a the first operand\n   * @param {ReadonlyQuat} b the second operand\n   * @returns {Number} dot product of a and b\n   * @function\n   */\n\n  var dot$2 = dot$3;\n  /**\n   * Performs a linear interpolation between two quat's\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {ReadonlyQuat} a the first operand\n   * @param {ReadonlyQuat} b the second operand\n   * @param {Number} t interpolation amount, in the range [0-1], between the two inputs\n   * @returns {quat} out\n   * @function\n   */\n\n  var lerp$2 = lerp$3;\n  /**\n   * Calculates the length of a quat\n   *\n   * @param {ReadonlyQuat} a vector to calculate length of\n   * @returns {Number} length of a\n   */\n\n  var length$2 = length$3;\n  /**\n   * Alias for {@link quat.length}\n   * @function\n   */\n\n  var len$2 = length$2;\n  /**\n   * Calculates the squared length of a quat\n   *\n   * @param {ReadonlyQuat} a vector to calculate squared length of\n   * @returns {Number} squared length of a\n   * @function\n   */\n\n  var squaredLength$2 = squaredLength$3;\n  /**\n   * Alias for {@link quat.squaredLength}\n   * @function\n   */\n\n  var sqrLen$2 = squaredLength$2;\n  /**\n   * Normalize a quat\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {ReadonlyQuat} a quaternion to normalize\n   * @returns {quat} out\n   * @function\n   */\n\n  var normalize$2 = normalize$3;\n  /**\n   * Returns whether the quaternions have exactly the same elements in the same position (when compared with ===)\n   *\n   * @param {ReadonlyQuat} a The first quaternion.\n   * @param {ReadonlyQuat} b The second quaternion.\n   * @returns {Boolean} True if the vectors are equal, false otherwise.\n   */\n\n  var exactEquals$2 = exactEquals$3;\n  /**\n   * Returns whether the quaternions point approximately to the same direction.\n   *\n   * Both quaternions are assumed to be unit length.\n   *\n   * @param {ReadonlyQuat} a The first unit quaternion.\n   * @param {ReadonlyQuat} b The second unit quaternion.\n   * @returns {Boolean} True if the quaternions are equal, false otherwise.\n   */\n\n  function equals$2(a, b) {\n    return Math.abs(dot$3(a, b)) >= 1 - EPSILON;\n  }\n  /**\n   * Sets a quaternion to represent the shortest rotation from one\n   * vector to another.\n   *\n   * Both vectors are assumed to be unit length.\n   *\n   * @param {quat} out the receiving quaternion.\n   * @param {ReadonlyVec3} a the initial vector\n   * @param {ReadonlyVec3} b the destination vector\n   * @returns {quat} out\n   */\n\n  var rotationTo = function () {\n    var tmpvec3 = create$4();\n    var xUnitVec3 = fromValues$4(1, 0, 0);\n    var yUnitVec3 = fromValues$4(0, 1, 0);\n    return function (out, a, b) {\n      var dot = dot$4(a, b);\n\n      if (dot < -0.999999) {\n        cross$2(tmpvec3, xUnitVec3, a);\n        if (len$4(tmpvec3) < 0.000001) cross$2(tmpvec3, yUnitVec3, a);\n        normalize$4(tmpvec3, tmpvec3);\n        setAxisAngle(out, tmpvec3, Math.PI);\n        return out;\n      } else if (dot > 0.999999) {\n        out[0] = 0;\n        out[1] = 0;\n        out[2] = 0;\n        out[3] = 1;\n        return out;\n      } else {\n        cross$2(tmpvec3, a, b);\n        out[0] = tmpvec3[0];\n        out[1] = tmpvec3[1];\n        out[2] = tmpvec3[2];\n        out[3] = 1 + dot;\n        return normalize$2(out, out);\n      }\n    };\n  }();\n  /**\n   * Performs a spherical linear interpolation with two control points\n   *\n   * @param {quat} out the receiving quaternion\n   * @param {ReadonlyQuat} a the first operand\n   * @param {ReadonlyQuat} b the second operand\n   * @param {ReadonlyQuat} c the third operand\n   * @param {ReadonlyQuat} d the fourth operand\n   * @param {Number} t interpolation amount, in the range [0-1], between the two inputs\n   * @returns {quat} out\n   */\n\n  var sqlerp = function () {\n    var temp1 = create$2();\n    var temp2 = create$2();\n    return function (out, a, b, c, d, t) {\n      slerp(temp1, a, d, t);\n      slerp(temp2, b, c, t);\n      slerp(out, temp1, temp2, 2 * t * (1 - t));\n      return out;\n    };\n  }();\n  /**\n   * Sets the specified quaternion with values corresponding to the given\n   * axes. Each axis is a vec3 and is expected to be unit length and\n   * perpendicular to all other specified axes.\n   *\n   * @param {ReadonlyVec3} view  the vector representing the viewing direction\n   * @param {ReadonlyVec3} right the vector representing the local \"right\" direction\n   * @param {ReadonlyVec3} up    the vector representing the local \"up\" direction\n   * @returns {quat} out\n   */\n\n  var setAxes = function () {\n    var matr = create$6();\n    return function (out, view, right, up) {\n      matr[0] = right[0];\n      matr[3] = right[1];\n      matr[6] = right[2];\n      matr[1] = up[0];\n      matr[4] = up[1];\n      matr[7] = up[2];\n      matr[2] = -view[0];\n      matr[5] = -view[1];\n      matr[8] = -view[2];\n      return normalize$2(out, fromMat3(out, matr));\n    };\n  }();\n\n  var quat = /*#__PURE__*/Object.freeze({\n    __proto__: null,\n    create: create$2,\n    identity: identity$1,\n    setAxisAngle: setAxisAngle,\n    getAxisAngle: getAxisAngle,\n    getAngle: getAngle,\n    multiply: multiply$2,\n    rotateX: rotateX$1,\n    rotateY: rotateY$1,\n    rotateZ: rotateZ$1,\n    calculateW: calculateW,\n    exp: exp,\n    ln: ln,\n    pow: pow,\n    slerp: slerp,\n    random: random$1,\n    invert: invert$1,\n    conjugate: conjugate$1,\n    fromMat3: fromMat3,\n    fromEuler: fromEuler,\n    str: str$2,\n    clone: clone$2,\n    fromValues: fromValues$2,\n    copy: copy$2,\n    set: set$2,\n    add: add$2,\n    mul: mul$2,\n    scale: scale$2,\n    dot: dot$2,\n    lerp: lerp$2,\n    length: length$2,\n    len: len$2,\n    squaredLength: squaredLength$2,\n    sqrLen: sqrLen$2,\n    normalize: normalize$2,\n    exactEquals: exactEquals$2,\n    equals: equals$2,\n    rotationTo: rotationTo,\n    sqlerp: sqlerp,\n    setAxes: setAxes\n  });\n\n  /**\n   * Dual Quaternion<br>\n   * Format: [real, dual]<br>\n   * Quaternion format: XYZW<br>\n   * Make sure to have normalized dual quaternions, otherwise the functions may not work as intended.<br>\n   * @module quat2\n   */\n\n  /**\n   * Creates a new identity dual quat\n   *\n   * @returns {quat2} a new dual quaternion [real -> rotation, dual -> translation]\n   */\n\n  function create$1() {\n    var dq = new ARRAY_TYPE(8);\n\n    if (ARRAY_TYPE != Float32Array) {\n      dq[0] = 0;\n      dq[1] = 0;\n      dq[2] = 0;\n      dq[4] = 0;\n      dq[5] = 0;\n      dq[6] = 0;\n      dq[7] = 0;\n    }\n\n    dq[3] = 1;\n    return dq;\n  }\n  /**\n   * Creates a new quat initialized with values from an existing quaternion\n   *\n   * @param {ReadonlyQuat2} a dual quaternion to clone\n   * @returns {quat2} new dual quaternion\n   * @function\n   */\n\n  function clone$1(a) {\n    var dq = new ARRAY_TYPE(8);\n    dq[0] = a[0];\n    dq[1] = a[1];\n    dq[2] = a[2];\n    dq[3] = a[3];\n    dq[4] = a[4];\n    dq[5] = a[5];\n    dq[6] = a[6];\n    dq[7] = a[7];\n    return dq;\n  }\n  /**\n   * Creates a new dual quat initialized with the given values\n   *\n   * @param {Number} x1 X component\n   * @param {Number} y1 Y component\n   * @param {Number} z1 Z component\n   * @param {Number} w1 W component\n   * @param {Number} x2 X component\n   * @param {Number} y2 Y component\n   * @param {Number} z2 Z component\n   * @param {Number} w2 W component\n   * @returns {quat2} new dual quaternion\n   * @function\n   */\n\n  function fromValues$1(x1, y1, z1, w1, x2, y2, z2, w2) {\n    var dq = new ARRAY_TYPE(8);\n    dq[0] = x1;\n    dq[1] = y1;\n    dq[2] = z1;\n    dq[3] = w1;\n    dq[4] = x2;\n    dq[5] = y2;\n    dq[6] = z2;\n    dq[7] = w2;\n    return dq;\n  }\n  /**\n   * Creates a new dual quat from the given values (quat and translation)\n   *\n   * @param {Number} x1 X component\n   * @param {Number} y1 Y component\n   * @param {Number} z1 Z component\n   * @param {Number} w1 W component\n   * @param {Number} x2 X component (translation)\n   * @param {Number} y2 Y component (translation)\n   * @param {Number} z2 Z component (translation)\n   * @returns {quat2} new dual quaternion\n   * @function\n   */\n\n  function fromRotationTranslationValues(x1, y1, z1, w1, x2, y2, z2) {\n    var dq = new ARRAY_TYPE(8);\n    dq[0] = x1;\n    dq[1] = y1;\n    dq[2] = z1;\n    dq[3] = w1;\n    var ax = x2 * 0.5,\n        ay = y2 * 0.5,\n        az = z2 * 0.5;\n    dq[4] = ax * w1 + ay * z1 - az * y1;\n    dq[5] = ay * w1 + az * x1 - ax * z1;\n    dq[6] = az * w1 + ax * y1 - ay * x1;\n    dq[7] = -ax * x1 - ay * y1 - az * z1;\n    return dq;\n  }\n  /**\n   * Creates a dual quat from a quaternion and a translation\n   *\n   * @param {ReadonlyQuat2} dual quaternion receiving operation result\n   * @param {ReadonlyQuat} q a normalized quaternion\n   * @param {ReadonlyVec3} t translation vector\n   * @returns {quat2} dual quaternion receiving operation result\n   * @function\n   */\n\n  function fromRotationTranslation(out, q, t) {\n    var ax = t[0] * 0.5,\n        ay = t[1] * 0.5,\n        az = t[2] * 0.5,\n        bx = q[0],\n        by = q[1],\n        bz = q[2],\n        bw = q[3];\n    out[0] = bx;\n    out[1] = by;\n    out[2] = bz;\n    out[3] = bw;\n    out[4] = ax * bw + ay * bz - az * by;\n    out[5] = ay * bw + az * bx - ax * bz;\n    out[6] = az * bw + ax * by - ay * bx;\n    out[7] = -ax * bx - ay * by - az * bz;\n    return out;\n  }\n  /**\n   * Creates a dual quat from a translation\n   *\n   * @param {ReadonlyQuat2} dual quaternion receiving operation result\n   * @param {ReadonlyVec3} t translation vector\n   * @returns {quat2} dual quaternion receiving operation result\n   * @function\n   */\n\n  function fromTranslation(out, t) {\n    out[0] = 0;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 1;\n    out[4] = t[0] * 0.5;\n    out[5] = t[1] * 0.5;\n    out[6] = t[2] * 0.5;\n    out[7] = 0;\n    return out;\n  }\n  /**\n   * Creates a dual quat from a quaternion\n   *\n   * @param {ReadonlyQuat2} dual quaternion receiving operation result\n   * @param {ReadonlyQuat} q the quaternion\n   * @returns {quat2} dual quaternion receiving operation result\n   * @function\n   */\n\n  function fromRotation(out, q) {\n    out[0] = q[0];\n    out[1] = q[1];\n    out[2] = q[2];\n    out[3] = q[3];\n    out[4] = 0;\n    out[5] = 0;\n    out[6] = 0;\n    out[7] = 0;\n    return out;\n  }\n  /**\n   * Creates a new dual quat from a matrix (4x4)\n   *\n   * @param {quat2} out the dual quaternion\n   * @param {ReadonlyMat4} a the matrix\n   * @returns {quat2} dual quat receiving operation result\n   * @function\n   */\n\n  function fromMat4(out, a) {\n    //TODO Optimize this\n    var outer = create$2();\n    getRotation(outer, a);\n    var t = new ARRAY_TYPE(3);\n    getTranslation$1(t, a);\n    fromRotationTranslation(out, outer, t);\n    return out;\n  }\n  /**\n   * Copy the values from one dual quat to another\n   *\n   * @param {quat2} out the receiving dual quaternion\n   * @param {ReadonlyQuat2} a the source dual quaternion\n   * @returns {quat2} out\n   * @function\n   */\n\n  function copy$1(out, a) {\n    out[0] = a[0];\n    out[1] = a[1];\n    out[2] = a[2];\n    out[3] = a[3];\n    out[4] = a[4];\n    out[5] = a[5];\n    out[6] = a[6];\n    out[7] = a[7];\n    return out;\n  }\n  /**\n   * Set a dual quat to the identity dual quaternion\n   *\n   * @param {quat2} out the receiving quaternion\n   * @returns {quat2} out\n   */\n\n  function identity(out) {\n    out[0] = 0;\n    out[1] = 0;\n    out[2] = 0;\n    out[3] = 1;\n    out[4] = 0;\n    out[5] = 0;\n    out[6] = 0;\n    out[7] = 0;\n    return out;\n  }\n  /**\n   * Set the components of a dual quat to the given values\n   *\n   * @param {quat2} out the receiving quaternion\n   * @param {Number} x1 X component\n   * @param {Number} y1 Y component\n   * @param {Number} z1 Z component\n   * @param {Number} w1 W component\n   * @param {Number} x2 X component\n   * @param {Number} y2 Y component\n   * @param {Number} z2 Z component\n   * @param {Number} w2 W component\n   * @returns {quat2} out\n   * @function\n   */\n\n  function set$1(out, x1, y1, z1, w1, x2, y2, z2, w2) {\n    out[0] = x1;\n    out[1] = y1;\n    out[2] = z1;\n    out[3] = w1;\n    out[4] = x2;\n    out[5] = y2;\n    out[6] = z2;\n    out[7] = w2;\n    return out;\n  }\n  /**\n   * Gets the real part of a dual quat\n   * @param  {quat} out real part\n   * @param  {ReadonlyQuat2} a Dual Quaternion\n   * @return {quat} real part\n   */\n\n  var getReal = copy$2;\n  /**\n   * Gets the dual part of a dual quat\n   * @param  {quat} out dual part\n   * @param  {ReadonlyQuat2} a Dual Quaternion\n   * @return {quat} dual part\n   */\n\n  function getDual(out, a) {\n    out[0] = a[4];\n    out[1] = a[5];\n    out[2] = a[6];\n    out[3] = a[7];\n    return out;\n  }\n  /**\n   * Set the real component of a dual quat to the given quaternion\n   *\n   * @param {quat2} out the receiving quaternion\n   * @param {ReadonlyQuat} q a quaternion representing the real part\n   * @returns {quat2} out\n   * @function\n   */\n\n  var setReal = copy$2;\n  /**\n   * Set the dual component of a dual quat to the given quaternion\n   *\n   * @param {quat2} out the receiving quaternion\n   * @param {ReadonlyQuat} q a quaternion representing the dual part\n   * @returns {quat2} out\n   * @function\n   */\n\n  function setDual(out, q) {\n    out[4] = q[0];\n    out[5] = q[1];\n    out[6] = q[2];\n    out[7] = q[3];\n    return out;\n  }\n  /**\n   * Gets the translation of a normalized dual quat\n   * @param  {vec3} out translation\n   * @param  {ReadonlyQuat2} a Dual Quaternion to be decomposed\n   * @return {vec3} translation\n   */\n\n  function getTranslation(out, a) {\n    var ax = a[4],\n        ay = a[5],\n        az = a[6],\n        aw = a[7],\n        bx = -a[0],\n        by = -a[1],\n        bz = -a[2],\n        bw = a[3];\n    out[0] = (ax * bw + aw * bx + ay * bz - az * by) * 2;\n    out[1] = (ay * bw + aw * by + az * bx - ax * bz) * 2;\n    out[2] = (az * bw + aw * bz + ax * by - ay * bx) * 2;\n    return out;\n  }\n  /**\n   * Translates a dual quat by the given vector\n   *\n   * @param {quat2} out the receiving dual quaternion\n   * @param {ReadonlyQuat2} a the dual quaternion to translate\n   * @param {ReadonlyVec3} v vector to translate by\n   * @returns {quat2} out\n   */\n\n  function translate(out, a, v) {\n    var ax1 = a[0],\n        ay1 = a[1],\n        az1 = a[2],\n        aw1 = a[3],\n        bx1 = v[0] * 0.5,\n        by1 = v[1] * 0.5,\n        bz1 = v[2] * 0.5,\n        ax2 = a[4],\n        ay2 = a[5],\n        az2 = a[6],\n        aw2 = a[7];\n    out[0] = ax1;\n    out[1] = ay1;\n    out[2] = az1;\n    out[3] = aw1;\n    out[4] = aw1 * bx1 + ay1 * bz1 - az1 * by1 + ax2;\n    out[5] = aw1 * by1 + az1 * bx1 - ax1 * bz1 + ay2;\n    out[6] = aw1 * bz1 + ax1 * by1 - ay1 * bx1 + az2;\n    out[7] = -ax1 * bx1 - ay1 * by1 - az1 * bz1 + aw2;\n    return out;\n  }\n  /**\n   * Rotates a dual quat around the X axis\n   *\n   * @param {quat2} out the receiving dual quaternion\n   * @param {ReadonlyQuat2} a the dual quaternion to rotate\n   * @param {number} rad how far should the rotation be\n   * @returns {quat2} out\n   */\n\n  function rotateX(out, a, rad) {\n    var bx = -a[0],\n        by = -a[1],\n        bz = -a[2],\n        bw = a[3],\n        ax = a[4],\n        ay = a[5],\n        az = a[6],\n        aw = a[7],\n        ax1 = ax * bw + aw * bx + ay * bz - az * by,\n        ay1 = ay * bw + aw * by + az * bx - ax * bz,\n        az1 = az * bw + aw * bz + ax * by - ay * bx,\n        aw1 = aw * bw - ax * bx - ay * by - az * bz;\n    rotateX$1(out, a, rad);\n    bx = out[0];\n    by = out[1];\n    bz = out[2];\n    bw = out[3];\n    out[4] = ax1 * bw + aw1 * bx + ay1 * bz - az1 * by;\n    out[5] = ay1 * bw + aw1 * by + az1 * bx - ax1 * bz;\n    out[6] = az1 * bw + aw1 * bz + ax1 * by - ay1 * bx;\n    out[7] = aw1 * bw - ax1 * bx - ay1 * by - az1 * bz;\n    return out;\n  }\n  /**\n   * Rotates a dual quat around the Y axis\n   *\n   * @param {quat2} out the receiving dual quaternion\n   * @param {ReadonlyQuat2} a the dual quaternion to rotate\n   * @param {number} rad how far should the rotation be\n   * @returns {quat2} out\n   */\n\n  function rotateY(out, a, rad) {\n    var bx = -a[0],\n        by = -a[1],\n        bz = -a[2],\n        bw = a[3],\n        ax = a[4],\n        ay = a[5],\n        az = a[6],\n        aw = a[7],\n        ax1 = ax * bw + aw * bx + ay * bz - az * by,\n        ay1 = ay * bw + aw * by + az * bx - ax * bz,\n        az1 = az * bw + aw * bz + ax * by - ay * bx,\n        aw1 = aw * bw - ax * bx - ay * by - az * bz;\n    rotateY$1(out, a, rad);\n    bx = out[0];\n    by = out[1];\n    bz = out[2];\n    bw = out[3];\n    out[4] = ax1 * bw + aw1 * bx + ay1 * bz - az1 * by;\n    out[5] = ay1 * bw + aw1 * by + az1 * bx - ax1 * bz;\n    out[6] = az1 * bw + aw1 * bz + ax1 * by - ay1 * bx;\n    out[7] = aw1 * bw - ax1 * bx - ay1 * by - az1 * bz;\n    return out;\n  }\n  /**\n   * Rotates a dual quat around the Z axis\n   *\n   * @param {quat2} out the receiving dual quaternion\n   * @param {ReadonlyQuat2} a the dual quaternion to rotate\n   * @param {number} rad how far should the rotation be\n   * @returns {quat2} out\n   */\n\n  function rotateZ(out, a, rad) {\n    var bx = -a[0],\n        by = -a[1],\n        bz = -a[2],\n        bw = a[3],\n        ax = a[4],\n        ay = a[5],\n        az = a[6],\n        aw = a[7],\n        ax1 = ax * bw + aw * bx + ay * bz - az * by,\n        ay1 = ay * bw + aw * by + az * bx - ax * bz,\n        az1 = az * bw + aw * bz + ax * by - ay * bx,\n        aw1 = aw * bw - ax * bx - ay * by - az * bz;\n    rotateZ$1(out, a, rad);\n    bx = out[0];\n    by = out[1];\n    bz = out[2];\n    bw = out[3];\n    out[4] = ax1 * bw + aw1 * bx + ay1 * bz - az1 * by;\n    out[5] = ay1 * bw + aw1 * by + az1 * bx - ax1 * bz;\n    out[6] = az1 * bw + aw1 * bz + ax1 * by - ay1 * bx;\n    out[7] = aw1 * bw - ax1 * bx - ay1 * by - az1 * bz;\n    return out;\n  }\n  /**\n   * Rotates a dual quat by a given quaternion (a * q)\n   *\n   * @param {quat2} out the receiving dual quaternion\n   * @param {ReadonlyQuat2} a the dual quaternion to rotate\n   * @param {ReadonlyQuat} q quaternion to rotate by\n   * @returns {quat2} out\n   */\n\n  function rotateByQuatAppend(out, a, q) {\n    var qx = q[0],\n        qy = q[1],\n        qz = q[2],\n        qw = q[3],\n        ax = a[0],\n        ay = a[1],\n        az = a[2],\n        aw = a[3];\n    out[0] = ax * qw + aw * qx + ay * qz - az * qy;\n    out[1] = ay * qw + aw * qy + az * qx - ax * qz;\n    out[2] = az * qw + aw * qz + ax * qy - ay * qx;\n    out[3] = aw * qw - ax * qx - ay * qy - az * qz;\n    ax = a[4];\n    ay = a[5];\n    az = a[6];\n    aw = a[7];\n    out[4] = ax * qw + aw * qx + ay * qz - az * qy;\n    out[5] = ay * qw + aw * qy + az * qx - ax * qz;\n    out[6] = az * qw + aw * qz + ax * qy - ay * qx;\n    out[7] = aw * qw - ax * qx - ay * qy - az * qz;\n    return out;\n  }\n  /**\n   * Rotates a dual quat by a given quaternion (q * a)\n   *\n   * @param {quat2} out the receiving dual quaternion\n   * @param {ReadonlyQuat} q quaternion to rotate by\n   * @param {ReadonlyQuat2} a the dual quaternion to rotate\n   * @returns {quat2} out\n   */\n\n  function rotateByQuatPrepend(out, q, a) {\n    var qx = q[0],\n        qy = q[1],\n        qz = q[2],\n        qw = q[3],\n        bx = a[0],\n        by = a[1],\n        bz = a[2],\n        bw = a[3];\n    out[0] = qx * bw + qw * bx + qy * bz - qz * by;\n    out[1] = qy * bw + qw * by + qz * bx - qx * bz;\n    out[2] = qz * bw + qw * bz + qx * by - qy * bx;\n    out[3] = qw * bw - qx * bx - qy * by - qz * bz;\n    bx = a[4];\n    by = a[5];\n    bz = a[6];\n    bw = a[7];\n    out[4] = qx * bw + qw * bx + qy * bz - qz * by;\n    out[5] = qy * bw + qw * by + qz * bx - qx * bz;\n    out[6] = qz * bw + qw * bz + qx * by - qy * bx;\n    out[7] = qw * bw - qx * bx - qy * by - qz * bz;\n    return out;\n  }\n  /**\n   * Rotates a dual quat around a given axis. Does the normalisation automatically\n   *\n   * @param {quat2} out the receiving dual quaternion\n   * @param {ReadonlyQuat2} a the dual quaternion to rotate\n   * @param {ReadonlyVec3} axis the axis to rotate around\n   * @param {Number} rad how far the rotation should be\n   * @returns {quat2} out\n   */\n\n  function rotateAroundAxis(out, a, axis, rad) {\n    //Special case for rad = 0\n    if (Math.abs(rad) < EPSILON) {\n      return copy$1(out, a);\n    }\n\n    var axisLength = Math.hypot(axis[0], axis[1], axis[2]);\n    rad = rad * 0.5;\n    var s = Math.sin(rad);\n    var bx = s * axis[0] / axisLength;\n    var by = s * axis[1] / axisLength;\n    var bz = s * axis[2] / axisLength;\n    var bw = Math.cos(rad);\n    var ax1 = a[0],\n        ay1 = a[1],\n        az1 = a[2],\n        aw1 = a[3];\n    out[0] = ax1 * bw + aw1 * bx + ay1 * bz - az1 * by;\n    out[1] = ay1 * bw + aw1 * by + az1 * bx - ax1 * bz;\n    out[2] = az1 * bw + aw1 * bz + ax1 * by - ay1 * bx;\n    out[3] = aw1 * bw - ax1 * bx - ay1 * by - az1 * bz;\n    var ax = a[4],\n        ay = a[5],\n        az = a[6],\n        aw = a[7];\n    out[4] = ax * bw + aw * bx + ay * bz - az * by;\n    out[5] = ay * bw + aw * by + az * bx - ax * bz;\n    out[6] = az * bw + aw * bz + ax * by - ay * bx;\n    out[7] = aw * bw - ax * bx - ay * by - az * bz;\n    return out;\n  }\n  /**\n   * Adds two dual quat's\n   *\n   * @param {quat2} out the receiving dual quaternion\n   * @param {ReadonlyQuat2} a the first operand\n   * @param {ReadonlyQuat2} b the second operand\n   * @returns {quat2} out\n   * @function\n   */\n\n  function add$1(out, a, b) {\n    out[0] = a[0] + b[0];\n    out[1] = a[1] + b[1];\n    out[2] = a[2] + b[2];\n    out[3] = a[3] + b[3];\n    out[4] = a[4] + b[4];\n    out[5] = a[5] + b[5];\n    out[6] = a[6] + b[6];\n    out[7] = a[7] + b[7];\n    return out;\n  }\n  /**\n   * Multiplies two dual quat's\n   *\n   * @param {quat2} out the receiving dual quaternion\n   * @param {ReadonlyQuat2} a the first operand\n   * @param {ReadonlyQuat2} b the second operand\n   * @returns {quat2} out\n   */\n\n  function multiply$1(out, a, b) {\n    var ax0 = a[0],\n        ay0 = a[1],\n        az0 = a[2],\n        aw0 = a[3],\n        bx1 = b[4],\n        by1 = b[5],\n        bz1 = b[6],\n        bw1 = b[7],\n        ax1 = a[4],\n        ay1 = a[5],\n        az1 = a[6],\n        aw1 = a[7],\n        bx0 = b[0],\n        by0 = b[1],\n        bz0 = b[2],\n        bw0 = b[3];\n    out[0] = ax0 * bw0 + aw0 * bx0 + ay0 * bz0 - az0 * by0;\n    out[1] = ay0 * bw0 + aw0 * by0 + az0 * bx0 - ax0 * bz0;\n    out[2] = az0 * bw0 + aw0 * bz0 + ax0 * by0 - ay0 * bx0;\n    out[3] = aw0 * bw0 - ax0 * bx0 - ay0 * by0 - az0 * bz0;\n    out[4] = ax0 * bw1 + aw0 * bx1 + ay0 * bz1 - az0 * by1 + ax1 * bw0 + aw1 * bx0 + ay1 * bz0 - az1 * by0;\n    out[5] = ay0 * bw1 + aw0 * by1 + az0 * bx1 - ax0 * bz1 + ay1 * bw0 + aw1 * by0 + az1 * bx0 - ax1 * bz0;\n    out[6] = az0 * bw1 + aw0 * bz1 + ax0 * by1 - ay0 * bx1 + az1 * bw0 + aw1 * bz0 + ax1 * by0 - ay1 * bx0;\n    out[7] = aw0 * bw1 - ax0 * bx1 - ay0 * by1 - az0 * bz1 + aw1 * bw0 - ax1 * bx0 - ay1 * by0 - az1 * bz0;\n    return out;\n  }\n  /**\n   * Alias for {@link quat2.multiply}\n   * @function\n   */\n\n  var mul$1 = multiply$1;\n  /**\n   * Scales a dual quat by a scalar number\n   *\n   * @param {quat2} out the receiving dual quat\n   * @param {ReadonlyQuat2} a the dual quat to scale\n   * @param {Number} b amount to scale the dual quat by\n   * @returns {quat2} out\n   * @function\n   */\n\n  function scale$1(out, a, b) {\n    out[0] = a[0] * b;\n    out[1] = a[1] * b;\n    out[2] = a[2] * b;\n    out[3] = a[3] * b;\n    out[4] = a[4] * b;\n    out[5] = a[5] * b;\n    out[6] = a[6] * b;\n    out[7] = a[7] * b;\n    return out;\n  }\n  /**\n   * Calculates the dot product of two dual quat's (The dot product of the real parts)\n   *\n   * @param {ReadonlyQuat2} a the first operand\n   * @param {ReadonlyQuat2} b the second operand\n   * @returns {Number} dot product of a and b\n   * @function\n   */\n\n  var dot$1 = dot$2;\n  /**\n   * Performs a linear interpolation between two dual quats's\n   * NOTE: The resulting dual quaternions won't always be normalized (The error is most noticeable when t = 0.5)\n   *\n   * @param {quat2} out the receiving dual quat\n   * @param {ReadonlyQuat2} a the first operand\n   * @param {ReadonlyQuat2} b the second operand\n   * @param {Number} t interpolation amount, in the range [0-1], between the two inputs\n   * @returns {quat2} out\n   */\n\n  function lerp$1(out, a, b, t) {\n    var mt = 1 - t;\n    if (dot$1(a, b) < 0) t = -t;\n    out[0] = a[0] * mt + b[0] * t;\n    out[1] = a[1] * mt + b[1] * t;\n    out[2] = a[2] * mt + b[2] * t;\n    out[3] = a[3] * mt + b[3] * t;\n    out[4] = a[4] * mt + b[4] * t;\n    out[5] = a[5] * mt + b[5] * t;\n    out[6] = a[6] * mt + b[6] * t;\n    out[7] = a[7] * mt + b[7] * t;\n    return out;\n  }\n  /**\n   * Calculates the inverse of a dual quat. If they are normalized, conjugate is cheaper\n   *\n   * @param {quat2} out the receiving dual quaternion\n   * @param {ReadonlyQuat2} a dual quat to calculate inverse of\n   * @returns {quat2} out\n   */\n\n  function invert(out, a) {\n    var sqlen = squaredLength$1(a);\n    out[0] = -a[0] / sqlen;\n    out[1] = -a[1] / sqlen;\n    out[2] = -a[2] / sqlen;\n    out[3] = a[3] / sqlen;\n    out[4] = -a[4] / sqlen;\n    out[5] = -a[5] / sqlen;\n    out[6] = -a[6] / sqlen;\n    out[7] = a[7] / sqlen;\n    return out;\n  }\n  /**\n   * Calculates the conjugate of a dual quat\n   * If the dual quaternion is normalized, this function is faster than quat2.inverse and produces the same result.\n   *\n   * @param {quat2} out the receiving quaternion\n   * @param {ReadonlyQuat2} a quat to calculate conjugate of\n   * @returns {quat2} out\n   */\n\n  function conjugate(out, a) {\n    out[0] = -a[0];\n    out[1] = -a[1];\n    out[2] = -a[2];\n    out[3] = a[3];\n    out[4] = -a[4];\n    out[5] = -a[5];\n    out[6] = -a[6];\n    out[7] = a[7];\n    return out;\n  }\n  /**\n   * Calculates the length of a dual quat\n   *\n   * @param {ReadonlyQuat2} a dual quat to calculate length of\n   * @returns {Number} length of a\n   * @function\n   */\n\n  var length$1 = length$2;\n  /**\n   * Alias for {@link quat2.length}\n   * @function\n   */\n\n  var len$1 = length$1;\n  /**\n   * Calculates the squared length of a dual quat\n   *\n   * @param {ReadonlyQuat2} a dual quat to calculate squared length of\n   * @returns {Number} squared length of a\n   * @function\n   */\n\n  var squaredLength$1 = squaredLength$2;\n  /**\n   * Alias for {@link quat2.squaredLength}\n   * @function\n   */\n\n  var sqrLen$1 = squaredLength$1;\n  /**\n   * Normalize a dual quat\n   *\n   * @param {quat2} out the receiving dual quaternion\n   * @param {ReadonlyQuat2} a dual quaternion to normalize\n   * @returns {quat2} out\n   * @function\n   */\n\n  function normalize$1(out, a) {\n    var magnitude = squaredLength$1(a);\n\n    if (magnitude > 0) {\n      magnitude = Math.sqrt(magnitude);\n      var a0 = a[0] / magnitude;\n      var a1 = a[1] / magnitude;\n      var a2 = a[2] / magnitude;\n      var a3 = a[3] / magnitude;\n      var b0 = a[4];\n      var b1 = a[5];\n      var b2 = a[6];\n      var b3 = a[7];\n      var a_dot_b = a0 * b0 + a1 * b1 + a2 * b2 + a3 * b3;\n      out[0] = a0;\n      out[1] = a1;\n      out[2] = a2;\n      out[3] = a3;\n      out[4] = (b0 - a0 * a_dot_b) / magnitude;\n      out[5] = (b1 - a1 * a_dot_b) / magnitude;\n      out[6] = (b2 - a2 * a_dot_b) / magnitude;\n      out[7] = (b3 - a3 * a_dot_b) / magnitude;\n    }\n\n    return out;\n  }\n  /**\n   * Returns a string representation of a dual quaternion\n   *\n   * @param {ReadonlyQuat2} a dual quaternion to represent as a string\n   * @returns {String} string representation of the dual quat\n   */\n\n  function str$1(a) {\n    return \"quat2(\" + a[0] + \", \" + a[1] + \", \" + a[2] + \", \" + a[3] + \", \" + a[4] + \", \" + a[5] + \", \" + a[6] + \", \" + a[7] + \")\";\n  }\n  /**\n   * Returns whether the dual quaternions have exactly the same elements in the same position (when compared with ===)\n   *\n   * @param {ReadonlyQuat2} a the first dual quaternion.\n   * @param {ReadonlyQuat2} b the second dual quaternion.\n   * @returns {Boolean} true if the dual quaternions are equal, false otherwise.\n   */\n\n  function exactEquals$1(a, b) {\n    return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3] && a[4] === b[4] && a[5] === b[5] && a[6] === b[6] && a[7] === b[7];\n  }\n  /**\n   * Returns whether the dual quaternions have approximately the same elements in the same position.\n   *\n   * @param {ReadonlyQuat2} a the first dual quat.\n   * @param {ReadonlyQuat2} b the second dual quat.\n   * @returns {Boolean} true if the dual quats are equal, false otherwise.\n   */\n\n  function equals$1(a, b) {\n    var a0 = a[0],\n        a1 = a[1],\n        a2 = a[2],\n        a3 = a[3],\n        a4 = a[4],\n        a5 = a[5],\n        a6 = a[6],\n        a7 = a[7];\n    var b0 = b[0],\n        b1 = b[1],\n        b2 = b[2],\n        b3 = b[3],\n        b4 = b[4],\n        b5 = b[5],\n        b6 = b[6],\n        b7 = b[7];\n    return Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && Math.abs(a2 - b2) <= EPSILON * Math.max(1.0, Math.abs(a2), Math.abs(b2)) && Math.abs(a3 - b3) <= EPSILON * Math.max(1.0, Math.abs(a3), Math.abs(b3)) && Math.abs(a4 - b4) <= EPSILON * Math.max(1.0, Math.abs(a4), Math.abs(b4)) && Math.abs(a5 - b5) <= EPSILON * Math.max(1.0, Math.abs(a5), Math.abs(b5)) && Math.abs(a6 - b6) <= EPSILON * Math.max(1.0, Math.abs(a6), Math.abs(b6)) && Math.abs(a7 - b7) <= EPSILON * Math.max(1.0, Math.abs(a7), Math.abs(b7));\n  }\n\n  var quat2 = /*#__PURE__*/Object.freeze({\n    __proto__: null,\n    create: create$1,\n    clone: clone$1,\n    fromValues: fromValues$1,\n    fromRotationTranslationValues: fromRotationTranslationValues,\n    fromRotationTranslation: fromRotationTranslation,\n    fromTranslation: fromTranslation,\n    fromRotation: fromRotation,\n    fromMat4: fromMat4,\n    copy: copy$1,\n    identity: identity,\n    set: set$1,\n    getReal: getReal,\n    getDual: getDual,\n    setReal: setReal,\n    setDual: setDual,\n    getTranslation: getTranslation,\n    translate: translate,\n    rotateX: rotateX,\n    rotateY: rotateY,\n    rotateZ: rotateZ,\n    rotateByQuatAppend: rotateByQuatAppend,\n    rotateByQuatPrepend: rotateByQuatPrepend,\n    rotateAroundAxis: rotateAroundAxis,\n    add: add$1,\n    multiply: multiply$1,\n    mul: mul$1,\n    scale: scale$1,\n    dot: dot$1,\n    lerp: lerp$1,\n    invert: invert,\n    conjugate: conjugate,\n    length: length$1,\n    len: len$1,\n    squaredLength: squaredLength$1,\n    sqrLen: sqrLen$1,\n    normalize: normalize$1,\n    str: str$1,\n    exactEquals: exactEquals$1,\n    equals: equals$1\n  });\n\n  /**\n   * 2 Dimensional Vector\n   * @module vec2\n   */\n\n  /**\n   * Creates a new, empty vec2\n   *\n   * @returns {vec2} a new 2D vector\n   */\n\n  function create() {\n    var out = new ARRAY_TYPE(2);\n\n    if (ARRAY_TYPE != Float32Array) {\n      out[0] = 0;\n      out[1] = 0;\n    }\n\n    return out;\n  }\n  /**\n   * Creates a new vec2 initialized with values from an existing vector\n   *\n   * @param {ReadonlyVec2} a vector to clone\n   * @returns {vec2} a new 2D vector\n   */\n\n  function clone(a) {\n    var out = new ARRAY_TYPE(2);\n    out[0] = a[0];\n    out[1] = a[1];\n    return out;\n  }\n  /**\n   * Creates a new vec2 initialized with the given values\n   *\n   * @param {Number} x X component\n   * @param {Number} y Y component\n   * @returns {vec2} a new 2D vector\n   */\n\n  function fromValues(x, y) {\n    var out = new ARRAY_TYPE(2);\n    out[0] = x;\n    out[1] = y;\n    return out;\n  }\n  /**\n   * Copy the values from one vec2 to another\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a the source vector\n   * @returns {vec2} out\n   */\n\n  function copy(out, a) {\n    out[0] = a[0];\n    out[1] = a[1];\n    return out;\n  }\n  /**\n   * Set the components of a vec2 to the given values\n   *\n   * @param {vec2} out the receiving vector\n   * @param {Number} x X component\n   * @param {Number} y Y component\n   * @returns {vec2} out\n   */\n\n  function set(out, x, y) {\n    out[0] = x;\n    out[1] = y;\n    return out;\n  }\n  /**\n   * Adds two vec2's\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a the first operand\n   * @param {ReadonlyVec2} b the second operand\n   * @returns {vec2} out\n   */\n\n  function add(out, a, b) {\n    out[0] = a[0] + b[0];\n    out[1] = a[1] + b[1];\n    return out;\n  }\n  /**\n   * Subtracts vector b from vector a\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a the first operand\n   * @param {ReadonlyVec2} b the second operand\n   * @returns {vec2} out\n   */\n\n  function subtract(out, a, b) {\n    out[0] = a[0] - b[0];\n    out[1] = a[1] - b[1];\n    return out;\n  }\n  /**\n   * Multiplies two vec2's\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a the first operand\n   * @param {ReadonlyVec2} b the second operand\n   * @returns {vec2} out\n   */\n\n  function multiply(out, a, b) {\n    out[0] = a[0] * b[0];\n    out[1] = a[1] * b[1];\n    return out;\n  }\n  /**\n   * Divides two vec2's\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a the first operand\n   * @param {ReadonlyVec2} b the second operand\n   * @returns {vec2} out\n   */\n\n  function divide(out, a, b) {\n    out[0] = a[0] / b[0];\n    out[1] = a[1] / b[1];\n    return out;\n  }\n  /**\n   * Math.ceil the components of a vec2\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a vector to ceil\n   * @returns {vec2} out\n   */\n\n  function ceil(out, a) {\n    out[0] = Math.ceil(a[0]);\n    out[1] = Math.ceil(a[1]);\n    return out;\n  }\n  /**\n   * Math.floor the components of a vec2\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a vector to floor\n   * @returns {vec2} out\n   */\n\n  function floor(out, a) {\n    out[0] = Math.floor(a[0]);\n    out[1] = Math.floor(a[1]);\n    return out;\n  }\n  /**\n   * Returns the minimum of two vec2's\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a the first operand\n   * @param {ReadonlyVec2} b the second operand\n   * @returns {vec2} out\n   */\n\n  function min(out, a, b) {\n    out[0] = Math.min(a[0], b[0]);\n    out[1] = Math.min(a[1], b[1]);\n    return out;\n  }\n  /**\n   * Returns the maximum of two vec2's\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a the first operand\n   * @param {ReadonlyVec2} b the second operand\n   * @returns {vec2} out\n   */\n\n  function max(out, a, b) {\n    out[0] = Math.max(a[0], b[0]);\n    out[1] = Math.max(a[1], b[1]);\n    return out;\n  }\n  /**\n   * Math.round the components of a vec2\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a vector to round\n   * @returns {vec2} out\n   */\n\n  function round(out, a) {\n    out[0] = Math.round(a[0]);\n    out[1] = Math.round(a[1]);\n    return out;\n  }\n  /**\n   * Scales a vec2 by a scalar number\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a the vector to scale\n   * @param {Number} b amount to scale the vector by\n   * @returns {vec2} out\n   */\n\n  function scale(out, a, b) {\n    out[0] = a[0] * b;\n    out[1] = a[1] * b;\n    return out;\n  }\n  /**\n   * Adds two vec2's after scaling the second operand by a scalar value\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a the first operand\n   * @param {ReadonlyVec2} b the second operand\n   * @param {Number} scale the amount to scale b by before adding\n   * @returns {vec2} out\n   */\n\n  function scaleAndAdd(out, a, b, scale) {\n    out[0] = a[0] + b[0] * scale;\n    out[1] = a[1] + b[1] * scale;\n    return out;\n  }\n  /**\n   * Calculates the euclidian distance between two vec2's\n   *\n   * @param {ReadonlyVec2} a the first operand\n   * @param {ReadonlyVec2} b the second operand\n   * @returns {Number} distance between a and b\n   */\n\n  function distance(a, b) {\n    var x = b[0] - a[0],\n        y = b[1] - a[1];\n    return Math.hypot(x, y);\n  }\n  /**\n   * Calculates the squared euclidian distance between two vec2's\n   *\n   * @param {ReadonlyVec2} a the first operand\n   * @param {ReadonlyVec2} b the second operand\n   * @returns {Number} squared distance between a and b\n   */\n\n  function squaredDistance(a, b) {\n    var x = b[0] - a[0],\n        y = b[1] - a[1];\n    return x * x + y * y;\n  }\n  /**\n   * Calculates the length of a vec2\n   *\n   * @param {ReadonlyVec2} a vector to calculate length of\n   * @returns {Number} length of a\n   */\n\n  function length(a) {\n    var x = a[0],\n        y = a[1];\n    return Math.hypot(x, y);\n  }\n  /**\n   * Calculates the squared length of a vec2\n   *\n   * @param {ReadonlyVec2} a vector to calculate squared length of\n   * @returns {Number} squared length of a\n   */\n\n  function squaredLength(a) {\n    var x = a[0],\n        y = a[1];\n    return x * x + y * y;\n  }\n  /**\n   * Negates the components of a vec2\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a vector to negate\n   * @returns {vec2} out\n   */\n\n  function negate(out, a) {\n    out[0] = -a[0];\n    out[1] = -a[1];\n    return out;\n  }\n  /**\n   * Returns the inverse of the components of a vec2\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a vector to invert\n   * @returns {vec2} out\n   */\n\n  function inverse(out, a) {\n    out[0] = 1.0 / a[0];\n    out[1] = 1.0 / a[1];\n    return out;\n  }\n  /**\n   * Normalize a vec2\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a vector to normalize\n   * @returns {vec2} out\n   */\n\n  function normalize(out, a) {\n    var x = a[0],\n        y = a[1];\n    var len = x * x + y * y;\n\n    if (len > 0) {\n      //TODO: evaluate use of glm_invsqrt here?\n      len = 1 / Math.sqrt(len);\n    }\n\n    out[0] = a[0] * len;\n    out[1] = a[1] * len;\n    return out;\n  }\n  /**\n   * Calculates the dot product of two vec2's\n   *\n   * @param {ReadonlyVec2} a the first operand\n   * @param {ReadonlyVec2} b the second operand\n   * @returns {Number} dot product of a and b\n   */\n\n  function dot(a, b) {\n    return a[0] * b[0] + a[1] * b[1];\n  }\n  /**\n   * Computes the cross product of two vec2's\n   * Note that the cross product must by definition produce a 3D vector\n   *\n   * @param {vec3} out the receiving vector\n   * @param {ReadonlyVec2} a the first operand\n   * @param {ReadonlyVec2} b the second operand\n   * @returns {vec3} out\n   */\n\n  function cross(out, a, b) {\n    var z = a[0] * b[1] - a[1] * b[0];\n    out[0] = out[1] = 0;\n    out[2] = z;\n    return out;\n  }\n  /**\n   * Performs a linear interpolation between two vec2's\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a the first operand\n   * @param {ReadonlyVec2} b the second operand\n   * @param {Number} t interpolation amount, in the range [0-1], between the two inputs\n   * @returns {vec2} out\n   */\n\n  function lerp(out, a, b, t) {\n    var ax = a[0],\n        ay = a[1];\n    out[0] = ax + t * (b[0] - ax);\n    out[1] = ay + t * (b[1] - ay);\n    return out;\n  }\n  /**\n   * Generates a random vector with the given scale\n   *\n   * @param {vec2} out the receiving vector\n   * @param {Number} [scale] Length of the resulting vector. If omitted, a unit vector will be returned\n   * @returns {vec2} out\n   */\n\n  function random(out, scale) {\n    scale = scale === undefined ? 1.0 : scale;\n    var r = RANDOM() * 2.0 * Math.PI;\n    out[0] = Math.cos(r) * scale;\n    out[1] = Math.sin(r) * scale;\n    return out;\n  }\n  /**\n   * Transforms the vec2 with a mat2\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a the vector to transform\n   * @param {ReadonlyMat2} m matrix to transform with\n   * @returns {vec2} out\n   */\n\n  function transformMat2(out, a, m) {\n    var x = a[0],\n        y = a[1];\n    out[0] = m[0] * x + m[2] * y;\n    out[1] = m[1] * x + m[3] * y;\n    return out;\n  }\n  /**\n   * Transforms the vec2 with a mat2d\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a the vector to transform\n   * @param {ReadonlyMat2d} m matrix to transform with\n   * @returns {vec2} out\n   */\n\n  function transformMat2d(out, a, m) {\n    var x = a[0],\n        y = a[1];\n    out[0] = m[0] * x + m[2] * y + m[4];\n    out[1] = m[1] * x + m[3] * y + m[5];\n    return out;\n  }\n  /**\n   * Transforms the vec2 with a mat3\n   * 3rd vector component is implicitly '1'\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a the vector to transform\n   * @param {ReadonlyMat3} m matrix to transform with\n   * @returns {vec2} out\n   */\n\n  function transformMat3(out, a, m) {\n    var x = a[0],\n        y = a[1];\n    out[0] = m[0] * x + m[3] * y + m[6];\n    out[1] = m[1] * x + m[4] * y + m[7];\n    return out;\n  }\n  /**\n   * Transforms the vec2 with a mat4\n   * 3rd vector component is implicitly '0'\n   * 4th vector component is implicitly '1'\n   *\n   * @param {vec2} out the receiving vector\n   * @param {ReadonlyVec2} a the vector to transform\n   * @param {ReadonlyMat4} m matrix to transform with\n   * @returns {vec2} out\n   */\n\n  function transformMat4(out, a, m) {\n    var x = a[0];\n    var y = a[1];\n    out[0] = m[0] * x + m[4] * y + m[12];\n    out[1] = m[1] * x + m[5] * y + m[13];\n    return out;\n  }\n  /**\n   * Rotate a 2D vector\n   * @param {vec2} out The receiving vec2\n   * @param {ReadonlyVec2} a The vec2 point to rotate\n   * @param {ReadonlyVec2} b The origin of the rotation\n   * @param {Number} rad The angle of rotation in radians\n   * @returns {vec2} out\n   */\n\n  function rotate(out, a, b, rad) {\n    //Translate point to the origin\n    var p0 = a[0] - b[0],\n        p1 = a[1] - b[1],\n        sinC = Math.sin(rad),\n        cosC = Math.cos(rad); //perform rotation and translate to correct position\n\n    out[0] = p0 * cosC - p1 * sinC + b[0];\n    out[1] = p0 * sinC + p1 * cosC + b[1];\n    return out;\n  }\n  /**\n   * Get the angle between two 2D vectors\n   * @param {ReadonlyVec2} a The first operand\n   * @param {ReadonlyVec2} b The second operand\n   * @returns {Number} The angle in radians\n   */\n\n  function angle(a, b) {\n    var x1 = a[0],\n        y1 = a[1],\n        x2 = b[0],\n        y2 = b[1],\n        // mag is the product of the magnitudes of a and b\n    mag = Math.sqrt((x1 * x1 + y1 * y1) * (x2 * x2 + y2 * y2)),\n        // mag &&.. short circuits if mag == 0\n    cosine = mag && (x1 * x2 + y1 * y2) / mag; // Math.min(Math.max(cosine, -1), 1) clamps the cosine between -1 and 1\n\n    return Math.acos(Math.min(Math.max(cosine, -1), 1));\n  }\n  /**\n   * Set the components of a vec2 to zero\n   *\n   * @param {vec2} out the receiving vector\n   * @returns {vec2} out\n   */\n\n  function zero(out) {\n    out[0] = 0.0;\n    out[1] = 0.0;\n    return out;\n  }\n  /**\n   * Returns a string representation of a vector\n   *\n   * @param {ReadonlyVec2} a vector to represent as a string\n   * @returns {String} string representation of the vector\n   */\n\n  function str(a) {\n    return \"vec2(\" + a[0] + \", \" + a[1] + \")\";\n  }\n  /**\n   * Returns whether the vectors exactly have the same elements in the same position (when compared with ===)\n   *\n   * @param {ReadonlyVec2} a The first vector.\n   * @param {ReadonlyVec2} b The second vector.\n   * @returns {Boolean} True if the vectors are equal, false otherwise.\n   */\n\n  function exactEquals(a, b) {\n    return a[0] === b[0] && a[1] === b[1];\n  }\n  /**\n   * Returns whether the vectors have approximately the same elements in the same position.\n   *\n   * @param {ReadonlyVec2} a The first vector.\n   * @param {ReadonlyVec2} b The second vector.\n   * @returns {Boolean} True if the vectors are equal, false otherwise.\n   */\n\n  function equals(a, b) {\n    var a0 = a[0],\n        a1 = a[1];\n    var b0 = b[0],\n        b1 = b[1];\n    return Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1));\n  }\n  /**\n   * Alias for {@link vec2.length}\n   * @function\n   */\n\n  var len = length;\n  /**\n   * Alias for {@link vec2.subtract}\n   * @function\n   */\n\n  var sub = subtract;\n  /**\n   * Alias for {@link vec2.multiply}\n   * @function\n   */\n\n  var mul = multiply;\n  /**\n   * Alias for {@link vec2.divide}\n   * @function\n   */\n\n  var div = divide;\n  /**\n   * Alias for {@link vec2.distance}\n   * @function\n   */\n\n  var dist = distance;\n  /**\n   * Alias for {@link vec2.squaredDistance}\n   * @function\n   */\n\n  var sqrDist = squaredDistance;\n  /**\n   * Alias for {@link vec2.squaredLength}\n   * @function\n   */\n\n  var sqrLen = squaredLength;\n  /**\n   * Perform some operation over an array of vec2s.\n   *\n   * @param {Array} a the array of vectors to iterate over\n   * @param {Number} stride Number of elements between the start of each vec2. If 0 assumes tightly packed\n   * @param {Number} offset Number of elements to skip at the beginning of the array\n   * @param {Number} count Number of vec2s to iterate over. If 0 iterates over entire array\n   * @param {Function} fn Function to call for each vector in the array\n   * @param {Object} [arg] additional argument to pass to fn\n   * @returns {Array} a\n   * @function\n   */\n\n  var forEach = function () {\n    var vec = create();\n    return function (a, stride, offset, count, fn, arg) {\n      var i, l;\n\n      if (!stride) {\n        stride = 2;\n      }\n\n      if (!offset) {\n        offset = 0;\n      }\n\n      if (count) {\n        l = Math.min(count * stride + offset, a.length);\n      } else {\n        l = a.length;\n      }\n\n      for (i = offset; i < l; i += stride) {\n        vec[0] = a[i];\n        vec[1] = a[i + 1];\n        fn(vec, vec, arg);\n        a[i] = vec[0];\n        a[i + 1] = vec[1];\n      }\n\n      return a;\n    };\n  }();\n\n  var vec2 = /*#__PURE__*/Object.freeze({\n    __proto__: null,\n    create: create,\n    clone: clone,\n    fromValues: fromValues,\n    copy: copy,\n    set: set,\n    add: add,\n    subtract: subtract,\n    multiply: multiply,\n    divide: divide,\n    ceil: ceil,\n    floor: floor,\n    min: min,\n    max: max,\n    round: round,\n    scale: scale,\n    scaleAndAdd: scaleAndAdd,\n    distance: distance,\n    squaredDistance: squaredDistance,\n    length: length,\n    squaredLength: squaredLength,\n    negate: negate,\n    inverse: inverse,\n    normalize: normalize,\n    dot: dot,\n    cross: cross,\n    lerp: lerp,\n    random: random,\n    transformMat2: transformMat2,\n    transformMat2d: transformMat2d,\n    transformMat3: transformMat3,\n    transformMat4: transformMat4,\n    rotate: rotate,\n    angle: angle,\n    zero: zero,\n    str: str,\n    exactEquals: exactEquals,\n    equals: equals,\n    len: len,\n    sub: sub,\n    mul: mul,\n    div: div,\n    dist: dist,\n    sqrDist: sqrDist,\n    sqrLen: sqrLen,\n    forEach: forEach\n  });\n\n  exports.glMatrix = common;\n  exports.mat2 = mat2;\n  exports.mat2d = mat2d;\n  exports.mat3 = mat3;\n  exports.mat4 = mat4;\n  exports.quat = quat;\n  exports.quat2 = quat2;\n  exports.vec2 = vec2;\n  exports.vec3 = vec3;\n  exports.vec4 = vec4;\n\n  Object.defineProperty(exports, '__esModule', { value: true });\n\n}));\n"
  },
  {
    "path": "dev-utils/scripts/icn-regex-matching.ts",
    "content": "// dev-utils/scripts/icn-regex-matching.ts\n\n\n/**\n * This stores a monster regex I made for matching ICN.\n * \n * It has a big issue. When matching ICNs with over ~2M pieces,\n * we'll get a stack overflow error. Fundamental Regex issue,\n * regex isn't built for handling such large strings.\n */\n\n\n\n// Construct the MONSTER ICN regex!\n\n/**\n * Delimiter between all ICN parts.\n * Matches whitespace OR the end of the ICN.\n */\nconst delimiter = String.raw`(?:\\s+|(?=$))`;\n// const delimiter = String.raw`\\s+`; // Matches only whitespace\n\n/** Matches an entire ICN, capturing with named groups. */\nconst ICNRegex = new RegExp(\n\t// If any ICN section match is found, whitespace is required immediately after them.\n\tString.raw`^\\s*` + // Start of the string\n\tpossessive(String.raw`(?:(?<metadata>${getSingleMetadataSource(false)}(?:\\s+${getSingleMetadataSource(false)})*)${delimiter})?`) + // Captures all metadata into one string\n\tpossessive(String.raw`(?:(?<turnOrder>${raw_piece_code_regex_source}(?::${raw_piece_code_regex_source})*)${delimiter})?`) +\n\tpossessive(String.raw`(?:(?<enpassant>${coordsKeyRegexSource})${delimiter})?`) +\n\tpossessive(String.raw`(?:(?<moveRule>${wholeNumberSource}\\/${countingNumberSource})${delimiter})?`) +\n\tpossessive(String.raw`(?:(?<fullMove>${countingNumberSource})${delimiter})?`) +\n\tpossessive(String.raw`(?:${promotionsRegexSource}${delimiter})?`) +\n\tpossessive(String.raw`(?:${winConditionRegexSource}${delimiter})?`) +\n\tpossessive(String.raw`(?:(?<position>${positionRegexSource})${delimiter})?`) + // Captures the whole position in one string\n\tpossessive(String.raw`(?<moves>${movesRegexSource})?`) + // Captures all moves in one string\n\tString.raw`\\s*$` // End of the string\n);\nconsole.log(\"ICNRegex:\", ICNRegex);\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n/**\n * Converts a string in Infinite Chess Notation to game in JSON format.\n * \n * Throws an error if it's in an invalid format, or if required sections are missing.\n */\nfunction ShortToLong_Format(icn: string): LongFormatOut {\n\tconsole.log(\"Start match...\");\n\tconst matches = icn.match(ICNRegex);\n\tconsole.log(\"Done matching!\");\n\tif (matches === null) throw new Error(\"ICN is in an invalid format! \" + icn);\n\tconst groups = matches.groups!;\n\n\tconst metadata: Record<string, string> = {};\n\tif (groups['metadata']) {\n\t\tconst metadataMatches = groups['metadata'].matchAll(new RegExp(getSingleMetadataSource(true), 'g'));\n\t\tfor (const match of metadataMatches) {\n\t\t\tconst key = match[1]!;\n\t\t\tconst value = match[2]!;\n\t\t\tmetadata[key] = value;\n\t\t}\n\t}\n\n\tlet turnOrder: Player[] = defaults.turnOrder;\n\tif (groups['turnOrder']) {\n\t\t// console.log(`Turn Order: (${groups['turnOrder']})}`);\n\t\t// Substitues\n\t\tif (groups['turnOrder'] === 'w') groups['turnOrder'] = 'w:b'; // 'w' is short for 'w:b'\n\t\telse if (groups['turnOrder'] === 'b') groups['turnOrder'] = 'b:w'; // 'b' is short for 'b:w'\n\t\tconst turnOrderArray = groups['turnOrder'].split(':'); // ['w','b']\n\t\tturnOrder = [...turnOrderArray.map(p_code => {\n\t\t\tif (!(p_code in player_codes_inverted)) throw Error(`Unknown player code (${p_code}) when parsing turn order of ICN! Turn order (${groups['turnOrder']})`);\n\t\t\treturn Number(player_codes_inverted[p_code]);\n\t\t})] as Player[]; // [1,2]\n\t}\n\n\tlet enpassant: EnPassant | undefined;\n\tif (groups['enpassant']) {\n\t\tconst coords = coordutil.getCoordsFromKey(groups['enpassant'] as CoordsKey);\n\t\tconst lastTurn = turnOrder[turnOrder.length - 1];\n\t\tconst yParity = lastTurn === p.WHITE ? 1 : lastTurn === p.BLACK ? -1 : (() => { throw new Error(`Invalid last turn (${lastTurn}) when parsing enpassant in ICN!`); })();\n\t\tenpassant = { square: coords, pawn: [coords[0], coords[1] + yParity] };\n\t}\n\n\tlet moveRule: number | undefined;\n\tlet moveRuleState: number | undefined;\n\tif (groups['moveRule']) {\n\t\tconst [_moveRuleState, _moveRule] = groups['moveRule'].split('/').map(Number);\n\t\tif (_moveRuleState! > _moveRule!) throw Error(`Invalid move rule (${groups['moveRule']}) when parsing ICN!`);\n\t\tmoveRule = _moveRule;\n\t\tmoveRuleState = _moveRuleState;\n\t}\n\n\tlet fullMove: number = defaults.fullMove;\n\tif (groups['fullMove']) fullMove = Number(groups['fullMove']);\n\n\tlet promotionRanks: PlayerGroup<number[]> | undefined;\n\tlet promotionsAllowed: PlayerGroup<RawType[]> | undefined;\n\tif (groups['promotions']) { // '8,16,24,32;q,r,b,n|1,9,17,25;q,r,b,n'\n\t\tconst _promotionRanks: PlayerGroup<number[]> = {};\n\t\tconst _promotionsAllowed: PlayerGroup<RawType[]> = {};\n\t\tconst promotions = groups['promotions'].split('|'); // ['8,16,24,32;q,r,b,n','1,9,17,25;q,r,b,n']\n\t\t// Make sure the number of promotions matches the number of players\n\t\tif (promotions.length !== turnOrder.length) throw new Error(`Number of promotions (${promotions.length}) does not match number of players (${turnOrder.length})!`);\n\t\tfor (const player of turnOrder) {\n\t\t\tconst playerPromotions = promotions.shift()!; // '8,16,24,32;q,r,b,n'\n\t\t\tif (playerPromotions === '') continue; // Player has no promotions. Maybe promotions were \"(8|)\"\n\t\t\tconst [ranks, allowed] = playerPromotions.split(';'); // The allowed section is optional\n\t\t\t_promotionRanks[player] = ranks!.split(',').map(Number);\n\t\t\t_promotionsAllowed[player] = allowed ? allowed.split(',').map(raw => Number(piece_codes_raw_inverted[raw]) as RawType) : default_promotions;\n\t\t}\n\t\tpromotionRanks = _promotionRanks;\n\t\tpromotionsAllowed = _promotionsAllowed;\n\t}\n\n\tlet winConditions: PlayerGroup<string[]> = defaults.winConditions;\n\tif (groups['winConditions']) { // 'checkmate,checkmate|allpiecescaptured'\n\t\tconst winConStrings = groups['winConditions'].split('|'); // ['checkmate','checkmate|allpiecescaptured']\n\t\tconst _winConditions: PlayerGroup<string[]> = {};\n\t\t// If winConStrings.length is 1, all players have the same win conditions\n\t\tif (winConStrings.length === 1) {\n\t\t\tconst winConArray = winConStrings[0]!.split(','); // ['checkmate','allpiecescaptured']\n\t\t\tfor (const player of turnOrder) {\n\t\t\t\t_winConditions[player] = [...winConArray];\n\t\t\t}\n\t\t} else { // Each player has their own win conditions\n\t\t\t// Make sure the number of win conditions matches the number of players\n\t\t\tif (winConStrings.length !== turnOrder.length) throw new Error(`Number of win conditions (${winConStrings.length}) does not match number of players (${turnOrder.length})!`);\n\t\t\tfor (const player of turnOrder) {\n\t\t\t\tconst winConString = winConStrings.shift()!;\n\t\t\t\t_winConditions[player] = winConString.split(','); // ['checkmate','allpiecescaptured']\n\t\t\t}\n\t\t}\n\t\twinConditions = _winConditions;\n\t}\n\n\tlet position: Map<CoordsKey, number> | undefined;\n\tlet specialRights: Set<CoordsKey> | undefined;\n\tif (groups['position']) {\n\t\t({ position, specialRights } = generatePositionFromShortForm(groups['position']));\n\t} else {\n\t\t// Position not specified. We then require the metadata: Variant, UTCDate, and UTCTime\n\t\tif (!metadata['Variant'] || !metadata['UTCDate'] || !metadata['UTCTime']) throw Error(\"ICN's Variant, UTCDate, and UTCTime must be specified when no position specified.\");\n\t\t// Could optionally get the position from variant.ts, but probably not a responsibility of icnconverter\n\t\t// ({ position, specialRights } = variant.getStartingPositionOfVariant({ Variant: metadata['Variant'], UTCDate: metadata['UTCDate'], UTCTime: metadata['UTCTime'] }));\n\t}\n\n\tlet moves: MoveParsed[] | undefined;\n\tif (groups['moves']) moves = parseShortFormMoves(groups['moves']);\n\n\t// =================================== Return the game object ===================================\n\n\tconst gameRules: GameRules = {\n\t\tturnOrder,\n\t\twinConditions,\n\t};\n\tif (moveRule) gameRules.moveRule = moveRule;\n\tif (promotionRanks) gameRules.promotionRanks = promotionRanks;\n\tif (promotionsAllowed) gameRules.promotionsAllowed = promotionsAllowed;\n\n\tconst state_global: Partial<GlobalGameState> = {};\n\tif (specialRights) state_global.specialRights = specialRights;\n\tif (enpassant) state_global.enpassant = enpassant;\n\tif (moveRuleState !== undefined) state_global.moveRuleState = moveRuleState;\n\t\n\tconst game: LongFormatOut = {\n\t\tmetadata: metadata as unknown as MetaData,\n\t\tgameRules,\n\t\tfullMove,\n\t\tstate_global\n\t};\n\tif (position) game.position = position;\n\tif (moves) game.moves = moves;\n\n\tconsole.log(\"Parced ICN: \", jsutil.deepCopyObject(game));\n\n\treturn game;\n}"
  },
  {
    "path": "dev-utils/scripts/meshSimplification.ts",
    "content": "\n\n/**\n * This stores a mesh simplification algorithm Naviary designed to simplify the void mesh.\n * \n * It can't be used anymore since a board editor may dynamically add and remove voids all the time.\n * We would have to regenerate the mesh every time.\n */\n\n/**\n * Simplifies a list of void squares and merges them into larger rectangles.\n * @param voidList - The list of coordinates where all the voids are\n * @returns An array of rectangles that look like: `{ left, right, bottom, top }`.\n */\nfunction simplifyMesh(voidList: PooledArray<Coords>): BoundingBox[] { // array of coordinates\n\n\t// console.log(\"Simplifying void mesh..\")\n\n\tconst voidHash: { [coordsKey: CoordsKey]: true } = {};\n\tfor (const thisVoid of voidList) {\n\t\tif (!thisVoid) continue;\n\t\tconst key = coordutil.getKeyFromCoords(thisVoid);\n\t\tvoidHash[key] = true;\n\t}\n\n\tconst rectangles: BoundingBox[] = []; // rectangle: { left, right, bottom, top }\n\tconst alreadyMerged: { [coordsKey: CoordsKey]: true } = { }; // Set the coordinate key `x,y` to true when a void has been merged\n\n\tfor (const thisVoid of voidList) { // [x,y]\n\t\tif (!thisVoid) continue;\n\n\t\t// Has this void already been merged with another previous?\n\t\tconst key = coordutil.getKeyFromCoords(thisVoid);\n\t\tif (alreadyMerged[key]) continue; // Next void\n\t\talreadyMerged[key] = true; // Set this void to true for next iteration\n\n\t\tlet left = thisVoid[0];\n\t\tlet right = thisVoid[0];\n\t\tlet bottom = thisVoid[1];\n\t\tlet top = thisVoid[1];\n\t\tlet width = 1;\n\t\tlet height = 1;\n\n\t\tlet foundNeighbor = true;\n\t\twhile (foundNeighbor) { // Keep expanding while successful\n\n\t\t\t// First test left neighbors\n\n\t\t\tlet potentialMergers: CoordsKey[] = [];\n\t\t\tlet allNeighborsAreVoid = true;\n\t\t\tlet testX = left - 1;\n\t\t\tfor (let a = 0; a < height; a++) { // Start from bottom and go up\n\t\t\t\tconst thisTestY = bottom + a;\n\t\t\t\tconst thisCoord: Coords = [testX, thisTestY];\n\t\t\t\tconst thisKey = coordutil.getKeyFromCoords(thisCoord);\n\t\t\t\tconst isVoid = voidHash[thisKey];\n\t\t\t\tif (!isVoid || alreadyMerged[thisKey]) {\n\t\t\t\t\tallNeighborsAreVoid = false;\n\t\t\t\t\tbreak; // Can't merge\n\t\t\t\t}\n\t\t\t\tpotentialMergers.push(thisKey); // Can merge\n\t\t\t}\n\t\t\tif (allNeighborsAreVoid) { \n\t\t\t\tleft = testX; // Merge!\n\t\t\t\twidth++;\n\t\t\t\t// Add all the merged squares to the already-merged list\n\t\t\t\tpotentialMergers.forEach(key => { alreadyMerged[key] = true; });\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Next test right neighbors\n\n\t\t\tpotentialMergers = [];\n\t\t\tallNeighborsAreVoid = true;\n\t\t\ttestX = right + 1;\n\t\t\tfor (let a = 0; a < height; a++) { // Start from bottom and go up\n\t\t\t\tconst thisTestY = bottom + a;\n\t\t\t\tconst thisCoord: Coords = [testX, thisTestY];\n\t\t\t\tconst thisKey = coordutil.getKeyFromCoords(thisCoord);\n\t\t\t\tconst isVoid = voidHash[thisKey];\n\t\t\t\tif (!isVoid || alreadyMerged[thisKey]) {\n\t\t\t\t\tallNeighborsAreVoid = false;\n\t\t\t\t\tbreak; // Can't merge\n\t\t\t\t}\n\t\t\t\tpotentialMergers.push(thisKey); // Can merge\n\t\t\t}\n\t\t\tif (allNeighborsAreVoid) { \n\t\t\t\tright = testX; // Merge!\n\t\t\t\twidth++;\n\t\t\t\t// Add all the merged squares to the already-merged list\n\t\t\t\tpotentialMergers.forEach(key => { alreadyMerged[key] = true; });\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Next test bottom neighbors\n\n\t\t\tpotentialMergers = [];\n\t\t\tallNeighborsAreVoid = true;\n\t\t\tlet testY = bottom - 1;\n\t\t\tfor (let a = 0; a < width; a++) { // Start from bottom and go up\n\t\t\t\tconst thisTestX = left + a;\n\t\t\t\tconst thisCoord: Coords = [thisTestX, testY];\n\t\t\t\tconst thisKey = coordutil.getKeyFromCoords(thisCoord);\n\t\t\t\tconst isVoid = voidHash[thisKey];\n\t\t\t\tif (!isVoid || alreadyMerged[thisKey]) {\n\t\t\t\t\tallNeighborsAreVoid = false;\n\t\t\t\t\tbreak; // Can't merge\n\t\t\t\t}\n\t\t\t\tpotentialMergers.push(thisKey); // Can merge\n\t\t\t}\n\t\t\tif (allNeighborsAreVoid) { \n\t\t\t\tbottom = testY; // Merge!\n\t\t\t\theight++;\n\t\t\t\t// Add all the merged squares to the already-merged list\n\t\t\t\tpotentialMergers.forEach(key => { alreadyMerged[key] = true; });\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Next test top neighbors\n\n\t\t\tpotentialMergers = [];\n\t\t\tallNeighborsAreVoid = true;\n\t\t\ttestY = top + 1;\n\t\t\tfor (let a = 0; a < width; a++) { // Start from bottom and go up\n\t\t\t\tconst thisTestX = left + a;\n\t\t\t\tconst thisCoord: Coords = [thisTestX, testY];\n\t\t\t\tconst thisKey = coordutil.getKeyFromCoords(thisCoord);\n\t\t\t\tconst isVoid = voidHash[thisKey];\n\t\t\t\tif (!isVoid || alreadyMerged[thisKey]) {\n\t\t\t\t\tallNeighborsAreVoid = false;\n\t\t\t\t\tbreak; // Can't merge\n\t\t\t\t}\n\t\t\t\tpotentialMergers.push(thisKey); // Can merge\n\t\t\t}\n\t\t\tif (allNeighborsAreVoid) { \n\t\t\t\ttop = testY; // Merge!\n\t\t\t\theight++;\n\t\t\t\t// Add all the merged squares to the already-merged list\n\t\t\t\tpotentialMergers.forEach(key => { alreadyMerged[key] = true; });\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tfoundNeighbor = false; // Cannot expand this rectangle! Stop searching\n\t\t}\n\n\t\tconst rectangle: BoundingBox = { left, right, bottom, top };\n\t\trectangles.push(rectangle);\n\t}\n\n\t// We now have a filled  rectangles  variable\n\treturn rectangles;\n}"
  },
  {
    "path": "dev-utils/scripts/positionnormalizer/moveexpander.ts",
    "content": "\n// src/client/scripts/esm/chess/logic/positionnormalizer/moveexpander.ts\n\n/**\n * This script takes a chosen move from analyzing a COMPRESSED/NORMALIZED position\n * by positioncompressor.ts, and the transformation information of the position,\n * and expands the move out so it can be applied to the original UNCOMPRESSED position.\n */\n\nimport type { Coords } from \"../movesets.js\";\nimport type { MoveCoords } from \"../icn/icnconverter.js\";\n\nimport bd from \"../../../util/bigdecimal/bigdecimal.js\";\nimport geometry from \"../../../util/math/geometry.js\";\nimport coordutil, { BDCoords } from \"../../util/coordutil.js\";\nimport vectors, { LineCoefficients, Vec2, Vec2Key } from \"../../../util/math/vectors.js\";\nimport positioncompressor, { AxisOrders, PieceTransform } from \"./positioncompressor.js\";\n\n// ================================== MOVE EXPANDER ==================================\n\n\n\n/**\n * Takes a move that should have been calculated from the compressed position,\n * and modifies its start and end coords so that it moves the original\n * uncompressed position's piece, and so its destination coordinates still\n * threaten all the same original pieces.\n * @param compressedPosition - The original uncompressed position\n * @param move - The decided upon move based on the compressed position\n */\nfunction expandMove(AllAxisOrders: AxisOrders, pieceTransformations: PieceTransform[], move: MoveCoords): MoveCoords {\n\tconst startCoordsBigInt: Coords = [BigInt(move.startCoords[0]), BigInt(move.startCoords[1])];\n\tconst endCoordsBigInt: Coords = [BigInt(move.endCoords[0]), BigInt(move.endCoords[1])];\n\n\t// Determine the piece's original position\n\n\tconst originalPiece = pieceTransformations.find((pt) => coordutil.areCoordsEqual(startCoordsBigInt, pt.transformedCoords as Coords));\n\tif (originalPiece === undefined) throw Error(`Compressed position's pieces doesn't include the moved piece on coords ${String(move.startCoords)}! Were we sure to choose a move based on the compressed position and not the original?`);\n\n\t/** The true start coordinates of the piece they moved. */\n\tconst originalStartCoords: Coords = originalPiece.coords; // EASY! This is already given\n\n\t/**\n\t * Determine the piece's intended destination square.\n\t * \n\t * How do we do that?\n\t * \n\t * Determine if the piece is targetting any specific axis group.\n\t * We can then calculate the intersection of its movement vector\n\t * and the direction towards that group to determine its intended destination.\n\t * \n\t * For now there aren't any gaps between groups, so it can't target an arbitrary\n\t * opening between gaps, its always got to be a little to the left or right of a group.\n\t * However, they can move arbitrarily far fast the farthest group,\n\t * so we will just move it the same distance it wanted to.\n\t */\n\n\t// Did it capture a piece?\n\tconst capturedTransformedPiece = pieceTransformations.find((pt) => coordutil.areCoordsEqual(pt.transformedCoords as Coords, endCoordsBigInt));\n\tif (capturedTransformedPiece) { // EASY! Return the captured piece's original coords\n\t\treturn {\n\t\t\tstartCoords: originalStartCoords,\n\t\t\tendCoords: capturedTransformedPiece.coords\n\t\t};\n\t}\n\n\t// It didn't capture any piece\n\t// This is a little more complicated. But we will attach it to the nearest axis group.\n\n\t/** The direction the piece moved in. We KNOW this is preserved when expanding back out! */\n\tconst vector: Vec2 = vectors.absVector(vectors.normalizeVector(coordutil.subtractCoords(endCoordsBigInt, startCoordsBigInt)));\n\tconst vec2Key: Vec2Key = vectors.getKeyFromVec2(vector);\n\t// console.log(\"Original start coords:\", originalStartCoords);\n\t// console.log(\"Movement vector:\", vector);\n\tconst movementLine: LineCoefficients = vectors.getLineGeneralFormFromCoordsAndVec(originalStartCoords, vector);\n\n\t// Half the distance between groups so that we can pick the nearest one threatened.\n\tconst HALF_ARBITRARY_DISTANCE = positioncompressor.MIN_ARBITRARY_DISTANCE / 2n;\n\n\t/** The true end coordinates they want to move to. */\n\tlet originalEndCoords: Coords | undefined;\n\n\t// Search each axis group besides the direction it moved in\n\n\t// Skip if our movement is perpendicular to that axis,\n\t// its impossible for us to increase our axis value along it\n\t// => not interested in threatening any of those groups.\n\tif (vec2Key !== '0,1') determineIfMovedPieceInterestedInAxis('1,0');\n\tif (vec2Key !== '1,0') determineIfMovedPieceInterestedInAxis('0,1');\n\n\t/**\n\t * Determines if the moved piece is interested in any group in the given axis.\n\t * If so, its final destination will still be relative to that group.\n\t */\n\tfunction determineIfMovedPieceInterestedInAxis(axis: '1,0' | '0,1') {\n\t\tif (originalEndCoords) {\n\t\t\tconsole.log(`Moved piece already has end coords determined. Skipping axis ${axis}.`);\n\t\t\treturn; // We already found the original end coords, no need to continue\n\t\t}\n\n\t\tconst axisOrder = AllAxisOrders[axis];\n\t\tconst axisValueDeterminer = positioncompressor.AXIS_DETERMINERS[axis];\n\n\t\tconst compressedEndCoordsAxisValue = axisValueDeterminer(endCoordsBigInt);\n\t\t// console.log(\"compressedEndCoordsAxisValue:\", compressedEndCoordsAxisValue);\n\t\t// console.log('endCoords bigint:', endCoordsBigInt);\n\n\t\tfor (const axisGroup of axisOrder) {\n\t\t\tif (compressedEndCoordsAxisValue + HALF_ARBITRARY_DISTANCE >= axisGroup.transformedRange![0] &&\n\t\t\t\tcompressedEndCoordsAxisValue - HALF_ARBITRARY_DISTANCE <= axisGroup.transformedRange![1]) {\n\t\t\t\t// We found the group of interest this piece is targetting!\n\t\t\t\tconsole.log(`Moved piece is interested in group on the ${axis} axis with range ${axisGroup.transformedRange}.   Original range ${axisGroup.range}`);\n\n\t\t\t\t// The piece is on the same file as this axis group, so connect it to this axis group\n\t\t\t\t// so its position remains relative to them when the position is expanded back out.\n\n\t\t\t\tconst offsetFromGroupStart = compressedEndCoordsAxisValue - axisGroup.transformedRange![0];\n\t\t\t\tconst actualEndCoordsAxisValue = axisGroup.range[0] + offsetFromGroupStart;\n\t\t\t\t// console.log('offsetFromGroupStart:', offsetFromGroupStart);\n\t\t\t\t// console.log('actualEndCoordsAxisValue:', actualEndCoordsAxisValue);\n\t\t\t\t// The ACTUAL coordinates they moved to!\n\t\t\t\toriginalEndCoords = trueEndCoordsDeterminer(movementLine, axis, actualEndCoordsAxisValue);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif (!originalEndCoords) {\n\t\t\t// They didn't specifically target any group.\n\t\t\t// They must have moved further left or right than any group.\n\t\t\tif (compressedEndCoordsAxisValue + HALF_ARBITRARY_DISTANCE < axisOrder[0].transformedRange![0]) {\n\t\t\t\t// They moved left of the leftmost group\n\t\t\t\tconsole.log(`Moved piece wants to move left of the leftmost group on the ${axis} axis.`);\n\n\t\t\t\tconst distToLeftMostGroup = compressedEndCoordsAxisValue - axisOrder[0]!.transformedRange![0];\n\t\t\t\tconst actualEndCoordsAxisValue = axisOrder[0]!.range[0] + distToLeftMostGroup;\n\t\t\t\t// The ACTUAL coordinates they moved to!\n\t\t\t\toriginalEndCoords = trueEndCoordsDeterminer(movementLine, axis, actualEndCoordsAxisValue);\n\t\t\t} else if (compressedEndCoordsAxisValue - HALF_ARBITRARY_DISTANCE > axisOrder[axisOrder.length - 1]!.transformedRange![1]) {\n\t\t\t\t// They moved right of the rightmost group\n\t\t\t\tconsole.log(`Moved piece wants to move right of the rightmost group on the ${axis} axis.`);\n\n\t\t\t\tconst distToRightMostGroup = compressedEndCoordsAxisValue - axisOrder[axisOrder.length - 1]!.transformedRange![1];\n\t\t\t\tconst actualEndCoordsAxisValue = axisOrder[axisOrder.length - 1]!.range[1] + distToRightMostGroup;\n\t\t\t\t// The ACTUAL coordinates they moved to!\n\t\t\t\toriginalEndCoords = trueEndCoordsDeterminer(movementLine, axis, actualEndCoordsAxisValue);\n\t\t\t} else {\n\t\t\t\tconsole.log(`Moved piece is not interested in any groups on the ${axis} axis.`);\n\t\t\t\tconsole.log('compressedEndCoordsAxisValue:', compressedEndCoordsAxisValue);\n\t\t\t}\n\t\t}\n\t}\n\n\tif (!originalEndCoords) throw Error(\"Unable to determine the original end coordinates of the moved piece! \");\n\n\treturn {\n\t\tstartCoords: originalStartCoords,\n\t\tendCoords: originalEndCoords\n\t};\n}\n\n/**\n * Takes the movement line of the moved piece, the axis it is interested in,\n * the axis value of the axis group it is interested in,\n * and determines the true end coordinates it wants to land on\n * in the original uncompressed position.\n */\nfunction trueEndCoordsDeterminer(movementLine: LineCoefficients, axisOfInterest: '1,0' | '0,1', targetAxisValue: bigint): Coords {\n\t// console.log(\"Determining true end coords for axis:\", axisOfGroupOfInterest, \" with target axis value:\", targetAxisValue);\n\n\tconst axisPerpendicularVec: Vec2 = vectors.getPerpendicularVector(vectors.getVec2FromKey(axisOfInterest));\n\n\t// I need to find the intersection point between the movement line,\n\t// and the line of vector axisPerpendicularVec with the targetAxisValue.\n\n\t// First determine the axisPerpendicularVec line with the targetAxisValue.\n\tlet intersectionLine: LineCoefficients;\n\tif (axisOfInterest === '1,0') {\n\t\t// The line is vertical, so the x coordinate is targetAxisValue\n\t\tintersectionLine = vectors.getLineGeneralFormFromCoordsAndVec([targetAxisValue, 0n], axisPerpendicularVec);\n\t} else if (axisOfInterest === '0,1') {\n\t\t// The line is horizontal, so the y coordinate is targetAxisValue\n\t\tintersectionLine = vectors.getLineGeneralFormFromCoordsAndVec([0n, targetAxisValue], axisPerpendicularVec);\n\t} else throw Error(`Unknown axis of group of interest: ${axisOfInterest}`);\n\n\t// console.log(\"movementLine:\", movementLine);\n\t// console.log(\"intersectionLine:\", intersectionLine);\n\n\t// Now find the intersection point between the movement line and the intersection line.\n\tconst intersectionPoint: BDCoords | undefined = geometry.calcIntersectionPointOfLines(...movementLine, ...intersectionLine);\n\tif (!intersectionPoint) throw Error(`Unable to find intersection point between movement line and group of interest!`);\n\tif (!bd.areCoordsIntegers(intersectionPoint)) throw Error(`Intersection point between movement line and group of interest is not an integer coordinate!`);\n\n\treturn bd.coordsToBigInt(intersectionPoint);\n}\n\n\n// ================================== EXPORTS ==================================\n\n\nexport default {\n\texpandMove,\n};"
  },
  {
    "path": "dev-utils/scripts/positionnormalizer/normalizertester.ts",
    "content": "// dev-utils/scripts/positionnormalizer/normalizertester.ts\n\n\n/**\n * ONLY FOR TESTING COMPRESSING POSITIONS\n */\n\n\n// ================================ Testing Usage ================================\n\nimport moveexpander from \"./moveexpander\";\nimport positioncompressor from \"./positioncompressor\";\nimport icnconverter, { MoveCoords } from \"../icn/icnconverter\";\n\nconst example_position = '[Event \"Casual local Classical infinite chess game\"] [Site \"https://www.infinitechess.org/\"] [Variant \"Classical\"] [Round \"-\"] [UTCDate \"2023.11.01\"] [UTCTime \"12:02:58\"] [TimeControl \"-\"] [Result \"0-1\"] [Termination \"Checkmate\"] b 1/100 21 (8|1) P1,2+|P8,2+|p1,7+|p2,7+|p7,7+|p8,7+|R8,1+|r1,8+|N7,1|P5,4|p6,6|k6,7|K6,2|r6,8|n7,8|p5,6|P7,4|b5,10|n5,5|q-35694371,-35694371|B-114930749,114930754 ';\n// const example_position = 'K0,0|q-800,1200|N300,-1800|B-1800,100|r600,200|R-520,-340|P900,-50|b-1100,700|n220,330|Q-1500,-1200|k-7000,5000|R9400,300|r-2700,-8800|B-500,9800|b1500,-9600|Q-9300,1500|q8200,-3600|N-9800,-600|n9900,-400|P-9200,3800';\n// const example_position = 'Q-1214,8032|n-594,9261|R4939,1877|B-2227,-3463|b-6210,553|q-8440,1848|N6323,-2171|r8431,671|n-3601,-7208|B4522,209|R-8722,-9556|Q-4978,-100|b1854,-9810|N5564,4021|q2312,-1722|r-6410,9360|n2938,-831|B-7724,-2190|Q9019,3540|R-1125,-6378';\n// const example_position = 'Q-120,850|n-125,858|B4200,-7320|b4207,-7313|R-7821,5110|r-7815,5118|q9012,-442|N-311,-9980|n-318,-9989|B7345,1442|b7336,1436|R-2599,-6288'; // Heart\n// const example_position = 'R-42,118|b133,-55|N-210,305|q87,192|n-166,-211|B249,-315|Q-321,88|r-140,-388|B422,-76|n355,301|b-291,-422|Q315,94|R-388,255|q298,-154|N4200,-3900|r-5600,3188|B7120,-2981|n-8441,1210|b9822,-4033|Q-9331,6120'; // Julia set was always working.\n// const example_position = 'Q-1214,8032|R4939,1877|N6323,-2171|n-3601,-7208|B4522,209|q2312,-1722|r-6410,9360';\n// const example_position = 'Q-9032,1442|B3841,-6672|R-7210,5142|q912,8475|B-6112,2033|R-1278,-9880|Q-4468,755'; // Infinity repetition triangle FORCED TO calculate group's error against all other pieces!\n// const example_position = 'k0,0|R1200,800|R-1500,-600|R900,-1300|R-700,1100|R300,-1300|R2000,0|R900,2100|R0,2300|R-2200,-2200|R2000,-600|R8000,12000|R-15000,4000|R18000,-6000|R-13000,-16000|R10000,9000|R-9500,14500|R12000,-18000|R-8000,-12000|R19000,2100|R-20000,-600|R905,-1295|R1204,804|R-1504,-596|R295,-1304|R-705,1097'; // Orthogonal test\n// const example_position = 'k0,0|R900,-1300|R-700,1100|R300,-1300|R2000,0|R2000,-600|R1204,804'; // Orthogonal test\n// const example_position = 'k0,0|R1200,800|R-1500,-600|R-700,1100|R2000,0|R900,2100|R0,2300|R2000,-600|R-1504,-596'; // House\n// const example_position = 'k0,0|R900,2100|R0,2300|R18000,-6000|R12000,-18000|R1204,804|R295,-1304|R-705,1097';\n// const example_position = 'k0,0|Q-10000,5000|R-20000,1000|R-20000,2000|R-20000,3000|R-20000,4000'; // Diagonal test\n// const example_position = 'K0,0|q834,1191|R-2240,6303|n4201,-889|b-1719,-8260|Q9329,-214'; // 5 random pieces\n// const example_position = 'K0,0|q834,1191|R-2240,6303'; // 3 pieces of above\n// const example_position = 'K0,0|q-150,150|R-30,60|r-30,64|R-30,120';\n// const example_position = 'K0,0|R-30,60|r-30,65|R-30,120';\n// const example_position = 'K0,0|q-150,150|R-30,60|r-30,90|R-30,120'; // 3 rooks in between\n// const example_position = 'K0,0|q-110,125|R-30,60|r-30,90'; // 2 rooks in between, queen WAY up\n// const example_position = 'b-140,-30|K0,0|q-120,125|R-30,60|r-30,90'; // Same as below but with an extra bishop\n// const example_position = 'K0,0|q-120,125|R-30,60|r-30,90'; // 2 rooks in between, queen 5 up\n// const example_position = 'K0,0|q-125,120|R-30,60|r-30,90'; // 2 rooks in between, queen 5 left\n// const example_position = 'K0,0|q-120,120|R-30,60|r-30,90'; // 2 rooks in between\n// const example_position = 'q-125,120|R-30,115|k0,0'; // rook same y group as queen\n// const example_position = 'q-125,120|R-30,60|k0,0'; // 1 rook in between, queen 5 left\n// const example_position = 'n5,60|q-20,60|r33,40|K40,0'; // random connections\n// const example_position = 'n5,60|q0,60|r40,40|K60,0'; // 1 rook in between, knight 5 right\n// const example_position = 'n-5,60|q0,60|r40,40|K60,0'; // 1 rook in between, knight 5 left\n// const example_position = 'q0,60|r40,40|K60,0'; // 1 rook in between\n// const example_position = 'K0,33|q30,0';\n// const example_position = 'K0,30|q33,0';\n// const example_position = 'K0,30|q30,0';\n// const example_position = \"q0,50|k80,0\";\n\nconst parsedPosition = icnconverter.ShortToLong_Format(example_position);\n// console.log(\"parsedPosition:\", JSON.stringify(parsedPosition.position, jsutil.stringifyReplacer));\n\n// const compressedPosition = positioncompressor.compressPosition(parsedPosition.position!, 'orthogonals');\nconst compressedPosition = positioncompressor.compressPosition(parsedPosition.position!, 'diagonals');\n\nconsole.log(\"\\nBefore:\");\nconsole.log(example_position);\n\nconst newICN = icnconverter.getShortFormPosition(compressedPosition.position, parsedPosition.state_global.specialRights!);\nconsole.log(\"\\nAfter:\");\nconsole.log(newICN);\nconsole.log(\"\\n\");\n\n// const chosenMove: MoveCoords = {\n// \tstartCoords: [20n, 5n],\n// \tendCoords: [0n, 1n],\n// };\n\n// const expandedMove = moveexpander.expandMove(compressedPosition.axisOrders, compressedPosition.pieceTransformations, chosenMove);\n\n// console.log(`\\nChosen move:   Start: (${String(chosenMove.startCoords)})   End: (${String(chosenMove.endCoords)})`);\n// console.log(`Expanded move:   Start: (${String(expandedMove.startCoords)})   End: (${String(expandedMove.endCoords)})\\n`);"
  },
  {
    "path": "dev-utils/scripts/positionnormalizer/positioncompressor.ts",
    "content": "// dev-utils/scripts/positionnormalizer/positioncompressor.ts\n\n/**\n * This script contains an algorithm that can take an infinite chess position,\n * which may have pieces at arbitrarily large coordinates, and compress it\n * so that all pieces are within the bounds of standard javascript doubles,\n * while retaining all piece relationships to each other.\n */\n\nimport type { Vec2Key } from \"../../../util/math/vectors.js\";\n\nimport { solve, Model } from \"yalps\"; // Linear Programming Solver!\n\nimport bimath from \"../../../util/bigdecimal/bimath.js\";\nimport coordutil, { Coords, CoordsKey } from \"../../util/coordutil.js\";\nimport typeutil, { players as p, rawTypes as r } from \"../../util/typeutil.js\";\n\n// ============================== Type Definitions ==============================\n\n\n/**\n * A compressed position, along with the transformation info to be able to\n * expand the chosen move back to the original position.\n */\ninterface CompressionInfo {\n\tposition: Map<CoordsKey, number>;\n\taxisOrders: AxisOrders;\n\t/**\n\t * Contains information on each group, the group's\n\t * original position, and each piece in the group.\n\t */\n\tpieceTransformations: PieceTransform[]\n}\n\n/**\n * Contains the information of where a piece started\n * before compressing the position, and where they ended up.\n */\ntype PieceTransform = {\n\ttype: number;\n\t/** The original coordinates of the piece in the uncompressed position. */\n\tcoords: Coords;\n\t/**\n\t * The pieces new coordinates in the transformed/compressed position.\n\t * Both coords will be fully defined after the orthogonal solution is finished.\n\t */\n\ttransformedCoords: [bigint | undefined, bigint | undefined];\n};\n\n\n/**\n * Contains information of what pieces are connected/linked/merged on what axis,\n * and how they have been transformed into the compressed position.\n */\ntype AxisOrders = Record<Vec2Key, AxisOrder>;\n\n/**\n * An ordering of the pieces on one axis (X/Y/pos-diag/neg-diag),\n * also storing what pieces are linked together (their axis values are close together).\n */\ntype AxisOrder = AxisGroup[];\n\n/**\n * A group of pieces all linked on one axis (X/Y/pos-diag/neg-diag) \n * due to being close together.\n */\ntype AxisGroup = {\n\trange: [bigint, bigint];\n\ttransformedRange?: [bigint, bigint];\n\tpieces: PieceTransform[];\n}\n\n\n/**\n * Takes a pair of coordinates and returns a single\n * value that is unique to the axis line that piece is on.\n */\ntype AxisDeterminer = (_coords: Coords) => bigint;\n\n/** All orthogonal axes. */\ntype OrthoAxis = '1,0' | '0,1';\n/** All diagonal axes. */\ntype DiagAxis = '1,1' | '1,-1';\n/** Any axis. */\ntype Axis = OrthoAxis | DiagAxis;\n\n\n/**\n * A variable name in the Linear Programming Model.\n * \n * The first letter is what axis the piece coord is for. (u/v is only used in constraint names)\n * After the `-` is the index of the piece in its sorted list.\n */\ntype VariableName = `x-${number}` | `y-${number}` | `u-${number}` | `v-${number}`;\n\n/**\n * One column in a constraint of the Linear Programming Model.\n */\ntype Column = {\n\t/** The name of the variable */\n\tvariable: string;\n\t/** The coefficient of the variable in the constraint equation. Usually 1 or -1.  */\n\tcoefficient: number; // \n}\n\n\n// ================================== Constants ==================================\n\n\n/**\n * Piece groups further than this many squares away from the origin\n * will be compressed closer to the origin.\n * \n * IN THE FUTURE: Determine whether a position needs to be compressed or not\n * BASED ON WHETHER intersections of groups, or intersections of intersections\n * lie beyond Number.MAX_SAFE_INTEGER!\n * \n * Actually it actually might be smarter to always normalize positions so engines\n * have more floating point precision to work with.\n */\nconst UNSAFE_BOUND_BIGINT = BigInt(Math.trunc(Number.MAX_SAFE_INTEGER * 0.1));\n// const UNSAFE_BOUND_BIGINT = 1000n;\n\n\n/**\n * How close pieces or groups have to be on on axis or diagonal to\n * link them together, so that that axis or diagonal will not be\n * broken when compressing the position.\n * \n * They will receive equality constrains instead of inequality constraints.\n * \n * This is also considered the minimum distance for a distance\n * to be considered arbitrary. After all, almost never do we move a\n * short range piece over 20 squares in a game, so the difference\n * between 20 and 1 million squares is very little.\n * \n * Of course if we are taking into account connections between sub groups\n * and sub sub groups, the distance naturally becomes larger in order to\n * retain forks and forks of forks.\n * \n * REQUIREMENTS:\n * \n * * Must be OVER 2x larger than than the longest jumping jumper piece.\n * This is so that they will remain connected to the same group when expanding/lifting the move back out.\n * Jumping moves don't need extra attention other than making sure this is big enough.\n * Code works automatically, even for hippogonal jumps!\n * \n * * Must be divisible by 2, as this is divided by two in moveexpander.ts\n */\n// const MIN_ARBITRARY_DISTANCE = 100n;\nconst MIN_ARBITRARY_DISTANCE = 10n;\n\n\n/**\n * Each axis determiner, given a coordinate, will return the bigint value\n * that represents the axis value on the given axis for that piece.\n * \n * The axis value is an integer unique to all pieces that lie on the same axis line as it.\n */\nconst AXIS_DETERMINERS = {\n\t/** X Axis */\n\t'1,0': (compressedEndCoords: Coords): bigint => compressedEndCoords[0],\n\t/** Y Axis */\n\t'0,1': (compressedEndCoords: Coords): bigint => compressedEndCoords[1],\n\t/** Positive Diagonal Axis */\n\t'1,1': (coords: Coords): bigint => coords[1] - coords[0],\n\t/** Negative Diagonal Axis */\n\t'1,-1': (coords: Coords): bigint => coords[1] + coords[0],\n};\n\n\n\n// ==================================== Main Function ====================================\n\n\n\n/**\n * Compresses/normalizes a position. Reduces all arbitrary large distances\n * to some small distance constant.\n * Returns transformation info so that the chosen move from the compressed position\n * can be expanded/lifted back to the original position.\n * @param position - The position to compress, as a Map of coords to piece types.\n * @param mode - The compression mode, either 'orthogonals' or 'diagonals'.\n *     - 'orthogonals' require all pieces to remain in the same quadrant relative to other pieces.\n *     - 'diagonals' require all pieces to remain in the same octant relative to other pieces.\n *     - FUTURE: 'hipppogonal' require all pieces to remain in the same hexadecant relative to other pieces.\n */\nfunction compressPosition(position: Map<CoordsKey, number>, mode: 'orthogonals' | 'diagonals'): CompressionInfo {\n\n\t// List all pieces with their bigint arbitrary coordinates.\n\n\tconst pieces: PieceTransform[] = [];\n\n\tposition.forEach((type, coordsKey) => {\n\t\tconst coords = coordutil.getCoordsFromKey(coordsKey);\n\t\tpieces.push({\n\t\t\ttype,\n\t\t\tcoords,\n\t\t\ttransformedCoords: [undefined, undefined], // Initially undefined\n\t\t});\n\t});\n\n\t// Determine if the position even needs compression by\n\t// seeing whether any piece lies beyond UNSAFE_BOUND_BIGINT.\n\n\t// const needsCompression = pieces.some(piece =>\n\t// \tbimath.abs(piece.coords[0]) > UNSAFE_BOUND_BIGINT || bimath.abs(piece.coords[1]) > UNSAFE_BOUND_BIGINT\n\t// );\n\n\t// if (!needsCompression) {\n\t// \tconsole.log(\"No compression needed.\");\n\t// \tfor (const piece of pieces) piece.transformedCoords = piece.coords;\n\t// \treturn { position, pieceTransformations: pieces };\n\t// }\n\n\n\t// ==================================== Construct Axis Orders, Order Pieces ====================================\n\n\t\n\t/**\n\t * Orderings of the pieces on every axis of movement,\n\t * and how they are all grouped/connected together.\n\t */\n\tconst AllAxisOrders: AxisOrders = {};\n\n\t/** All pieces, organized in ascending order on every axis. */\n\tconst OrderedPieces: Record<Vec2Key, PieceTransform[]> = {};\n\n\t// Init the Axis Orders\n\tprocessAxis('1,0');\n\tprocessAxis('0,1');\n\tif (mode === 'diagonals') {\n\t\tprocessAxis('1,1');\n\t\tprocessAxis('1,-1');\n\t}\n\n\t/** Helper for constructing the axisOrder and ordered pieces of one axis. */\n\tfunction processAxis(axis: Axis): void {\n\t\tconst axisDeterminer = AXIS_DETERMINERS[axis];\n\n\t\t// First sort the pieces by ascending axis value\n\t\tconst sortedPieces: PieceTransform[] = pieces.slice(); // Shallow copy\n\t\tsortedPieces.sort((a, b) => bimath.compare(axisDeterminer(a.coords), axisDeterminer(b.coords)));\n\t\tOrderedPieces[axis] = sortedPieces;\n\n\t\tconst axisOrder: AxisOrder = [];\n\t\tAllAxisOrders[axis] = axisOrder;\n\n\t\t// Go through the sorted pieces one by one, creating the groups on this axis.\n\t\tlet currentGroup: AxisGroup | null = null;\n\t\tfor (const piece of sortedPieces) {\n\t\t\tconst currentAxisValue = axisDeterminer(piece.coords);\n\t\t\t\n\t\t\t// If the axis value is less than or equal to MIN_ARBITRARY_DISTANCE from the current\n\t\t\t// group being pushed to range's END, add it to that group and extend its range.\n\t\t\t// Else, start a new group.\n\n\t\t\tif (currentGroup === null || currentAxisValue - currentGroup.range[1] > MIN_ARBITRARY_DISTANCE) {\n\t\t\t\t// Start a new group\n\t\t\t\tcurrentGroup = { pieces: [], range: [currentAxisValue, currentAxisValue] };\n\t\t\t\taxisOrder.push(currentGroup);\n\t\t\t}\n\n\t\t\t// Add the piece to the current running group\n\t\t\tcurrentGroup.pieces.push(piece);\n\t\t\t// Update its range\n\t\t\tcurrentGroup.range[1] = currentAxisValue;\n\t\t}\n\t}\n\n\n\t// All pieces are now in order!\n\n\t// ONLY FOR LOGGING ---------------------------------------------\n\t// console.log(\"\\nAll axis orders after registering pieces:\");\n\t// for (const vec2Key in AllAxisOrders) {\n\t// \tconst axisOrder = AllAxisOrders[vec2Key] as AxisOrder;\n\t// \tconsole.log(`Axis order ${vec2Key}:`);\n\t// \tfor (const axisGroup of axisOrder) {\n\t// \t\tconsole.log(`  Range: ${axisGroup.range}, Pieces: ${axisGroup.pieces.length}`);\n\t// \t}\n\t// }\n\t// --------------------------------------------------------------\n\t\n\n\t// ================================ MODEL CONSTRAINTS ================================\n\n\t\n\t// Initiate the linear programming model for solving.\n\n\tconst model: Model = {\n\t\tdirection: 'minimize',\n\t\tobjective: 'manhatten_norm', // The objective function to minimize\n\t\tconstraints: {\n\t\t\t// An equation\n\t\t\t// piece1_X_constraint: { min: 10 }, // The right hand side of the equation:   >= 10\n\t\t\t// piece1_Y_constraint: { min: 10 },\n\t\t},\n\t\tvariables: {\n\t\t\t// piece1_X: { manhatten_norm: 1,   piece1_X_constraint: 1 }, // A list of what equations (constraints) this variable is a part of (a column in), and the coefficient it gets (1 for addition, -1 for subtraction).\n\t\t\t// piece1_Y: { manhatten_norm: 1,   piece1_Y_constraint: 1 },\n\t\t},\n\t\t// Enforces all variables to be integers.\n\t\t// Without this, sometimes the solution's piece coordinates will be at half squares.\n\t\tintegers: true,\n\t};\n\n\t/**\n\t * A map containing a reference to each piece's Model X & Y coord variable names.\n\t * Only used if we are in diagonals mode.\n\t */\n\tconst pieceToVarNames = new Map<PieceTransform, Record<Vec2Key, VariableName>>();\n\n\t// ANCHOR: Add constraints to anchor the first X and Y pieces at 0. -------------\n\n\tconst firstXVarName = getVariableName('1,0', 0);\n\taddConstraintToModel(model, `${firstXVarName}_anchor`, [\n\t\t{ variable: firstXVarName, coefficient: 1 },\n\t], 'equal', 0);\n\n\tconst firstYVarName = getVariableName('0,1', 0);\n\taddConstraintToModel(model, `${firstYVarName}_anchor`, [\n\t\t{ variable: firstYVarName, coefficient: 1 },\n\t], 'equal', 0);\n\n\t// -------------------------------------------------------------------------------\n\n\t// Add all the constraints between our piece coordinates to the model.\n\n\t// For each sorted piece on a specific axis, add a constraint to that piece and the previous piece\n\tcreateConstraintsForAxis('1,0');\n\tcreateConstraintsForAxis('0,1');\n\tif (mode === 'diagonals') {\n\t\t// When using diagonals, first populate the piece to varName map first.\n\t\t// We need this because a piece's index in the organized diagonal list\n\t\t// is not the same as its index in the orthogonal lists.\n\t\tpopulatePieceVarNames('1,0');\n\t\tpopulatePieceVarNames('0,1');\n\n\t\tcreateConstraintsForAxis('1,1');\n\t\tcreateConstraintsForAxis('1,-1');\n\t}\n\n\t/** Helper for constructing {@link pieceToVarNames}. */\n\tfunction populatePieceVarNames(axis: '0,1' | '1,0') {\n\t\tOrderedPieces[axis].forEach((piece, index) => {\n\t\t\tconst varName = getVariableName(axis, index);\n\t\t\tif (!pieceToVarNames.has(piece)) pieceToVarNames.set(piece, {});\n\t\t\tpieceToVarNames.get(piece)![axis] = varName;\n\t\t});\n\t}\n\n\t/**\n\t * Helper for creating and adding the constraints between each\n\t * adjacent piece on one specific axis to the linear programming model.\n\t */\n\tfunction createConstraintsForAxis(axis: Axis) {\n\t\tconst axisDeterminer = AXIS_DETERMINERS[axis];\n\t\tconst sortedPieces = OrderedPieces[axis];\n\n\t\tconst firstPiece = sortedPieces[0];\n\t\tlet firstPieceAxisValue = axisDeterminer(firstPiece.coords);\n\n\t\tfor (let i = 1; i < sortedPieces.length; i++) {\n\t\t\tconst secondPiece = sortedPieces[i];\n\t\t\tconst secondPieceAxisValue = axisDeterminer(secondPiece.coords);\n\n\t\t\t// Determine if the constraint is exact, or min\n\t\t\tlet type: 'equal' | 'min';\n\t\t\tlet constraint: number;\n\t\t\tconst difference = secondPieceAxisValue - firstPieceAxisValue;\n\t\t\tif (difference <= MIN_ARBITRARY_DISTANCE) {\n\t\t\t\t// EXACT constraint (same group)\n\t\t\t\ttype = 'equal';\n\t\t\t\tconstraint = Number(difference);\n\t\t\t} else {\n\t\t\t\t// MINIMUM constraint (different groups, over MIN_ARBITRARY_DISTANCE apart)\n\t\t\t\ttype = 'min';\n\t\t\t\tconstraint = Number(MIN_ARBITRARY_DISTANCE);\n\t\t\t}\n\n\t\t\tif (axis === '1,0' || axis === '0,1') {\n\t\t\t\tconst firstPieceVarName = getVariableName(axis, i - 1);\n\t\t\t\tconst secondPieceVarName = getVariableName(axis, i);\n\n\t\t\t\tconst constraintName = getConstraintName(secondPieceVarName);\n\n\t\t\t\t// What does the constraint look like on the X/Y axis?\n\t\t\t\t// Desired:\t\t\t   thisPieceXY >= prevPieceXY + 10\n\t\t\t\t// To get that we do:  thisPieceXY - prevPieceXY >= 10\n\n\t\t\t\taddConstraintToModel(model, constraintName, [\n\t\t\t\t\t{ variable: secondPieceVarName, coefficient: 1 },\n\t\t\t\t\t{ variable: firstPieceVarName, coefficient: -1 },\n\t\t\t\t], type, constraint);\n\t\t\t\t\n\t\t\t\t// If this is the last piece on the X/Y axis, then we\n\t\t\t\t// need to include it in our optimization function!\n\t\t\t\t// The optimization function tries to minimize the furthest piece\n\t\t\t\t// on the X/Y axes. This naturally tries to shrink the position.\n\t\t\t\tconst lastPiece = i === sortedPieces.length - 1;\n\t\t\t\tif (lastPiece) model.variables[secondPieceVarName][model.objective!] = 1;\n\t\t\t} else if (axis === '1,1' || axis === '1,-1') {\n\t\t\t\tconst firstPiece = sortedPieces[i - 1];\n\t\t\t\tconst secondPiece = sortedPieces[i];\n\n\t\t\t\t// Get the variable names for the piece's X and Y coordinates from the X & Y ordered lists.\n\t\t\t\tconst firstPieceVars = pieceToVarNames.get(firstPiece)!;\n\t\t\t\tconst secondPieceVars = pieceToVarNames.get(secondPiece)!;\n\n\t\t\t\tconst firstPieceVarNameX = firstPieceVars['1,0']!;\n\t\t\t\tconst firstPieceVarNameY = firstPieceVars['0,1']!;\n\t\t\t\tconst secondPieceVarNameX = secondPieceVars['1,0']!;\n\t\t\t\tconst secondPieceVarNameY = secondPieceVars['0,1']!;\n\n\t\t\t\tconst constraintName = getConstraintName(getVariableName(axis, i));\n\n\t\t\t\tif (axis === '1,1') {\n\t\t\t\t\t// What does the constraint look like if this is the U axis?\n\t\t\t\t\t// U axis value (positive diagonal) is determined by:  Y - X\n\t\t\t\t\t// Desired:\t\t\t   thisPieceY - thisPieceX >= prevPieceY - prevPieceX + 10\n\t\t\t\t\t// To get that we do:  thisPieceY - thisPieceX - prevPieceY + prevPieceX >= 10\n\t\t\t\t\taddConstraintToModel(model, constraintName, [\n\t\t\t\t\t\t// Second piece diagonal\n\t\t\t\t\t\t{ variable: secondPieceVarNameY, coefficient: 1 },\n\t\t\t\t\t\t{ variable: secondPieceVarNameX, coefficient: -1 },\n\t\t\t\t\t\t// First piece diagonal\n\t\t\t\t\t\t{ variable: firstPieceVarNameY, coefficient: -1 },\n\t\t\t\t\t\t{ variable: firstPieceVarNameX, coefficient: 1 },\n\t\t\t\t\t], type, constraint);\n\t\t\t\t} else if (axis === '1,-1') {\n\t\t\t\t\t// What does the constraint look like if this is the V axis?\n\t\t\t\t\t// V axis value (negative diagonal) is determined by:  X + Y\n\t\t\t\t\t// Desired:\t\t\t   thisPieceX + thisPieceY >= prevPieceX + prevPieceY + 10\n\t\t\t\t\t// To get that we do:  thisPieceX + thisPieceY - prevPieceX - prevPieceY >= 10\n\t\t\t\t\taddConstraintToModel(model, constraintName, [\n\t\t\t\t\t\t// Second piece diagonal\n\t\t\t\t\t\t{ variable: secondPieceVarNameX, coefficient: 1 },\n\t\t\t\t\t\t{ variable: secondPieceVarNameY, coefficient: 1 },\n\t\t\t\t\t\t// First piece diagonal\n\t\t\t\t\t\t{ variable: firstPieceVarNameX, coefficient: -1 },\n\t\t\t\t\t\t{ variable: firstPieceVarNameY, coefficient: -1 },\n\t\t\t\t\t], type, constraint);\n\t\t\t\t} else throw Error(\"Unexpected!\");\n\t\t\t} else throw Error(`Unsupported axis ${axis}.`);\n\n\t\t\t// Prepare for next iteration\n\t\t\tfirstPieceAxisValue = secondPieceAxisValue;\n\t\t}\n\t}\n\n\t// Solve the Model\n\n\tconsole.time(\"Solved\");\n\n\tconst solution = solve(model, {\n\t\t// Include variables that are zero in the solution.\n\t\t// We need piece coords even if they are at 0!\n\t\tincludeZeroVariables: true,\n\t});\n\n\tconsole.timeEnd(\"Solved\");\n\n\tconsole.log(\"Solution status:\", solution.status);\n\t// The score of the solution. This is the sum of the furthest piece's X and Y coordinates.\n\tconsole.log(\"Result:\", solution.result);\n\n\tif (solution.status !== 'optimal') {\n\t\tconsole.error(\"The unified solver could not find a feasible solution.\");\n\t\tthrow new Error(\"Unified LP solver failed. Constraints may be contradictory.\");\n\t}\n\n\t\n\t// ==================================== Transformed Coordinate Assembly ====================================\n\n\t\n\t// The solution object contains the solved X & Y positions for every single piece.\n\t// Extract all the variables.\n\n\tfor (const [variableName, value] of solution.variables) {\n\t\tconst [axis, pieceIndex] = (variableName as VariableName).split('-');\n\n\t\tif (axis === 'x') {\n\t\t\tconst sortedPieces = OrderedPieces['1,0'];\n\t\t\tconst piece = sortedPieces[pieceIndex]!;\n\t\t\t// Set its transformed X coord.\n\t\t\tpiece.transformedCoords[0] = BigInt(value);\n\t\t} else if (axis === 'y') {\n\t\t\tconst sortedPieces = OrderedPieces['0,1'];\n\t\t\tconst piece = sortedPieces[pieceIndex]!;\n\t\t\t// Set its transformed Y coord.\n\t\t\tpiece.transformedCoords[1] = BigInt(value);\n\t\t} else throw Error(\"Unknown axis.\");\n\t}\n\n\t// Calculate the new, transformed range, for each group on each axis.\n\t// Needed for the moveexpander knows what group your move is targeting.\n\tfor (const axisKey in AllAxisOrders) {\n\t\tconst axisOrder = AllAxisOrders[axisKey as Vec2Key];\n\t\tconst axisDeterminer = AXIS_DETERMINERS[axisKey as Axis];\n\n\t\tfor (const group of axisOrder) {\n\t\t\tlet start: bigint | null = null;\n\t\t\tlet end: bigint | null = null;\n\n\t\t\t// Iterate through the pieces in the group to find the min and max axis values.\n\t\t\tfor (let i = 0; i < group.pieces.length; i++) {\n\t\t\t\tconst piece = group.pieces[i]!;\n\t\t\t\tconst axisValue = axisDeterminer(piece.transformedCoords as Coords);\n\t\t\t\tif (start === null || axisValue < start) start = axisValue;\n\t\t\t\tif (end === null || axisValue > end) end = axisValue;\n\t\t\t}\n\t\t\t\n\t\t\t// Set the calculated transformed range for the group.\n\t\t\tgroup.transformedRange = [start!, end!];\n\t\t}\n\t}\n\n\t// [Optional] Shift the entire solution so that the White King is in its original spot! (Doesn't break the solution/topology)\n\t// ISN'T required for engines, but may be nice for visuals.\n\t// Commented-out for decreasing the script size.\n\t// RecenterTransformedPosition(pieces, AllAxisOrders);\n\n\t// Assemble the final compressed position from the solved piece's transformed coordinates.\n\n\tconst compressedPosition: Map<CoordsKey, number> = new Map();\n\tfor (const piece of pieces) {\n\t\t// Add the final coordinate and piece type to our output map.\n\t\tconst transformedCoordsKey = coordutil.getKeyFromCoords(piece.transformedCoords as Coords);\n\t\tcompressedPosition.set(transformedCoordsKey, piece.type);\n\t}\n\n\t// Return the complete compression information, which is used to expand the chosen move, later.\n\treturn {\n\t\tposition: compressedPosition,\n\t\taxisOrders: AllAxisOrders,\n\t\tpieceTransformations: pieces,\n\t};\n}\n\n\n// ========================================== MODEL HELPERS ==========================================\n\n\n/**\n * Returns a string we'll use for the variable name in the linear programming model.\n * @param axis - What axis this variable is for\n * @param index - The index of the piece in its sorted list.\n */\nfunction getVariableName(axis: Axis, index: number): VariableName {\n\tconst axisLetter = axis === '1,0' ? 'x' : axis === '0,1' ? 'y' : axis === '1,1' ? 'u' : axis === '1,-1' ? 'v' : (() => { throw Error(\"Unsupported axis.\"); })();\n\treturn `${axisLetter}-${index}`;\n}\n\nfunction getConstraintName(varName: VariableName) {\n\treturn `${varName}_constraint`;\n}\n\n/**\n * Helper for adding a constraint to the running linear programming model.\n * \n * Creates the variable in the model if it doesn't exist yet, adds the constraint,\n * and updates the variable's columns its included in.\n */\nfunction addConstraintToModel(model: Model, constraint_name: string, columns: Column[], type: 'equal' | 'min' | 'max', value: number): void {\n\t// Add the equation\n\tmodel.constraints[constraint_name] = { [type]: value };\n\t// Add the variables as columns to it\n\tfor (const column of columns) {\n\t\t// Initialize first if not already\n\t\tif (!model.variables[column.variable]) model.variables[column.variable] = {};\n\t\t// Include the variable in the column of the constraint function\n\t\tmodel.variables[column.variable][constraint_name] = column.coefficient;\n\t}\n}\n\n\n// ======================================== RECENTERING TRANFORMED POSITION ========================================\n\n\n\n// ISN'T required for engines, but may be nice for visuals.\n// Commented-out for decreasing the script size.\n/**\n * Translates the entire transformed position so tht the White King\n * ends up on the same square it occupied in the original, uncompressed position.\n * This doesn't affect the solution or topology at all.\n * @param allPieces The list of all transformed pieces.\n * @param allAxisOrders The AxisOrders object containing all axis groups of the transformed position.\n */\nfunction RecenterTransformedPosition(allPieces: PieceTransform[], allAxisOrders: AxisOrders) {\n\t// Define the type for a White King (you may need to import typeutil and players)\n\tconst whiteKingType = typeutil.buildType(r.KING, p.WHITE);\n\n\t// 1. Find the White King in the list of pieces.\n\tconst whiteKing: PieceTransform | undefined = allPieces.find(p => p.type === whiteKingType);\n\n\tif (!whiteKing) {\n\t\tconsole.warn(\"Could not find White King to normalize position. Skipping translation.\");\n\t\treturn;\n\t}\n\n\t// 2. Calculate the required translation vector (dx, dy).\n\tconst transformedKingCoords = whiteKing.transformedCoords as Coords;\n\tconst translationVector: Coords = [\n\t\twhiteKing.coords[0] - transformedKingCoords[0],\n\t\twhiteKing.coords[1] - transformedKingCoords[1]\n\t];\n\n\tconsole.log(`Normalizing position by translating all pieces by [${translationVector[0]}, ${translationVector[1]}] to match White King's original position.`);\n\n\t// 3. Apply the translation to every piece's transformed coordinates.\n\tfor (const piece of allPieces) {\n\t\tpiece.transformedCoords[0]! += translationVector[0];\n\t\tpiece.transformedCoords[1]! += translationVector[1];\n\t}\n\n\t// 4. Apply the same translation to all axes' groups' transformedRange.\n\tfor (const axisKey in allAxisOrders) {\n\t\tconst axisOrder = allAxisOrders[axisKey as Vec2Key];\n\t\tconst axisDeterminer = AXIS_DETERMINERS[axisKey];\n\n\t\t// Calculate how the translationVector translates on this specific axis.\n\t\t// This is equivalent to axisDeterminer([dx, dy]) - axisDeterminer([0, 0]).\n\t\tconst pushAmount = axisDeterminer(translationVector);\n\t\t\n\t\tfor (const group of axisOrder) {\n\t\t\tif (group.transformedRange) {\n\t\t\t\tgroup.transformedRange[0] += pushAmount;\n\t\t\t\tgroup.transformedRange[1] += pushAmount;\n\t\t\t}\n\t\t}\n\t}\n}\n\n\n// ========================================= EXPORTS =========================================\n\n\nexport type {\n\tAxisOrders,\n\tPieceTransform,\n};\n\nexport default {\n\t// Constants\n\tMIN_ARBITRARY_DISTANCE,\n\tAXIS_DETERMINERS,\n\t// Main Function\n\tcompressPosition,\n};"
  },
  {
    "path": "dev-utils/scripts/positionnormalizer/positioncompressorplusintersections.ts",
    "content": "// src/client/scripts/esm/chess/logic/positionnormalizer/positioncompressor.ts\n\n/**\n * This script contains an algorithm that can take an infinite chess position,\n * which may have pieces at arbitrarily large coordinates, and compress it\n * so that all pieces are within the bounds of standard javascript doubles.\n */\n\nimport type { LineCoefficientsBD, Vec2, Vec2Key } from '../../../util/math/vectors.js';\n\nimport { solve, Model } from 'yalps'; // Linear Programming Solver!\n\nimport bimath from '../../../util/bigdecimal/bimath.js';\nimport coordutil, { BDCoords, Coords, CoordsKey } from '../../util/coordutil.js';\nimport typeutil, { players as p, rawTypes as r } from '../../util/typeutil.js';\nimport vectors from '../../../util/math/vectors.js';\nimport geometry from '../../../util/math/geometry.js';\nimport bd, { BigDecimal } from '../../../util/bigdecimal/bigdecimal.js';\n\n// ============================== Type Definitions ==============================\n\n/**\n * A compressed position, along with the transformation info to be able to\n * expand the chosen move back to the original position.\n */\ninterface CompressionInfo {\n\tposition: Map<CoordsKey, number>;\n\taxisOrders: AxisOrders;\n\t/**\n\t * Contains information on each group, the group's\n\t * original position, and each piece in the group.\n\t */\n\tpieceTransformations: PieceTransform[];\n}\n\n/**\n * Contains the information of where a piece started\n * before compressing the position, and where they ended up.\n */\ntype PieceTransform = {\n\ttype: number;\n\t/** The original coordinates of the piece in the uncompressed position. */\n\tcoords: BDCoords;\n\t/**\n\t * The pieces new coordinates in the transformed/compressed position.\n\t * Both coords will be fully defined after the orthogonal solution is finished.\n\t */\n\ttransformedCoords: [BigDecimal | undefined, BigDecimal | undefined];\n};\n\n/**\n * Contains information of what pieces are connected/linked/merged on what axis,\n * and how they have been transformed into the compressed position.\n */\ntype AxisOrders = Record<Vec2Key, AxisOrder>;\n\n/**\n * An ordering of the pieces on one axis (X/Y/pos-diag/neg-diag),\n * also storing what pieces are linked together (their axis values are close together).\n */\ntype AxisOrder = AxisGroup[];\n\n/**\n * A group of pieces all linked on one axis (X/Y/pos-diag/neg-diag)\n * due to being close together.\n */\ntype AxisGroup = {\n\trange: [BigDecimal, BigDecimal];\n\ttransformedRange?: [BigDecimal, BigDecimal];\n\tpieces: PieceTransform[];\n};\n\n/**\n * Takes a pair of coordinates and returns a single\n * value that is unique to the axis line that piece is on.\n */\ntype AxisDeterminer = (_coords: Coords) => bigint;\n\n/** All orthogonal axes. */\ntype OrthoAxis = '1,0' | '0,1';\n/** All diagonal axes. */\ntype DiagAxis = '1,1' | '1,-1';\n/** Any axis. */\ntype Axis = OrthoAxis | DiagAxis;\n\n/**\n * A variable name in the Linear Programming Model.\n *\n * The first letter is what axis the piece coord is for. (u/v is only used in constraint names)\n * After the `-` is the index of the piece in its sorted list.\n */\ntype VariableName = `x-${number}` | `y-${number}` | `u-${number}` | `v-${number}`;\n\n/**\n * One column in a constraint of the Linear Programming Model.\n */\ntype Column = {\n\t/** The name of the variable */\n\tvariable: string;\n\t/** The coefficient of the variable in the constraint equation. Usually 1 or -1.  */\n\tcoefficient: number; //\n};\n\n// ================================== Constants ==================================\n\n/**\n * Piece groups further than this many squares away from the origin\n * will be compressed closer to the origin.\n *\n * IN THE FUTURE: Determine whether a position needs to be compressed or not\n * BASED ON WHETHER intersections of groups, or intersections of intersections\n * lie beyond Number.MAX_SAFE_INTEGER!\n *\n * Actually it actually might be smarter to always normalize positions so engines\n * have more floating point precision to work with.\n */\nconst UNSAFE_BOUND_BIGINT = BigInt(Math.trunc(Number.MAX_SAFE_INTEGER * 0.1));\n// const UNSAFE_BOUND_BIGINT = 1000n;\n\n/**\n * How close pieces or groups have to be on on axis or diagonal to\n * link them together, so that that axis or diagonal will not be\n * broken when compressing the position.\n *\n * They will receive equality constrains instead of inequality constraints.\n *\n * This is also considered the minimum distance for a distance\n * to be considered arbitrary. After all, almost never do we move a\n * short range piece over 20 squares in a game, so the difference\n * between 20 and 1 million squares is very little.\n *\n * Of course if we are taking into account connections between sub groups\n * and sub sub groups, the distance naturally becomes larger in order to\n * retain forks and forks of forks.\n *\n * REQUIREMENTS:\n *\n * * Must be OVER 2x larger than than the longest jumping jumper piece.\n * This is so that they will remain connected to the same group when expanding/lifting the move back out.\n * Jumping moves don't need extra attention other than making sure this is big enough.\n * Code works automatically, even for hippogonal jumps!\n *\n * * Must be divisible by 2, as this is divided by two in moveexpander.ts\n */\n// const MIN_ARBITRARY_DISTANCE = 100n;\nconst MIN_ARBITRARY_DISTANCE = 10n;\nconst MIN_ARBITRARY_DISTANCE_BD = bd.fromBigInt(MIN_ARBITRARY_DISTANCE);\n\n/**\n * Each axis determiner, given a coordinate, will return the bigint value\n * that represents the axis value on the given axis for that piece.\n *\n * The axis value is an integer unique to all pieces that lie on the same axis line as it.\n */\nconst AXIS_DETERMINERS = {\n\t/** X Axis */\n\t'1,0': (compressedEndCoords: Coords): bigint => compressedEndCoords[0],\n\t/** Y Axis */\n\t'0,1': (compressedEndCoords: Coords): bigint => compressedEndCoords[1],\n\t/** Positive Diagonal Axis */\n\t'1,1': (coords: Coords): bigint => coords[1] - coords[0],\n\t/** Negative Diagonal Axis */\n\t'1,-1': (coords: Coords): bigint => coords[1] + coords[0],\n};\n\nconst AXIS_DETERMINERS_BD = {\n\t/** X Axis */\n\t'1,0': (compressedEndCoords: BDCoords): BigDecimal => compressedEndCoords[0],\n\t/** Y Axis */\n\t'0,1': (compressedEndCoords: BDCoords): BigDecimal => compressedEndCoords[1],\n\t/** Positive Diagonal Axis */\n\t'1,1': (coords: BDCoords): BigDecimal => bd.subtract(coords[1], coords[0]),\n\t/** Negative Diagonal Axis */\n\t'1,-1': (coords: BDCoords): BigDecimal => bd.add(coords[1], coords[0]),\n};\n\n/**\n * The piece type number reserved for intersection placeholder pieces.\n *\n * MUST NOT BE ANY NUMBER ALREADY ASIGNED TO A PIECE TYPE IN typeutil.ts!\n */\nconst INTERSECTION_TYPE = -1;\n\n// ==================================== Main Function ====================================\n\n/**\n * Compresses/normalizes a position. Reduces all arbitrary large distances\n * to some small distance constant.\n * Returns transformation info so that the chosen move from the compressed position\n * can be expanded/lifted back to the original position.\n * @param position - The position to compress, as a Map of coords to piece types.\n * @param mode - The compression mode, either 'orthogonals' or 'diagonals'.\n *     - 'orthogonals' require all pieces to remain in the same quadrant relative to other pieces.\n *     - 'diagonals' require all pieces to remain in the same octant relative to other pieces.\n *     - FUTURE: 'hipppogonal' require all pieces to remain in the same hexadecant relative to other pieces.\n */\nfunction compressPosition(\n\tposition: Map<CoordsKey, number>,\n\torthogonals: boolean,\n\tdiagonals: boolean,\n\thippogonals: boolean,\n\tnumIntersections: number,\n): CompressionInfo {\n\tif (!orthogonals && !diagonals && !hippogonals)\n\t\tthrow Error('Position to compress must have at least one axis mode enabled.');\n\tif (numIntersections > 0 && !hippogonals && orthogonals !== diagonals)\n\t\tthrow Error('numIntersections has no effect when only one axis is enabled.');\n\n\t// List all pieces with their bigint arbitrary coordinates.\n\n\tconst pieces: PieceTransform[] = [];\n\n\tposition.forEach((type, coordsKey) => {\n\t\tconst coords = coordutil.getCoordsFromKey(coordsKey);\n\t\tpieces.push({\n\t\t\ttype,\n\t\t\tcoords: bd.FromCoords(coords),\n\t\t\ttransformedCoords: [undefined, undefined], // Initially undefined\n\t\t});\n\t});\n\n\t// Append the intersections of each pair of pieces.\n\n\taddIntersectionsToPieces(pieces, orthogonals, diagonals, hippogonals, numIntersections);\n\n\t// Determine if the position even needs compression by\n\t// seeing whether any piece lies beyond UNSAFE_BOUND_BIGINT.\n\n\t// const needsCompression = pieces.some(piece =>\n\t// \tbimath.abs(piece.coords[0]) > UNSAFE_BOUND_BIGINT || bimath.abs(piece.coords[1]) > UNSAFE_BOUND_BIGINT\n\t// );\n\n\t// if (!needsCompression) {\n\t// \tconsole.log(\"No compression needed.\");\n\t// \tfor (const piece of pieces) piece.transformedCoords = piece.coords;\n\t// \treturn { position, pieceTransformations: pieces };\n\t// }\n\n\t// ==================================== Construct Axis Orders, Order Pieces ====================================\n\n\t/**\n\t * Orderings of the pieces on every axis of movement,\n\t * and how they are all grouped/connected together.\n\t */\n\tconst AllAxisOrders: AxisOrders = {};\n\n\t/** All pieces, organized in ascending order on every axis. */\n\tconst OrderedPieces: Record<Vec2Key, PieceTransform[]> = {};\n\n\t// Init the Axis Orders\n\tif (orthogonals) {\n\t\tprocessAxis('1,0');\n\t\tprocessAxis('0,1');\n\t}\n\tif (diagonals) {\n\t\tprocessAxis('1,1');\n\t\tprocessAxis('1,-1');\n\t}\n\n\t/** Helper for constructing the axisOrder and ordered pieces of one axis. */\n\tfunction processAxis(axis: Axis): void {\n\t\tconst axisDeterminer = AXIS_DETERMINERS_BD[axis];\n\n\t\t// First sort the pieces by ascending axis value\n\t\tconst sortedPieces: PieceTransform[] = pieces.slice(); // Shallow copy\n\t\tsortedPieces.sort((a, b) => bd.compare(axisDeterminer(a.coords), axisDeterminer(b.coords)));\n\t\tOrderedPieces[axis] = sortedPieces;\n\n\t\tconst axisOrder: AxisOrder = [];\n\t\tAllAxisOrders[axis] = axisOrder;\n\n\t\t// Go through the sorted pieces one by one, creating the groups on this axis.\n\t\tlet currentGroup: AxisGroup | null = null;\n\t\tfor (const piece of sortedPieces) {\n\t\t\tconst currentAxisValue: BigDecimal = axisDeterminer(piece.coords);\n\n\t\t\t// If the axis value is less than or equal to MIN_ARBITRARY_DISTANCE from the current\n\t\t\t// group being pushed to range's END, add it to that group and extend its range.\n\t\t\t// Else, start a new group.\n\n\t\t\tif (\n\t\t\t\tcurrentGroup === null ||\n\t\t\t\tbd.compare(\n\t\t\t\t\tbd.subtract(currentAxisValue, currentGroup.range[1]),\n\t\t\t\t\tMIN_ARBITRARY_DISTANCE_BD,\n\t\t\t\t) > 0\n\t\t\t) {\n\t\t\t\t// currentAxisValue - currentGroup.range[1] > MIN_ARBITRARY_DISTANCE\n\t\t\t\t// Start a new group\n\t\t\t\tcurrentGroup = { pieces: [], range: [currentAxisValue, currentAxisValue] };\n\t\t\t\taxisOrder.push(currentGroup);\n\t\t\t}\n\n\t\t\t// Add the piece to the current running group\n\t\t\tcurrentGroup.pieces.push(piece);\n\t\t\t// Update its range\n\t\t\tcurrentGroup.range[1] = currentAxisValue;\n\t\t}\n\t}\n\n\t// All pieces are now in order!\n\n\t// ONLY FOR LOGGING ---------------------------------------------\n\t// console.log(\"\\nAll axis orders after registering pieces:\");\n\t// for (const vec2Key in AllAxisOrders) {\n\t// \tconst axisOrder = AllAxisOrders[vec2Key] as AxisOrder;\n\t// \tconsole.log(`Axis order ${vec2Key}:`);\n\t// \tfor (const axisGroup of axisOrder) {\n\t// \t\tconsole.log(`  Range: ${stringifyBDCoords(axisGroup.range)}, Pieces: ${axisGroup.pieces.length}`);\n\t// \t}\n\t// }\n\t// --------------------------------------------------------------\n\n\t// ================================ MODEL CONSTRAINTS ================================\n\n\t// Initiate the linear programming model for solving.\n\n\tconst model: Model = {\n\t\tdirection: 'minimize',\n\t\tobjective: 'manhatten_norm', // The objective function to minimize\n\t\tconstraints: {\n\t\t\t// An equation\n\t\t\t// piece1_X_constraint: { min: 10 }, // The right hand side of the equation:   >= 10\n\t\t\t// piece1_Y_constraint: { min: 10 },\n\t\t},\n\t\tvariables: {\n\t\t\t// piece1_X: { manhatten_norm: 1,   piece1_X_constraint: 1 }, // A list of what equations (constraints) this variable is a part of (a column in), and the coefficient it gets (1 for addition, -1 for subtraction).\n\t\t\t// piece1_Y: { manhatten_norm: 1,   piece1_Y_constraint: 1 },\n\t\t},\n\t\t// Enforces all variables to be integers.\n\t\t// Without this, sometimes the solution's piece coordinates will be at half squares.\n\t\t// integers: true,\n\t};\n\n\t/**\n\t * A map containing a reference to each piece's Model X & Y coord variable names.\n\t * Only used if we are in diagonals mode.\n\t */\n\tconst pieceToVarNames = new Map<PieceTransform, Record<Vec2Key, VariableName>>();\n\n\t// ANCHOR: Add constraints to anchor the first X and Y pieces at 0. -------------\n\n\tconst firstXVarName = getVariableName('1,0', 0);\n\taddConstraintToModel(\n\t\tmodel,\n\t\t`${firstXVarName}_anchor`,\n\t\t[{ variable: firstXVarName, coefficient: 1 }],\n\t\t'equal',\n\t\t0,\n\t);\n\n\tconst firstYVarName = getVariableName('0,1', 0);\n\taddConstraintToModel(\n\t\tmodel,\n\t\t`${firstYVarName}_anchor`,\n\t\t[{ variable: firstYVarName, coefficient: 1 }],\n\t\t'equal',\n\t\t0,\n\t);\n\n\t// -------------------------------------------------------------------------------\n\n\t// Add all the constraints between our piece coordinates to the model.\n\n\t// For each sorted piece on a specific axis, add a constraint to that piece and the previous piece\n\tif (orthogonals) {\n\t\tcreateConstraintsForAxis('1,0');\n\t\tcreateConstraintsForAxis('0,1');\n\t}\n\tif (diagonals) {\n\t\t// When using diagonals, first populate the piece to varName map first.\n\t\t// We need this because a piece's index in the organized diagonal list\n\t\t// is not the same as its index in the orthogonal lists.\n\t\tpopulatePieceVarNames('1,0');\n\t\tpopulatePieceVarNames('0,1');\n\n\t\tcreateConstraintsForAxis('1,1');\n\t\tcreateConstraintsForAxis('1,-1');\n\t}\n\n\t/** Helper for constructing {@link pieceToVarNames}. */\n\tfunction populatePieceVarNames(axis: '0,1' | '1,0') {\n\t\tOrderedPieces[axis].forEach((piece, index) => {\n\t\t\tconst varName = getVariableName(axis, index);\n\t\t\tif (!pieceToVarNames.has(piece)) pieceToVarNames.set(piece, {});\n\t\t\tpieceToVarNames.get(piece)![axis] = varName;\n\t\t});\n\t}\n\n\t/**\n\t * Helper for creating and adding the constraints between each\n\t * adjacent piece on one specific axis to the linear programming model.\n\t */\n\tfunction createConstraintsForAxis(axis: Axis) {\n\t\tconst axisDeterminer = AXIS_DETERMINERS_BD[axis];\n\t\tconst sortedPieces = OrderedPieces[axis];\n\n\t\tconst firstPiece = sortedPieces[0];\n\t\tlet firstPieceAxisValue = axisDeterminer(firstPiece.coords);\n\n\t\tfor (let i = 1; i < sortedPieces.length; i++) {\n\t\t\tconst secondPiece = sortedPieces[i];\n\t\t\tconst secondPieceAxisValue = axisDeterminer(secondPiece.coords);\n\n\t\t\t// Determine if the constraint is exact, or min\n\t\t\tlet type: 'equal' | 'min';\n\t\t\tlet constraint: number;\n\t\t\tconst difference = bd.subtract(secondPieceAxisValue, firstPieceAxisValue);\n\t\t\tif (bd.compare(difference, MIN_ARBITRARY_DISTANCE_BD) <= 0) {\n\t\t\t\t// EXACT constraint (same group)\n\t\t\t\ttype = 'equal';\n\t\t\t\t// constraint = Number(difference);\n\t\t\t\tconstraint = bd.toNumber(difference);\n\t\t\t} else {\n\t\t\t\t// MINIMUM constraint (different groups, over MIN_ARBITRARY_DISTANCE apart)\n\t\t\t\ttype = 'min';\n\t\t\t\tconstraint = Number(MIN_ARBITRARY_DISTANCE);\n\t\t\t}\n\n\t\t\tif (axis === '1,0' || axis === '0,1') {\n\t\t\t\tconst firstPieceVarName = getVariableName(axis, i - 1);\n\t\t\t\tconst secondPieceVarName = getVariableName(axis, i);\n\n\t\t\t\tconst constraintName = getConstraintName(secondPieceVarName);\n\n\t\t\t\t// What does the constraint look like on the X/Y axis?\n\t\t\t\t// Desired:\t\t\t   thisPieceXY >= prevPieceXY + 10\n\t\t\t\t// To get that we do:  thisPieceXY - prevPieceXY >= 10\n\n\t\t\t\taddConstraintToModel(\n\t\t\t\t\tmodel,\n\t\t\t\t\tconstraintName,\n\t\t\t\t\t[\n\t\t\t\t\t\t{ variable: secondPieceVarName, coefficient: 1 },\n\t\t\t\t\t\t{ variable: firstPieceVarName, coefficient: -1 },\n\t\t\t\t\t],\n\t\t\t\t\ttype,\n\t\t\t\t\tconstraint,\n\t\t\t\t);\n\n\t\t\t\t// If this is the last piece on the X/Y axis, then we\n\t\t\t\t// need to include it in our optimization function!\n\t\t\t\t// The optimization function tries to minimize the furthest piece\n\t\t\t\t// on the X/Y axes. This naturally tries to shrink the position.\n\t\t\t\tconst lastPiece = i === sortedPieces.length - 1;\n\t\t\t\tif (lastPiece) model.variables[secondPieceVarName][model.objective!] = 1;\n\t\t\t} else if (axis === '1,1' || axis === '1,-1') {\n\t\t\t\tconst firstPiece = sortedPieces[i - 1];\n\t\t\t\tconst secondPiece = sortedPieces[i];\n\n\t\t\t\t// Get the variable names for the piece's X and Y coordinates from the X & Y ordered lists.\n\t\t\t\tconst firstPieceVars = pieceToVarNames.get(firstPiece)!;\n\t\t\t\tconst secondPieceVars = pieceToVarNames.get(secondPiece)!;\n\n\t\t\t\tconst firstPieceVarNameX = firstPieceVars['1,0']!;\n\t\t\t\tconst firstPieceVarNameY = firstPieceVars['0,1']!;\n\t\t\t\tconst secondPieceVarNameX = secondPieceVars['1,0']!;\n\t\t\t\tconst secondPieceVarNameY = secondPieceVars['0,1']!;\n\n\t\t\t\tconst constraintName = getConstraintName(getVariableName(axis, i));\n\n\t\t\t\tif (axis === '1,1') {\n\t\t\t\t\t// What does the constraint look like if this is the U axis?\n\t\t\t\t\t// U axis value (positive diagonal) is determined by:  Y - X\n\t\t\t\t\t// Desired:\t\t\t   thisPieceY - thisPieceX >= prevPieceY - prevPieceX + 10\n\t\t\t\t\t// To get that we do:  thisPieceY - thisPieceX - prevPieceY + prevPieceX >= 10\n\t\t\t\t\taddConstraintToModel(\n\t\t\t\t\t\tmodel,\n\t\t\t\t\t\tconstraintName,\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\t// Second piece diagonal\n\t\t\t\t\t\t\t{ variable: secondPieceVarNameY, coefficient: 1 },\n\t\t\t\t\t\t\t{ variable: secondPieceVarNameX, coefficient: -1 },\n\t\t\t\t\t\t\t// First piece diagonal\n\t\t\t\t\t\t\t{ variable: firstPieceVarNameY, coefficient: -1 },\n\t\t\t\t\t\t\t{ variable: firstPieceVarNameX, coefficient: 1 },\n\t\t\t\t\t\t],\n\t\t\t\t\t\ttype,\n\t\t\t\t\t\tconstraint,\n\t\t\t\t\t);\n\t\t\t\t} else if (axis === '1,-1') {\n\t\t\t\t\t// What does the constraint look like if this is the V axis?\n\t\t\t\t\t// V axis value (negative diagonal) is determined by:  X + Y\n\t\t\t\t\t// Desired:\t\t\t   thisPieceX + thisPieceY >= prevPieceX + prevPieceY + 10\n\t\t\t\t\t// To get that we do:  thisPieceX + thisPieceY - prevPieceX - prevPieceY >= 10\n\t\t\t\t\taddConstraintToModel(\n\t\t\t\t\t\tmodel,\n\t\t\t\t\t\tconstraintName,\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\t// Second piece diagonal\n\t\t\t\t\t\t\t{ variable: secondPieceVarNameX, coefficient: 1 },\n\t\t\t\t\t\t\t{ variable: secondPieceVarNameY, coefficient: 1 },\n\t\t\t\t\t\t\t// First piece diagonal\n\t\t\t\t\t\t\t{ variable: firstPieceVarNameX, coefficient: -1 },\n\t\t\t\t\t\t\t{ variable: firstPieceVarNameY, coefficient: -1 },\n\t\t\t\t\t\t],\n\t\t\t\t\t\ttype,\n\t\t\t\t\t\tconstraint,\n\t\t\t\t\t);\n\t\t\t\t} else throw Error('Unexpected!');\n\t\t\t} else throw Error(`Unsupported axis ${axis}.`);\n\n\t\t\t// Prepare for next iteration\n\t\t\tfirstPieceAxisValue = secondPieceAxisValue;\n\t\t}\n\t}\n\n\t// console.log(\"Model:\", model);\n\n\t// Solve the Model\n\n\tconsole.time('Solved');\n\n\tconst solution = solve(model, {\n\t\t// Include variables that are zero in the solution.\n\t\t// We need piece coords even if they are at 0!\n\t\tincludeZeroVariables: true,\n\t});\n\n\tconsole.timeEnd('Solved');\n\n\tconsole.log('Solution status:', solution.status);\n\t// The score of the solution. This is the sum of the furthest piece's X and Y coordinates.\n\tconsole.log('Result:', solution.result);\n\n\tif (solution.status !== 'optimal') {\n\t\tconsole.error('The unified solver could not find a feasible solution.');\n\t\tthrow new Error('Unified LP solver failed. Constraints may be contradictory.');\n\t}\n\n\t// ==================================== Transformed Coordinate Assembly ====================================\n\n\t// The solution object contains the solved X & Y positions for every single piece.\n\t// Extract all the variables.\n\n\tfor (const [variableName, value] of solution.variables) {\n\t\tconst [axis, pieceIndexStr] = (variableName as VariableName).split('-');\n\t\tconst pieceIndex = Number(pieceIndexStr);\n\n\t\tif (axis === 'x') {\n\t\t\tconst sortedPieces = OrderedPieces['1,0'];\n\t\t\tconst piece = sortedPieces[pieceIndex]!;\n\t\t\t// Set its transformed X coord.\n\t\t\t// piece.transformedCoords[0] = BigInt(value);\n\t\t\tpiece.transformedCoords[0] = bd.fromNumber(value);\n\t\t} else if (axis === 'y') {\n\t\t\tconst sortedPieces = OrderedPieces['0,1'];\n\t\t\tconst piece = sortedPieces[pieceIndex]!;\n\t\t\t// Set its transformed Y coord.\n\t\t\t// piece.transformedCoords[1] = BigInt(value);\n\t\t\tpiece.transformedCoords[1] = bd.fromNumber(value);\n\t\t} else throw Error('Unknown axis.');\n\t}\n\n\t// Calculate the new, transformed range, for each group on each axis.\n\t// Needed for the moveexpander knows what group your move is targeting.\n\tfor (const axisKey in AllAxisOrders) {\n\t\tconst axisOrder = AllAxisOrders[axisKey as Vec2Key];\n\t\tconst axisDeterminer = AXIS_DETERMINERS_BD[axisKey as Axis];\n\n\t\tfor (const group of axisOrder) {\n\t\t\tlet start: BigDecimal | null = null;\n\t\t\tlet end: BigDecimal | null = null;\n\n\t\t\t// Iterate through the pieces in the group to find the min and max axis values.\n\t\t\tfor (let i = 0; i < group.pieces.length; i++) {\n\t\t\t\tconst piece = group.pieces[i]!;\n\t\t\t\tconst axisValue = axisDeterminer(piece.transformedCoords as BDCoords);\n\t\t\t\tif (start === null || bd.compare(axisValue, start) < 0) start = axisValue;\n\t\t\t\tif (end === null || bd.compare(axisValue, end) > 0) end = axisValue;\n\t\t\t}\n\n\t\t\t// Set the calculated transformed range for the group.\n\t\t\tgroup.transformedRange = [start!, end!];\n\t\t}\n\t}\n\n\t// [Optional] Shift the entire solution so that the White King is in its original spot! (Doesn't break the solution/topology)\n\t// ISN'T required for engines, but may be nice for visuals.\n\t// Commented-out for decreasing the script size.\n\t// RecenterTransformedPosition(pieces, AllAxisOrders);\n\n\t// Assemble the final compressed position from the solved piece's transformed coordinates.\n\n\tconst compressedPosition: Map<CoordsKey, number> = new Map();\n\tfor (const piece of pieces) {\n\t\t// Add the final coordinate and piece type to our output map.\n\n\t\t// console.log(\"Piece type:\", stringifyBDCoords(piece.transformedCoords as BDCoords), typeutil.debugType(piece.type));\n\n\t\t// If the piece is an intersection, substitue a void for it, and round the coords to the nearest integer.\n\t\tif (piece.type === INTERSECTION_TYPE) {\n\t\t\tif (!bd.areCoordsIntegers(piece.transformedCoords as BDCoords)) continue; // Skip intersections that don't end up on integer coordinates.\n\t\t\tpiece.type = typeutil.buildType(r.VOID, p.NEUTRAL);\n\t\t} else if (!bd.areCoordsIntegers(piece.transformedCoords as BDCoords))\n\t\t\tthrow Error('Piece did not end up on integer coordinates after compression.');\n\n\t\t// Will round to the nearest integer, if it's an intersection.\n\t\tconst intCoords: Coords = bd.coordsToBigInt(piece.transformedCoords as BDCoords);\n\n\t\tconst transformedCoordsKey = coordutil.getKeyFromCoords(intCoords);\n\t\tcompressedPosition.set(transformedCoordsKey, piece.type);\n\t}\n\n\t// Return the complete compression information, which is used to expand the chosen move, later.\n\treturn {\n\t\tposition: compressedPosition,\n\t\taxisOrders: AllAxisOrders,\n\t\tpieceTransformations: pieces,\n\t};\n}\n\nfunction addIntersectionsToPieces(\n\tpieces: PieceTransform[],\n\torthogonals: boolean,\n\tdiagonals: boolean,\n\thippogonals: boolean,\n\tnumIntersections: number,\n) {\n\tif (numIntersections <= 0) return; // No intersections to add\n\n\tconsole.log('Piece count before adding intersections:', pieces.length);\n\n\tconst lineVectors: Vec2[] = [];\n\tif (orthogonals) lineVectors.push(...vectors.VECTORS_ORTHOGONAL);\n\tif (diagonals) lineVectors.push(...vectors.VECTORS_DIAGONAL);\n\tif (hippogonals) lineVectors.push(...vectors.VECTORS_HIPPOGONAL);\n\n\tconst intersections: BDCoords[] = [];\n\n\tfor (let a = 0; a < pieces.length; a++) {\n\t\tconst pieceA = pieces[a];\n\n\t\t// Eminate lines in all directions from the piece coords\n\t\tconst pieceALines: LineCoefficientsBD[] = lineVectors.map((l) =>\n\t\t\tvectors.getLineGeneralFormFromCoordsAndVecBD(pieceA.coords, l),\n\t\t);\n\n\t\tfor (let b = a + 1; b < pieces.length; b++) {\n\t\t\tconst pieceB = pieces[b];\n\n\t\t\t// Eminate lines in all directions from the piece coords\n\t\t\tconst pieceBLines: LineCoefficientsBD[] = lineVectors.map((l) =>\n\t\t\t\tvectors.getLineGeneralFormFromCoordsAndVecBD(pieceB.coords, l),\n\t\t\t);\n\n\t\t\t// For each pair of lines, check if they intersect.\n\t\t\tfor (const lineA of pieceALines) {\n\t\t\t\tfor (const lineB of pieceBLines) {\n\t\t\t\t\t// Do they intersect?\n\t\t\t\t\tconst intersection = geometry.calcIntersectionPointOfLinesBD(\n\t\t\t\t\t\t...lineA,\n\t\t\t\t\t\t...lineB,\n\t\t\t\t\t);\n\t\t\t\t\tif (intersection === undefined) continue; // No intersections (parallel, or same line)\n\t\t\t\t\t// They DO intersect.\n\t\t\t\t\t// Don't push if the same intersection hasn't already been added.\n\t\t\t\t\tif (intersections.some((i) => coordutil.areBDCoordsEqual(i, intersection)))\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t// Also don't push if the intersection lies on the same square as any other piece.\n\t\t\t\t\tif (pieces.some((p) => coordutil.areBDCoordsEqual(p.coords, intersection)))\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t// Push!\n\t\t\t\t\tintersections.push(intersection);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tconst intStrs: string[] = [];\n\tfor (const intersection of intersections) {\n\t\tintStrs.push(stringifyBDCoords(intersection));\n\t}\n\tconsole.log(`Found ${intersections.length} intersections: ` + intStrs.join(', '));\n\n\t// Add the intersections as pieces to the pieces list.\n\tfor (const intersection of intersections) {\n\t\tpieces.push({\n\t\t\ttype: INTERSECTION_TYPE,\n\t\t\tcoords: intersection,\n\t\t\ttransformedCoords: [undefined, undefined], // Initially undefined\n\t\t});\n\t}\n\n\tconsole.log('Piece count after adding intersections:', pieces.length);\n}\n\nfunction stringifyBDCoords(coords: BDCoords): string {\n\treturn `[${bd.toApproximateString(coords[0])}, ${bd.toApproximateString(coords[1])}]`;\n}\n\n// ========================================== MODEL HELPERS ==========================================\n\n/**\n * Returns a string we'll use for the variable name in the linear programming model.\n * @param axis - What axis this variable is for\n * @param index - The index of the piece in its sorted list.\n */\nfunction getVariableName(axis: Axis, index: number): VariableName {\n\tconst axisLetter =\n\t\taxis === '1,0'\n\t\t\t? 'x'\n\t\t\t: axis === '0,1'\n\t\t\t\t? 'y'\n\t\t\t\t: axis === '1,1'\n\t\t\t\t\t? 'u'\n\t\t\t\t\t: axis === '1,-1'\n\t\t\t\t\t\t? 'v'\n\t\t\t\t\t\t: (() => {\n\t\t\t\t\t\t\t\tthrow Error('Unsupported axis.');\n\t\t\t\t\t\t\t})();\n\treturn `${axisLetter}-${index}`;\n}\n\nfunction getConstraintName(varName: VariableName) {\n\treturn `${varName}_constraint`;\n}\n\n/**\n * Helper for adding a constraint to the running linear programming model.\n *\n * Creates the variable in the model if it doesn't exist yet, adds the constraint,\n * and updates the variable's columns its included in.\n */\nfunction addConstraintToModel(\n\tmodel: Model,\n\tconstraint_name: string,\n\tcolumns: Column[],\n\ttype: 'equal' | 'min' | 'max',\n\tvalue: number,\n): void {\n\t// Add the equation\n\tmodel.constraints[constraint_name] = { [type]: value };\n\t// Add the variables as columns to it\n\tfor (const column of columns) {\n\t\t// Initialize first if not already\n\t\tif (!model.variables[column.variable]) model.variables[column.variable] = {};\n\t\t// Include the variable in the column of the constraint function\n\t\tmodel.variables[column.variable][constraint_name] = column.coefficient;\n\t}\n}\n\n// ======================================== RECENTERING TRANFORMED POSITION ========================================\n\n// ISN'T required for engines, but may be nice for visuals.\n// Commented-out for decreasing the script size.\n/**\n * Translates the entire transformed position so tht the White King\n * ends up on the same square it occupied in the original, uncompressed position.\n * This doesn't affect the solution or topology at all.\n * @param allPieces The list of all transformed pieces.\n * @param allAxisOrders The AxisOrders object containing all axis groups of the transformed position.\n */\nfunction RecenterTransformedPosition(allPieces: PieceTransform[], allAxisOrders: AxisOrders) {\n\t// Define the type for a White King (you may need to import typeutil and players)\n\tconst whiteKingType = typeutil.buildType(r.KING, p.WHITE);\n\n\t// 1. Find the White King in the list of pieces.\n\tconst whiteKing: PieceTransform | undefined = allPieces.find((p) => p.type === whiteKingType);\n\n\tif (!whiteKing) {\n\t\tconsole.warn('Could not find White King to normalize position. Skipping translation.');\n\t\treturn;\n\t}\n\n\t// 2. Calculate the required translation vector (dx, dy).\n\tconst transformedKingCoords = whiteKing.transformedCoords as Coords;\n\tconst translationVector: Coords = [\n\t\twhiteKing.coords[0] - transformedKingCoords[0],\n\t\twhiteKing.coords[1] - transformedKingCoords[1],\n\t];\n\n\tconsole.log(\n\t\t`Normalizing position by translating all pieces by [${translationVector[0]}, ${translationVector[1]}] to match White King's original position.`,\n\t);\n\n\t// 3. Apply the translation to every piece's transformed coordinates.\n\tfor (const piece of allPieces) {\n\t\tpiece.transformedCoords[0]! += translationVector[0];\n\t\tpiece.transformedCoords[1]! += translationVector[1];\n\t}\n\n\t// 4. Apply the same translation to all axes' groups' transformedRange.\n\tfor (const axisKey in allAxisOrders) {\n\t\tconst axisOrder = allAxisOrders[axisKey as Vec2Key];\n\t\tconst axisDeterminer = AXIS_DETERMINERS[axisKey];\n\n\t\t// Calculate how the translationVector translates on this specific axis.\n\t\t// This is equivalent to axisDeterminer([dx, dy]) - axisDeterminer([0, 0]).\n\t\tconst pushAmount = axisDeterminer(translationVector);\n\n\t\tfor (const group of axisOrder) {\n\t\t\tif (group.transformedRange) {\n\t\t\t\tgroup.transformedRange[0] += pushAmount;\n\t\t\t\tgroup.transformedRange[1] += pushAmount;\n\t\t\t}\n\t\t}\n\t}\n}\n\n// ========================================= EXPORTS =========================================\n\nexport type { AxisOrders, PieceTransform };\n\nexport default {\n\t// Constants\n\tMIN_ARBITRARY_DISTANCE,\n\tAXIS_DETERMINERS,\n\t// Main Function\n\tcompressPosition,\n};\n"
  },
  {
    "path": "dev-utils/scripts/positionnormalizer/unusedpositionnormalizermethods.ts",
    "content": "\n\n\n// ======================================== ORTHOGONAL SOLVER ========================================\n\n\n// /**\n//  * On either the X or Y axis groups, initially sets each's transformedRange,\n//  * and their pieces' transformed coordinates according to the position's\n//  * orthogonal compressed solution.\n//  */\n// function TransformToOrthogonalSolution(axisOrder: AxisOrder, coordIndex: 0 | 1) {\n// \tlet current: bigint = 0n;\n\n// \tfor (const group of axisOrder) {\n// \t\t// Update the group's transformed range\n// \t\tconst groupSize = group.range[1] - group.range[0];\n// \t\t// Set the group's first draft transformed range.\n// \t\tgroup.transformedRange = [current, current + groupSize];\n\n// \t\t// Update each piece's transformed coordinates\n// \t\tfor (const piece of group.pieces) {\n// \t\t\t// Add the piece's offset from the start of the group\n// \t\t\tconst offset = piece.coords[coordIndex] - group.range[0];\n// \t\t\tpiece.transformedCoords[coordIndex] = group.transformedRange![0] + offset;\n// \t\t}\n\n// \t\t// Increment so that the next group has what's considered an arbitrary spacing between them\n// \t\tcurrent += MIN_ARBITRARY_DISTANCE + groupSize;\n// \t}\n// }\n\n\n// ======================================== HELPERS ========================================\n\n\n\n\n// /**\n//  * Calculates the amount a piece should be pushed to align with another piece.\n//  * It returns zero if the minimum space requirement is met already.\n//  */\n// function getShortFall(v_requirement: SeparationRequirement, current_dv: bigint): bigint {\n// \t// --- 3. CALCULATE V-AXIS SHORTFALL ---\n// \tlet v_shortfall = 0n;\n\n// \tif (v_requirement.type === 'exact') {\n// \t\t// If the requirement is exact, any deviation is a shortfall.\n// \t\tv_shortfall = v_requirement.separation - current_dv;\n// \t} else if (v_requirement.type === 'min') {\n// \t\t// If the requirement is a minimum, we only have a shortfall if we're below it.\n// \t\tif (current_dv < v_requirement.separation) {\n// \t\t\tv_shortfall = v_requirement.separation - current_dv;\n// \t\t}\n// \t} else if (v_requirement.type === 'max') {\n// \t\t// If the requirement is a maximum, we only have a shortfall if we're above it.\n// \t\tif (current_dv > v_requirement.separation) {\n// \t\t\tv_shortfall = v_requirement.separation - current_dv;\n// \t\t}\n// \t}\n\n// \treturn v_shortfall;\n// }\n\n// /**\n//  * Calculates the collapsible gap between a group and the group immediately following it on a given axis.\n//  * This gives the amount the group can be pushed WITHOUT AFFECTING FOLLOWING GROUPS!\n//  * @param axis - The orthogonal axis ('1,0' or '0,1') to measure the gap on.\n//  * @param groupIndex - The index of the group to check how much it can be pushed.\n//  * @returns The collapsible gap size as a non-negative bigint. Returns 0n if there is no collapsible space.\n//  */\n// function calculateCollapsableGap(axis: '1,0' | '0,1', AllAxisOrders: AxisOrders, groupIndex: number): bigint {\n// \tconst axisOrder = AllAxisOrders[axis];\n\n// \tconst currentGroup = axisOrder[groupIndex];\n// \tconst nextGroup = axisOrder[groupIndex + 1]!;\n\n// \t// The gap is the space between the end of the current group and the start of the next,\n// \t// minus the required padding. This is the amount of space that a push can \"collapse\".\n// \tconst gap = nextGroup.transformedRange![0] - currentGroup.transformedRange![1] - MIN_ARBITRARY_DISTANCE;\n\t\n// \t// The gap should never be negative in a valid state, but if it is, there's no collapsible space.\n// \tif (gap < 0n) throw Error(\"Overlapping groups!\"); // Safety check\n\t\n// \treturn gap;\n// }\n\n// /**\n//  * Calculates the total empty space (the sum of all gaps) between two groups on a given orthogonal axis.\n//  * The order of the group indices does not matter.\n//  * @param axis - The orthogonal axis ('1,0' or '0,1') to measure the gap on.\n//  * @param groupIndexA - The index of the first group.\n//  * @param groupIndexB - The index of the second group.\n//  * @returns The total gap size as a non-negative bigint. Returns 0n if the groups are adjacent or overlapping.\n//  */\n// function calculateGapBetweenGroups(axis: '1,0' | '0,1', AllAxisOrders: AxisOrders, groupIndexA: number, groupIndexB: number): bigint {\n// \tconst axisOrder = AllAxisOrders[axis];\n\n// \t// Ensure startIndex is the smaller of the two indices.\n// \tconst startIndex = Math.min(groupIndexA, groupIndexB);\n// \tconst endIndex = Math.max(groupIndexA, groupIndexB);\n\n// \t// If the groups are the same, there is no gap between them.\n// \tif (endIndex === startIndex) return 0n;\n\n// \tlet totalGap: bigint = 0n;\n\n// \t// Iterate through the groups *between* startIndex and endIndex.\n// \tfor (let i = startIndex; i < endIndex; i++) {\n// \t\tconst currentGroup = axisOrder[i];\n// \t\tconst nextGroup = axisOrder[i + 1];\n\n// \t\t// The gap is the space between the end of the current group and the start of the next, subtract the padding.\n// \t\tconst gap = nextGroup.transformedRange![0] - MIN_ARBITRARY_DISTANCE - currentGroup.transformedRange![1];\n// \t\tif (gap < 0n) throw Error(\"Gap is < 0!\"); // Protection in case this bug ever happens.\n\t\t\n// \t\ttotalGap += gap;\n// \t}\n\n// \treturn totalGap;\n// }\n\n// VERSION THAT PUSHES ALL GROUPS AFTERWARD EQUALLY, WITHOUT ABSORBING GAPS\n// /**\n//  * Pushes all groups on a given orthogonal axis from a starting index onwards by a specific amount.\n//  * @param axisToPush \n//  * @param axisOrder \n//  * @param startingGroupIndex - This group and all following groups will be pushed by the same amount.\n//  * @param pushAmount \n//  * @param coordIndex \n//  */\n// function ripplePush(axisToPush: '1,0' | '0,1', AllAxisOrders: AxisOrders, startingGroupIndex: number, pushAmount: bigint) {\n// \tif (pushAmount <= 0n) throw Error(`Ripple push amount must be positive, got ${pushAmount}.`);\n\n// \tconst coordIndex = axisToPush === '1,0' ? 0 : 1;\n// \tconst axisOrder = AllAxisOrders[axisToPush];\n\n// \tconst word = axisToPush === '1,0' ? 'RIGHT' : 'UP';\n// \tconsole.log(`Ripple pushing group of index ${startingGroupIndex} ${word} by ${pushAmount}...`);\n\n// \tfor (let i = startingGroupIndex; i < axisOrder.length; i++) {\n// \t\tconst groupToPush = axisOrder[i];\n// \t\tpushGroup(groupToPush, pushAmount, coordIndex);\n// \t}\n// }\n\n// /**\n//  * Pushes a given piece's group in the specified X/Y direction by a specific amount.\n//  * If there are any gaps in the X/Y axis groups to be filled behind it, it will do so,\n//  * otherwise, it will ripple push all groups in front of it, too.\n//  * In other words, subsequent groups will only be pushed by enough to ensure there\n//  * is no overlap between the last pushed group and them.\n//  * @param axis - What X/Y axis to ripple push the groups on.\n//  * @param firstPiece - This piece isn't pushed by the ripple, nor is its group.\n//  * @param piece - The piece of which group we are GUARANTEED to push. We will see if its optimal to push groups immediately before it, but not firstPiece's group or prior.\n//  * @param pushAmount - The amount to push the piece's group by. Subsequent groups will only be pushed enough to ensure there aren't any overlaps in groups.\n//  * @param axisDeterminer - What AxisDeterminer to use to calculate the error with the push. NOT the same as the direction of the push!!\n//  */\n// function ripplePush(\n// \taxis: '1,0' | '0,1',\n// \tAllAxisOrders: AxisOrders,\n// \tpiece: PieceTransform,\n// \tpushAmount: bigint,\n// ) {\n// \tif (pushAmount <= 0n) throw Error(`Ripple push amount must be positive, got ${pushAmount}.`);\n\n// \tconst word = axis === '1,0' ? 'RIGHT' : 'UP';\n\n// \tconst coordIndex = axis === '1,0' ? 0 : 1;\n// \tconst axisOrder = AllAxisOrders[axis];\n\n// \tconsole.log(`Ripple pushing group of piece ${String(piece.transformedCoords)} ${word} by ${pushAmount}...`);\n\n// \t// Perform the mandatory push on the piece's group and contionally, subsequent groups.\n// \t// If subsequent groups can fill a gap in this axis, they will. They just don't like to overlap.\n\t\n// \t// We know this push is REQUIRED because it is the ONLY action that will satisfy\n// \t// the constraint between piece A and piece B!\n\n// \t// First, push the group of the piece that is mandatory to be pushed.\n// \tconst mandatoryGroup = axisOrder[piece.axisGroups[axis]];\n// \tpushGroup(mandatoryGroup, pushAmount, coordIndex);\n\n// \t// Next, we're going to iterate through all subsequent groups,\n// \t// IF THEY NOW OVERLAP with the last pushed group, we push\n// \t// them right too, by the minimum amount to make their range start\n// \t// line up with the range end of the last pushed group.\n// \tlet lastPushedGroup = mandatoryGroup;\n// \tfor (let i = piece.axisGroups[axis] + 1; i < axisOrder.length; i++) {\n// \t\tconst groupToUpdate = axisOrder[i];\n\n// \t\t// If the last pushed group and this group now overlap, we need to push this group too,\n// \t\t// enough so that it starts at the end of the last pushed group's range end.\n// \t\tif (groupToUpdate.transformedRange![0] < lastPushedGroup.transformedRange![1] + MIN_ARBITRARY_DISTANCE) {\n// \t\t\t// Calculate how much to push this group by so that it starts at the end of the last pushed group's range.\n// \t\t\tconst pushAmount = lastPushedGroup.transformedRange![1] + MIN_ARBITRARY_DISTANCE - groupToUpdate.transformedRange![0];\n// \t\t\tconsole.log(`Pushing next group by ${pushAmount} to avoid overlap.`);\n// \t\t\tpushGroup(groupToUpdate, pushAmount, coordIndex);\n// \t\t\tlastPushedGroup = groupToUpdate; // Update the last pushed group\n// \t\t} else {\n// \t\t\t// No more groups to push, as they are not overlapping anymore.\n// \t\t\tbreak;\n// \t\t}\n// \t}\n// }\n\n// /**\n//  * Pushes a group by a specific amount in the X or Y direction,\n//  * updating its transformed range and the transformed coordinates of all pieces in the group.\n//  */\n// function pushGroup(group: AxisGroup, pushAmount: bigint, coordIndex: 0 | 1) {\n// \t// Update the transformed range of this group\n// \tgroup.transformedRange![0] += pushAmount;\n// \tgroup.transformedRange![1] += pushAmount;\n\n// \t// Update the transformed coords of all pieces in this group\n// \tfor (const pieceToPush of group.pieces) {\n// \t\tpieceToPush.transformedCoords[coordIndex]! += pushAmount;\n// \t}\n// }\n\n\n// /**\n//  * Takes a push amount and returns the level of error it has (absolute value).\n//  */\n// function calculateError(pushAmount: bigint) {\n// \treturn bimath.abs(pushAmount);\n// }\n\n// /**\n//  * Calculates the sum of all errors on the board on a specific axis between every single pair of pieces.\n//  * This gives one GRAND score where the higher the score, the more incorrect the pieces are relative\n//  * to each other (on that axis), and a score of 0n means the pieces are positioned PERFECT\n//  * relative to each other and no pushes are necessary anymore to satisfy all constraints between them.\n//  */\n// function calculateTotalAxisError(pieces: PieceTransform[], axisDeterminer: AxisDeterminer): bigint {\n// \tlet totalError = 0n;\n// \tfor (let i = 0; i < pieces.length; i++) {\n// \t\tconst pieceA = pieces[i];\n// \t\tfor (let j = i + 1; j < pieces.length; j++) {\n// \t\t\tconst pieceB = pieces[j];\n\n// \t\t\tconst axisDiff_Original = axisDeterminer(pieceA.coords) - axisDeterminer(pieceB.coords);\n// \t\t\tconst axisDiff_Transformed = axisDeterminer(pieceA.transformedCoords as Coords) - axisDeterminer(pieceB.transformedCoords as Coords);\n\n// \t\t\tconst pushAmount = calculatePushAmount(axisDiff_Original, axisDiff_Transformed);\n// \t\t\ttotalError += calculateError(pushAmount);\n// \t\t}\n// \t}\n// \treturn totalError;\n// }\n\n// /**\n//  * Calculates the topology of the board on a specific diagonal axis.\n//  * This is used for comparing against after doing some pushes\n//  * to detect if we've starting infinite repeating.\n//  * @param axis \n//  * @param AllAxisOrders \n//  */\n// function calculateBoardTopology(pieces: PieceTransform[], axisDeterminer: AxisDeterminer): bigint[] {\n\n// \tconst topology: bigint[] = [];\n\n// \t// Calculate the spacing between each pair of pieces on the board.\n// \tfor (let i = 0; i < pieces.length; i++) {\n// \t\tconst pieceA = pieces[i];\n// \t\tconst pieceA_AxisValue = axisDeterminer(pieceA.transformedCoords as Coords);\n// \t\tfor (let j = i + 1; j < pieces.length; j++) {\n// \t\t\tconst pieceB = pieces[j];\n// \t\t\tconst pieceB_AxisValue = axisDeterminer(pieceB.transformedCoords as Coords);\n\n// \t\t\tlet axisDiff = pieceB_AxisValue - pieceA_AxisValue;\n\n// \t\t\t// Cap the axisDiff to the +-MIN_ARBITRARY_DISTANCE\n// \t\t\taxisDiff = bimath.clamp(axisDiff, -MIN_ARBITRARY_DISTANCE, MIN_ARBITRARY_DISTANCE);\n\n// \t\t\ttopology.push(axisDiff);\n// \t\t}\n// \t}\n\n// \treturn topology;\n// }"
  },
  {
    "path": "dev-utils/scripts/vertexdatatotexture.ts",
    "content": "\n/**\n * This script converts an array of vertex data into a renderable WebGL texture.\n * \n * TODO:\n * \n * POLISH AND CLEAN THIS UP. It's actually untested to I have no idea how this works.\n * \n * Add options for controlling whether mipmaps are enabled, and smoothing.\n * \n */\n\nimport { createBufferFromData } from \"../../src/client/scripts/esm/game/rendering/buffers\";\n\n\n/**\n * Converts a shape, from its vertex data, to a renderable webgl texture.\n * @param gl - The webgl rendering context\n * @param vertexData - The vertex data of the shape to create a texture from. Stride length 6 (2 position, 4 color).\n * The positional data should be between 0-1\n * @returns The renderable webgl texture\n */\nfunction convertVertexDataToTexture(gl: WebGL2RenderingContext, vertexData: number[]): WebGLTexture {\n\tconst stride = 6; // Each vertex has 2 values for the x & y position, and 4 for the color\n\tconst resolution = 500; // 500px by 500px\n\n\tif (vertexData.length % stride !== 0) throw new Error('Vertex data not divisible by stride when converting to texture.');\n\n\t// Create and bind a framebuffer\n\tconst framebuffer = gl.createFramebuffer();\n\tgl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);\n\n\t// Create a texture to render to\n\tconst texture = gl.createTexture();\n\tgl.bindTexture(gl.TEXTURE_2D, texture);\n\tgl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, resolution, resolution, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);\n\n\tgl.generateMipmap(gl.TEXTURE_2D);\n\t// gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST_MIPMAP_LINEAR); // DEFAULT if not set. Jagged edges, mipmap interpollation (never blurry, though always jaggy)\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); // Smooth edges, mipmap interpollation (half-blurry all the time, EXCEPT with LOD bias)\n\t// gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST); // Smooth edges, mipmap snapping (clear on some zoom levels, full blurry at others)\n\t// gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST_MIPMAP_NEAREST); // Jagged edges, mipmap snapping (jagged all the time)\n\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); // Magnification, smooth edges (noticeable when zooming in)\n\n\n\t// Set texture parameters\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n\n\t// Attach the texture to the framebuffer\n\tgl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);\n\n\t// Check framebuffer completeness\n\tif (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {\n\t\tthrow new Error(\"Framebuffer is not complete\");\n\t}\n\n\tconst vbo = createBufferFromData(new Float32Array(vertexData));\n\n\t// Assume shaders and program are already set up\n\t// Attributes: aPosition (vec2) at location 0, aColor (vec4) at location 1\n\tgl.vertexAttribPointer(0, 2, gl.FLOAT, false, stride * Float32Array.BYTES_PER_ELEMENT, 0);\n\tgl.enableVertexAttribArray(0);\n\tgl.vertexAttribPointer(1, 4, gl.FLOAT, false, stride * Float32Array.BYTES_PER_ELEMENT, 2 * Float32Array.BYTES_PER_ELEMENT);\n\tgl.enableVertexAttribArray(1);\n\n\t// Set viewport to match the texture resolution\n\tgl.viewport(0, 0, resolution, resolution);\n\tgl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);\n\n\t// Clear and render the shape to the texture\n\tgl.clearColor(0.0, 0.0, 0.0, 0.0); // Transparent background\n\tgl.clear(gl.COLOR_BUFFER_BIT);\n\tgl.drawArrays(gl.TRIANGLES, 0, vertexData.length / 6);\n\n\t// Generate mipmaps\n\tgl.bindTexture(gl.TEXTURE_2D, texture);\n\tgl.generateMipmap(gl.TEXTURE_2D);\n\tgl.bindTexture(gl.TEXTURE_2D, null);\n\n\t// Unbind framebuffer to return to default rendering\n\tgl.bindFramebuffer(gl.FRAMEBUFFER, null);\n\n\t// Return the generated texture\n\treturn texture!;\n}\n\nexport { convertVertexDataToTexture };"
  },
  {
    "path": "dev-utils/shaders/texture/instanced/tint/fragment.glsl",
    "content": "#version 300 es\r\n\r\nprecision highp float;\r\n\r\nin vec2 vTextureCoord;          // From vertex shader\r\nuniform sampler2D u_sampler;     // Texture sampler\r\nuniform vec4 uTintColor;        // Universal tint color\r\n\r\nout vec4 fragColor;             // Output color\r\n\r\nvoid main() {\r\n    // Sample texture with LOD bias and apply universal tint\r\n    vec4 texColor = texture(u_sampler, vTextureCoord, -0.5);\r\n    fragColor = texColor * uTintColor;\r\n}"
  },
  {
    "path": "dev-utils/shaders/texture/instanced/tint/vertex.glsl",
    "content": "#version 300 es\r\n\r\n// This shader is capable of tinting all textures\r\n// a specific color via a uniform\r\n\r\nin vec4 a_position;        // Per-vertex position\r\nin vec2 a_texturecoord;          // Per-vertex texture coordinates\r\nin vec3 a_instanceposition;      // Per-instance position offset\r\n\r\nuniform mat4 u_transformmatrix;  // Transformation matrix\r\n\r\nout vec2 vTextureCoord;         // To fragment shader\r\n\r\nvoid main() {\r\n    // Apply instance position offset\r\n    vec4 offsetPosition = a_position + vec4(a_instanceposition, 0.0);\r\n    \r\n    // Transform position and pass through texture coords\r\n    gl_Position = u_transformmatrix * offsetPosition;\r\n    \r\n    // Pass texture coordinates to fragment shader\r\n    vTextureCoord = a_texturecoord;\r\n}"
  },
  {
    "path": "dev-utils/shaders/texture/tint/fragment.glsl",
    "content": "`#version 300 es\r\n\r\nprecision highp float;\r\n\r\nin vec2 vTextureCoord;\r\n\r\nuniform vec4 uTintColor;\r\nuniform sampler2D u_sampler;\r\n\r\nout vec4 fragColor;\r\n\r\nvoid main(void) {\r\n    fragColor = texture(u_sampler, vTextureCoord, -0.5) * uTintColor; // Apply a mipmap LOD bias so as to make the textures sharper.\r\n}"
  },
  {
    "path": "dev-utils/shaders/texture/tint/vertex.glsl",
    "content": "#version 300 es\r\n\r\n// This shader is capable of tinting all textures\r\n// a specific color via a uniform\r\n\r\nin vec4 a_position;\r\nin vec2 a_texturecoord;\r\n\r\nuniform mat4 u_transformmatrix;\r\n\r\nout vec2 vTextureCoord;\r\n\r\nvoid main(void) {\r\n    gl_Position = u_transformmatrix * a_position;\r\n    vTextureCoord = a_texturecoord;\r\n}"
  },
  {
    "path": "dev-utils/shaders/voronoi/fragment.glsl",
    "content": "#version 300 es\r\nprecision highp float;\r\n\r\n// This shader was replaced by a voronoi_distortion shader\r\n// for the Echo Rift zone effect. I am not 100% sure this\r\n// shader is working as is, nor polished. But if not, its\r\n// arithmetic could be modeled after the voronoi_distortion\r\n// shader to produce results and tileable randomness as desired.\r\n\r\n\r\n// Uniforms for customization\r\nuniform vec2 u_resolution;\r\nuniform float u_time;\r\nuniform float grid_density; // Controls the density of points\r\nuniform float evolution_strength; // 0.0 for static, higher for more movement\r\n// The brightness range for the voronoi effect\r\n// 0.0 = black, 1.0 = original brightness, >1.0 = brighter\r\nuniform float u_min_brightness;\r\nuniform float u_max_brightness;\r\n\r\n// The input texture\r\nuniform sampler2D u_texture;\r\n\r\nin vec2 v_uv;\r\n\r\nout vec4 fragColor;\r\n\r\n// 2D pseudo-random function\r\nvec2 random_2d(vec2 p) {\r\n    return fract(sin(vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)))) * 43758.5453);\r\n}\r\n\r\n// 3D noise function to get displacement values\r\n// It returns a vec2 for x and y displacement\r\nvec2 noise_3d_to_2d(vec3 p) {\r\n    float x = fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453);\r\n    float y = fract(sin(dot(p, vec3(269.5, 183.3, 246.3))) * 43758.5453);\r\n    return vec2(x, y);\r\n}\r\n\r\nvoid main() {\r\n    // --- VORONOI CALCULATION ---\r\n\r\n    // Normalize coordinates and adjust for aspect ratio\r\n    vec2 uv = gl_FragCoord.xy / u_resolution.xy;\r\n    float aspect_ratio = u_resolution.x / u_resolution.y;\r\n    uv.x *= aspect_ratio;\r\n\r\n    // Scale coordinates by density\r\n    vec2 uv_scaled = uv * grid_density;\r\n    \r\n    // Get the integer and fractional parts of the coordinate\r\n    vec2 cell_index = floor(uv_scaled);\r\n    vec2 fractional_coord = fract(uv_scaled);\r\n\r\n    float min_dist = 1.0; // Initialize with a large value\r\n\r\n    // Loop through a 3x3 grid of neighboring cells\r\n    for (int i = -1; i <= 1; i++) {\r\n        for (int j = -1; j <= 1; j++) {\r\n            vec2 neighbor_cell = vec2(float(i), float(j));\r\n            vec2 point_position = cell_index + neighbor_cell;\r\n\r\n            // Generate a random, stable offset for the point in the cell\r\n            vec2 point_offset = random_2d(point_position);\r\n\r\n            // Animate the point using 3D noise\r\n            // The third dimension is time, allowing the noise to evolve\r\n            vec3 noise_input = vec3(point_position, u_time * 0.1);\r\n            \r\n            // Get a displacement vector from the noise function\r\n            // Map noise from [0, 1] to [-1, 1]\r\n            vec2 displacement = (noise_3d_to_2d(noise_input) - 0.5) * 2.0;\r\n            \r\n            // The final animated point position\r\n            vec2 animated_point = neighbor_cell + point_offset + displacement * evolution_strength;\r\n            \r\n            // Calculate distance from the current fragment to the animated point\r\n            float dist = distance(fractional_coord, animated_point);\r\n\r\n            // Keep the minimum distance\r\n            min_dist = min(min_dist, dist);\r\n        }\r\n    }\r\n\r\n    // --- TEXTURE AND BRIGHTNESS MODIFICATION ---\r\n\r\n    // The final color is the distance, clamped to ensure it's between 0 and 1\r\n    // 1. Get the final greyscale voronoi value\r\n    float voronoi_value = smoothstep(0.0, 1.0, min_dist);\r\n\r\n    // 2. Sample the texture using the object's own UVs from the vertex shader\r\n    vec4 texture_color = texture(u_texture, v_uv);\r\n\r\n    // 3. Map the voronoi value to your desired brightness range\r\n    float brightness_factor = mix(u_min_brightness, u_max_brightness, voronoi_value);\r\n\r\n    // 4. Modify the texture color's brightness\r\n    vec3 final_rgb = texture_color.rgb * brightness_factor;\r\n\r\n\r\n        // --- [OPTIONAL] Add Red Glow Near Points ---\r\n        // This block adds a red glow to the darkest areas (pockets).\r\n        // To disable, just comment out this entire block.\r\n\r\n        // 1. Define the glow color and its intensity. You can tweak these values.\r\n        const vec3 glow_color = vec3(1.0, 0.0, 0.0);\r\n        const float glow_intensity = 0.25; // How strong the glow is\r\n\r\n        // 2. Calculate a \"glow factor\" based on the distance to the nearest point.\r\n        //    smoothstep(edge1, edge0, x) creates a smooth inverse falloff.\r\n        //    It's 1.0 when min_dist is at 0.0, and fades to 0.0 as min_dist approaches 0.15.\r\n        float glow_factor = smoothstep(0.15, 0.0, min_dist);\r\n\r\n        // 3. Add the glow to the final color using an additive blend.\r\n        //    The glow is strongest in the pockets and has no effect elsewhere.\r\n        final_rgb += glow_color * glow_intensity * glow_factor;\r\n\r\n\r\n    // 5. Output the final color\r\n    fragColor = vec4(final_rgb, texture_color.a);\r\n}"
  },
  {
    "path": "dev-utils/sounds/SoundscapeGenerator.html",
    "content": "<!-- Interactive Soundscape Generator, made by AI. -->\n<!-- Standalone. Just open the html in your browser. -->\n<!-- This was designed to assist in creating ambiences for Zones -->\n<!-- The output is a config json of the type SoundscapeConfig -->\n\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Ambient Synth & Soundscape Generator</title>\n    <style>\n        :root {\n            --bg-color: #1e1e1e;\n            --text-color: #d4d4d4;\n            --primary-color: #4a90e2;\n            --secondary-color: #252526;\n            --border-color: #333;\n            --accent-color: #569cd6;\n            --success-color: #4CAF50;\n            --danger-color: #f44336;\n            --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;\n        }\n        body { font-family: var(--font-family); background-color: var(--bg-color); color: var(--text-color); margin: 0; padding: 2rem; display: flex; justify-content: center; }\n        .container { width: 100%; max-width: 900px; }\n        h1, h2 { color: var(--primary-color); border-bottom: 2px solid var(--border-color); padding-bottom: 0.5rem; }\n        fieldset { background-color: var(--secondary-color); border: 1px solid var(--border-color); border-radius: 8px; padding: 1.5rem; margin: 0 0 1.5rem 0; }\n        legend { font-weight: bold; font-size: 1.2em; color: var(--accent-color); padding: 0 0.5rem; }\n        .control-group { margin-bottom: 1rem; }\n        .control-group label { display: block; margin-bottom: 0.5rem; font-weight: bold; }\n        .control-group .value-display { font-family: monospace; color: var(--accent-color); margin-left: 1rem; }\n        input[type=\"range\"] { -webkit-appearance: none; width: 100%; height: 8px; border-radius: 4px; background: var(--border-color); outline: none; padding: 0; margin: 0; transition: opacity 0.2s; }\n        input[type=\"range\"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 20px; height: 20px; border-radius: 50%; background: var(--primary-color); cursor: pointer; }\n        input[type=\"range\"]::-moz-range-thumb { width: 20px; height: 20px; border-radius: 50%; background: var(--primary-color); cursor: pointer; }\n        input[type=\"range\"]:disabled { opacity: 0.4; }\n        input[type=\"range\"]:disabled::-webkit-slider-thumb, input[type=\"range\"]:disabled::-moz-range-thumb { background: #666; cursor: not-allowed; }\n        input, select, button, textarea { background-color: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color); border-radius: 4px; padding: 0.5rem; font-size: 1em; box-sizing: border-box; }\n        button { background-color: var(--primary-color); color: white; border: none; padding: 0.75rem 1.5rem; font-weight: bold; cursor: pointer; transition: background-color 0.2s; }\n        button:hover { background-color: var(--accent-color); }\n        .btn-add { width: 100%; margin-top: 1rem; background-color: #3a3d41;}\n        .btn-add:hover { background-color: #4a4d51;}\n        .btn-secondary { background-color: #3a3d41; }\n        .btn-secondary:hover { background-color: #4a4d51;}\n        .btn-copy.success { background-color: var(--success-color); }\n        .play-button-container { text-align: center; margin-bottom: 2rem; }\n        #play-pause-btn { width: 120px; height: 120px; font-size: 1.5rem; border-radius: 50%; }\n        pre { background-color: #111; border: 1px solid var(--border-color); border-radius: 4px; padding: 1rem; white-space: pre-wrap; word-break: break-all; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; margin-top: 1rem; }\n        .layer { border: 1px solid var(--border-color); border-radius: 8px; margin-bottom: 1.5rem; background-color: var(--secondary-color); }\n        .layer > summary { font-size: 1.3em; font-weight: bold; padding: 1rem 1.5rem; cursor: pointer; position: relative; list-style: none; color: var(--accent-color); }\n        .layer > summary::-webkit-details-marker { display: none; }\n        .layer-controls { padding: 0 1.5rem 1.5rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem; border-top: 1px dashed var(--border-color); padding-top: 1.5rem; }\n        .filters-container { padding: 0 1.5rem 1.5rem; }\n        .remove-btn { position: absolute; top: 50%; transform: translateY(-50%); right: 1.5rem; background-color: transparent; color: var(--danger-color); border: 1px solid var(--danger-color); border-radius: 4px; width: auto; height: auto; font-weight: bold; font-size: 0.8em; line-height: 1; padding: 0.4rem 0.6rem; }\n        .remove-btn:hover { background-color: var(--danger-color); color: white; }\n        .filter-block { position: relative; margin-bottom: 1.5rem; padding: 1rem 1.5rem 0.5rem; border: 1px dashed var(--border-color); border-radius: 8px; }\n        .filter-block:last-child { margin-bottom: 0; }\n        .lfo-group { border-left: 3px solid var(--accent-color); padding-left: 1rem; margin-top: 0.5rem; }\n        .lfo-group label { font-size: 0.9em; opacity: 0.8; }\n        .lfo-toggle { display: flex; align-items: center; gap: 0.5rem; }\n        .source-controls { padding: 0 1.5rem 1.5rem; }\n        .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.6); }\n        .modal-content { background-color: var(--secondary-color); margin: 15% auto; padding: 2rem; border: 1px solid var(--border-color); border-radius: 8px; width: 80%; max-width: 600px; }\n        .modal-content textarea { width: 100%; height: 150px; resize: vertical; margin-bottom: 1rem; font-family: monospace; }\n        .modal-actions { display: flex; gap: 1rem; justify-content: flex-end; }\n    </style>\n</head>\n<body>\n\n    <div class=\"container\">\n        <h1>Ambient Synth & Soundscape Generator</h1>\n        <div class=\"play-button-container\"><button id=\"play-pause-btn\">Play</button></div>\n        <div style=\"display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem;\">\n            <fieldset>\n                <legend>Global Settings</legend>\n                <div class=\"control-group\">\n                    <label for=\"master-volume\">Master Volume <span class=\"value-display\" id=\"master-volume-value\">0.25</span></label>\n                    <input type=\"range\" id=\"master-volume\" min=\"0\" max=\"1\" step=\"0.01\" value=\"0.25\">\n                </div>\n            </fieldset>\n            <fieldset>\n                <legend>Config Actions</legend>\n                <div style=\"display: flex; gap: 1rem;\">\n                    <button class=\"btn-copy\" id=\"copy-config-btn\" style=\"flex: 1;\">Copy Config</button>\n                    <button id=\"load-config-btn\" class=\"btn-secondary\" style=\"flex: 1;\">Load Config</button>\n                </div>\n            </fieldset>\n        </div>\n        <pre id=\"config-output\">{}</pre>\n        <h2>Layers</h2>\n        <div id=\"layers-container\"></div>\n        <button class=\"btn-add\" id=\"add-layer-btn\">Add Sound Layer</button>\n    </div>\n\n    <div id=\"load-config-modal\" class=\"modal\">\n        <div class=\"modal-content\">\n            <h2>Load Configuration</h2>\n            <p>Paste a previously copied configuration object into the text area below.</p>\n            <textarea id=\"config-paste-area\" placeholder=\"{ masterVolume: ..., layers: [ ... ] }\"></textarea>\n            <div class=\"modal-actions\">\n                <button id=\"paste-from-clipboard-btn\" class=\"btn-secondary\">Paste & Load from Clipboard</button>\n                <button id=\"load-from-textarea-btn\">Load</button>\n                <button id=\"cancel-load-btn\" class=\"btn-secondary\">Cancel</button>\n            </div>\n        </div>\n    </div>\n\n    <script type=\"module\">\n        const SimplexNoise = (() => { const F2 = 0.5 * (Math.sqrt(3.0) - 1.0), G2 = (3.0 - Math.sqrt(3.0)) / 6.0; const grad3 = [[1, 1, 0], [-1, 1, 0], [1, -1, 0], [-1, -1, 0], [1, 0, 1], [-1, 0, 1], [1, 0, -1], [-1, 0, -1], [0, 1, 1], [0, -1, 1], [0, 1, -1], [0, -1, -1]]; const p = [151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69, 142, 8, 99, 37, 240, 21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177, 33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83, 111, 229, 122, 60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25, 63, 161, 1, 216, 80, 73, 209, 76, 132, 187, 208, 89, 18, 169, 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186, 3, 64, 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206, 59, 227, 47, 16, 58, 17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9, 129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218, 246, 97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241, 81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157, 184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128, 195, 78, 66, 215, 61, 156, 180]; const perm = new Array(512); for (let i = 0; i < 512; i++) perm[i] = p[i & 255]; const dot = (g, x, y) => g[0] * x + g[1] * y; return { noise: function (xin, yin) { let n0, n1, n2; const s = (xin + yin) * F2; const i = Math.floor(xin + s), j = Math.floor(yin + s); const t = (i + j) * G2; const X0 = i - t, Y0 = j - t; const x0 = xin - X0, y0 = yin - Y0; let i1, j1; if (x0 > y0) { i1 = 1; j1 = 0; } else { i1 = 0; j1 = 1; } const x1 = x0 - i1 + G2, y1 = y0 - j1 + G2; const x2 = x0 - 1.0 + 2.0 * G2, y2 = y0 - 1.0 + 2.0 * G2; const ii = i & 255, jj = j & 255; const gi0 = perm[ii + perm[jj]] % 12, gi1 = perm[ii + i1 + perm[jj + j1]] % 12, gi2 = perm[ii + 1 + perm[jj + 1]] % 12; let t0 = 0.5 - x0 * x0 - y0 * y0; if (t0 < 0) n0 = 0.0; else { t0 *= t0; n0 = t0 * t0 * dot(grad3[gi0], x0, y0); } let t1 = 0.5 - x1 * x1 - y1 * y1; if (t1 < 0) n1 = 0.0; else { t1 *= t1; n1 = t1 * t1 * dot(grad3[gi1], x1, y1); } let t2 = 0.5 - x2 * x2 - y2 * y2; if (t2 < 0) n2 = 0.0; else { t2 *= t2; n2 = t2 * t2 * dot(grad3[gi2], x2, y2); } return 70.0 * (n0 + n1 + n2); } }; })();\n        \n        const OSC_WAVEFORMS = ['sine', 'square', 'sawtooth', 'triangle'];\n        const LFO_WAVEFORMS = ['sine', 'square', 'sawtooth', 'triangle', 'perlin'];\n        const FILTER_TYPES = [\"lowpass\", \"highpass\", \"bandpass\", \"lowshelf\", \"highshelf\", \"peaking\", \"notch\", \"allpass\"];\n        const TYPES_WITH_Q = [\"lowpass\", \"highpass\", \"bandpass\", \"peaking\", \"notch\", \"allpass\"];\n        const TYPES_WITH_GAIN = [\"lowshelf\", \"highshelf\", \"peaking\"];\n        \n        let audioContext, masterGain, noiseSource, perlinNoiseBuffer = null, isPlaying = false;\n        let audioGraph = { layers: {} };\n        const dom = { playPauseBtn: document.getElementById('play-pause-btn'), masterVolume: document.getElementById('master-volume'), layersContainer: document.getElementById('layers-container'), addLayerBtn: document.getElementById('add-layer-btn'), copyConfigBtn: document.getElementById('copy-config-btn'), loadConfigBtn: document.getElementById('load-config-btn'), configOutput: document.getElementById('config-output'), loadConfigModal: document.getElementById('load-config-modal'), configPasteArea: document.getElementById('config-paste-area'), pasteFromClipboardBtn: document.getElementById('paste-from-clipboard-btn'), loadFromTextareaBtn: document.getElementById('load-from-textarea-btn'), cancelLoadBtn: document.getElementById('cancel-load-btn') };\n\n        async function initAudioContext() { if (audioContext) return; audioContext = new (window.AudioContext || window.webkitAudioContext)(); masterGain = audioContext.createGain(); masterGain.connect(audioContext.destination); updateMasterVolume(); const bufferSize = 10 * audioContext.sampleRate; const noiseBuffer = audioContext.createBuffer(2, bufferSize, audioContext.sampleRate); for (let c = 0; c < 2; c++) { const output = noiseBuffer.getChannelData(c); for (let i = 0; i < bufferSize; i++) output[i] = Math.random() * 2 - 1; } noiseSource = audioContext.createBufferSource(); noiseSource.buffer = noiseBuffer; noiseSource.loop = true; }\n        function updateMasterVolume() { const value = parseFloat(dom.masterVolume.value); document.getElementById('master-volume-value').textContent = value.toFixed(2); if (masterGain) masterGain.gain.setTargetAtTime(value, audioContext.currentTime, 0.01); }\n        function destroyAudioGraph() { Object.values(audioGraph.layers).forEach(layer => { layer.gain.disconnect(); if (layer.sourceNode && layer.sourceNode.stop) layer.sourceNode.stop(); if (layer.volumeLfo && layer.volumeLfo.source) layer.volumeLfo.source.stop(); Object.values(layer.oscLfos || {}).forEach(lfo => { if (lfo.source) lfo.source.stop(); }); Object.values(layer.filters).forEach(filter => { Object.values(filter.lfos).forEach(lfo => { if (lfo.source) lfo.source.stop(); }); }); }); audioGraph = { layers: {} }; }\n        function createPerlinLFO(rate) { if (!perlinNoiseBuffer) { const duration = 30, sampleCount = audioContext.sampleRate * duration; perlinNoiseBuffer = audioContext.createBuffer(1, sampleCount, audioContext.sampleRate); const data = perlinNoiseBuffer.getChannelData(0); for (let i = 0; i < sampleCount; i++) data[i] = SimplexNoise.noise(i / 40000, 0); } const lfoSource = audioContext.createBufferSource(); lfoSource.buffer = perlinNoiseBuffer; lfoSource.loop = true; lfoSource.playbackRate.value = rate; lfoSource.start(); return lfoSource; }\n        function createLFO(lfoState) { const lfoGain = audioContext.createGain(); lfoGain.gain.value = lfoState.depth; let lfoSource; if (lfoState.wave === 'perlin') { lfoSource = createPerlinLFO(lfoState.rate); } else { lfoSource = audioContext.createOscillator(); lfoSource.type = lfoState.wave; lfoSource.frequency.value = lfoState.rate; lfoSource.start(); } return { source: lfoSource, gain: lfoGain }; }\n        function buildAudioGraph() { if (!audioContext || !noiseSource || !isPlaying) return; destroyAudioGraph(); dom.layersContainer.querySelectorAll('.layer').forEach(layerEl => { const layerId = layerEl.id; const layerState = readLayerState(layerEl); const layerGraph = { gain: audioContext.createGain(), filters: {}, sourceNode: null, oscLfos: {}, volumeLfo: null }; layerGraph.gain.gain.value = layerState.volume.base; if (layerState.volume.lfo) { const lfo = createLFO(layerState.volume.lfo); lfo.source.connect(lfo.gain).connect(layerGraph.gain.gain); layerGraph.volumeLfo = lfo; } let currentNode; if (layerState.source.type === 'noise') { currentNode = noiseSource; } else { const osc = audioContext.createOscillator(); osc.type = layerState.source.wave; osc.frequency.value = layerState.source.freq.base; osc.detune.value = layerState.source.detune.base; layerGraph.sourceNode = osc; ['freq', 'detune'].forEach(param => { const paramKey = param === 'freq' ? 'frequency' : param; const paramState = layerState.source[param]; if (paramState.lfo) { const lfo = createLFO(paramState.lfo); lfo.source.connect(lfo.gain).connect(osc[paramKey]); layerGraph.oscLfos[paramKey] = lfo; } }); osc.start(); currentNode = osc; } layerEl.querySelectorAll('.filter-block').forEach(filterEl => { const filterId = filterEl.id; const filterState = readFilterState(filterEl); const filterNode = audioContext.createBiquadFilter(); filterNode.type = filterState.type; filterNode.frequency.value = filterState.frequency.base; filterNode.Q.value = filterState.Q.base; filterNode.gain.value = filterState.gain.base; const filterGraph = { node: filterNode, lfos: {} }; ['frequency', 'Q', 'gain'].forEach(param => { const paramState = filterState[param]; if (paramState.lfo) { const lfo = createLFO(paramState.lfo); lfo.source.connect(lfo.gain).connect(filterNode[param]); filterGraph.lfos[param] = lfo; } }); currentNode.connect(filterNode); currentNode = filterNode; layerGraph.filters[filterId] = filterGraph; }); currentNode.connect(layerGraph.gain).connect(masterGain); audioGraph.layers[layerId] = layerGraph; }); updateConfigOutput(); }\n        function getDescendantProp(obj, desc) { const arr = desc.split('.'); while(arr.length && obj) obj = obj[arr.shift()]; return obj; }\n        function updateAudioParameter(path, value) { if (!isPlaying || !audioContext) return; const finalDotIndex = path.lastIndexOf('.'); const parentPath = path.substring(0, finalDotIndex), propName = path.substring(finalDotIndex + 1); const parentObject = getDescendantProp(audioGraph, parentPath); if (parentObject && typeof parentObject[propName] !== 'undefined') { const target = parentObject[propName]; if (target instanceof AudioParam) target.setTargetAtTime(value, audioContext.currentTime, 0.02); else parentObject[propName] = value; } }\n        function readLayerState(layerEl) { const sourceType = layerEl.querySelector(`[name=\"source-type-${layerEl.id}\"]:checked`).value; const sourceConfig = { type: sourceType }; if (sourceType === 'oscillator') { sourceConfig.wave = layerEl.querySelector('[data-prop=\"osc-wave\"]')?.value; sourceConfig.freq = readModulatedParam(layerEl.querySelector('.osc-freq-controls')); sourceConfig.detune = readModulatedParam(layerEl.querySelector('.osc-detune-controls')); } return { volume: readModulatedParam(layerEl.querySelector('.volume-controls')), source: sourceConfig, }; }\n        function readFilterState(filterEl) { return { type: filterEl.querySelector('[data-prop=\"type\"]').value, frequency: readModulatedParam(filterEl.querySelector('.frequency-controls')), Q: readModulatedParam(filterEl.querySelector('.q-controls')), gain: readModulatedParam(filterEl.querySelector('.gain-controls')) }; }\n        function readModulatedParam(container) { const result = { base: parseFloat(container.querySelector('[data-prop=\"base\"]').value) }; const lfoEnabled = container.querySelector('[data-lfo-prop=\"enabled\"]')?.checked ?? false; if (lfoEnabled) { result.lfo = { wave: container.querySelector('[data-lfo-prop=\"wave\"]')?.value ?? 'sine', rate: parseFloat(container.querySelector('[data-lfo-prop=\"rate\"]')?.value ?? 0), depth: parseFloat(container.querySelector('[data-lfo-prop=\"depth\"]')?.value ?? 0) }; } return result; }\n        function readFullConfig() { const config = { masterVolume: parseFloat(dom.masterVolume.value), layers: [] }; dom.layersContainer.querySelectorAll('.layer').forEach(layerEl => { const layerConfig = readLayerState(layerEl); layerConfig.filters = []; layerEl.querySelectorAll('.filter-block').forEach(filterEl => layerConfig.filters.push(readFilterState(filterEl))); config.layers.push(layerConfig); }); return config; }\n        function updateConfigOutput() { const config = readFullConfig(); const jsonString = JSON.stringify(config, (k, v) => typeof v === 'number' ? parseFloat(v.toFixed(4)) : v, 2); dom.configOutput.textContent = jsonString.replace(/\"([^\"]+)\":/g, '$1:'); }\n        function createModulationControls(rate, depth) { const id = `lfo-enabled-${Math.random().toString(36).substr(2, 9)}`; return `<div class=\"lfo-group\"><div class=\"lfo-toggle\"><input type=\"checkbox\" data-lfo-prop=\"enabled\" id=\"${id}\"><label for=\"${id}\">Enable LFO</label></div><div class=\"lfo-params\" style=\"display: none;\"><label>Wave</label><select data-lfo-prop=\"wave\">${LFO_WAVEFORMS.map(w => `<option value=\"${w}\">${w}</option>`).join('')}</select><label>Rate <span class=\"value-display\">${rate.val.toFixed(2)}</span></label><input type=\"range\" min=\"${rate.min}\" max=\"${rate.max}\" step=\"${rate.step}\" value=\"${rate.val}\" data-lfo-prop=\"rate\"><label>Depth <span class=\"value-display\">${depth.val.toFixed(3)}</span></label><input type=\"range\" min=\"${depth.min}\" max=\"${depth.max}\" step=\"${depth.step}\" value=\"${depth.val}\" data-lfo-prop=\"depth\"></div></div>`; }\n        function createFilterBlock() { const block = document.createElement('div'); block.className = 'filter-block'; block.id = `filter-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`; block.innerHTML = `<button class=\"remove-btn\" title=\"Remove Filter\" style=\"top: 1rem; right: 1rem;\">&times;</button><div class=\"control-group\"><label>Type</label><select data-prop=\"type\">${FILTER_TYPES.map(t=>`<option value=\"${t}\" ${t==='lowpass'?'selected':''}>${t}</option>`).join('')}</select></div><div class=\"control-group frequency-controls\"><label>Frequency <span class=\"value-display\">1000 Hz</span></label><input type=\"range\" min=\"20\" max=\"20000\" step=\"1\" value=\"1000\" data-prop=\"base\">${createModulationControls({min:0.01,max:20,step:0.01,val:0.5},{min:0,max:5000,step:1,val:400})}</div><div class=\"control-group q-controls\"><label>Q <span class=\"value-display\">1</span></label><input type=\"range\" min=\"0.0001\" max=\"30\" step=\"0.01\" value=\"1\" data-prop=\"base\">${createModulationControls({min:0.01,max:10,step:0.01,val:0.3},{min:0,max:15,step:0.001,val:4})}</div><div class=\"control-group gain-controls\"><label>Gain <span class=\"value-display\">0 dB</span></label><input type=\"range\" min=\"-40\" max=\"40\" step=\"0.1\" value=\"0\" data-prop=\"base\" disabled>${createModulationControls({min:0.01,max:5,step:0.01,val:0.2},{min:0,max:20,step:0.01,val:10})}</div>`; return block; }\n        function createLayerBlock() {\n            const layerId = `layer-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;\n            const layer = document.createElement('details'); layer.className = 'layer'; layer.id = layerId; layer.open = true;\n            layer.innerHTML = `<summary>Sound Layer<button class=\"remove-btn\" title=\"Remove Layer\">Remove Layer</button></summary><div class=\"source-controls\"><fieldset><legend>Source</legend><div style=\"display: flex; gap: 2rem; margin-bottom: 1rem;\"><label><input type=\"radio\" name=\"source-type-${layerId}\" value=\"noise\" checked> Noise</label><label><input type=\"radio\" name=\"source-type-${layerId}\" value=\"oscillator\"> Oscillator</label></div><div class=\"osc-params\" style=\"display: none;\"><div class=\"control-group\"><label>Waveform</label><select data-prop=\"osc-wave\">${OSC_WAVEFORMS.map(w => `<option value=\"${w}\">${w}</option>`).join('')}</select></div><div class=\"control-group osc-freq-controls\"><label>Frequency <span class=\"value-display\">440 Hz</span></label><input type=\"range\" min=\"20\" max=\"2000\" step=\"1\" value=\"440\" data-prop=\"base\">${createModulationControls({min:0.01,max:20,step:0.01,val:5},{min:0,max:100,step:1,val:10})}</div><div class=\"control-group osc-detune-controls\"><label>Detune (Cents) <span class=\"value-display\">0</span></label><input type=\"range\" min=\"-1200\" max=\"1200\" step=\"1\" value=\"0\" data-prop=\"base\">${createModulationControls({min:0.01,max:10,step:0.01,val:1},{min:0,max:100,step:1,val:25})}</div></div></fieldset></div><div class=\"layer-controls\"><div class=\"control-group volume-controls\"><label>Volume <span class=\"value-display\">0.500</span></label><input type=\"range\" min=\"0\" max=\"1\" step=\"0.001\" value=\"0.5\" data-prop=\"base\">${createModulationControls({min:0.01,max:10,step:0.01,val:0.2},{min:0,max:0.5,step:0.001,val:0.25})}</div></div><div class=\"filters-container\"></div><div style=\"padding: 0 1.5rem 1.5rem;\"><button class=\"btn-add btn-add-filter\">Add Filter</button></div>`;\n            return layer;\n        }\n        \n        function attachLayerEventListeners(layerEl) { const layerId = layerEl.id; layerEl.querySelector('.remove-btn').addEventListener('click', e => { e.preventDefault(); layerEl.remove(); rebuildGraphAndConfig(); }); layerEl.querySelector('.btn-add-filter').addEventListener('click', () => { const filterContainer = layerEl.querySelector('.filters-container'), newFilterBlock = createFilterBlock(); filterContainer.appendChild(newFilterBlock); attachFilterEventListeners(newFilterBlock); rebuildGraphAndConfig(); }); const oscParamsDiv = layerEl.querySelector('.osc-params'); layerEl.querySelectorAll(`[name=\"source-type-${layerId}\"]`).forEach(radio => { radio.addEventListener('change', () => { oscParamsDiv.style.display = radio.value === 'oscillator' ? 'block' : 'none'; rebuildGraphAndConfig(); }); }); layerEl.querySelector('[data-prop=\"osc-wave\"]').addEventListener('change', e => { updateAudioParameter(`layers.${layerId}.sourceNode.type`, e.target.value); updateConfigOutput(); }); const basePath = `layers.${layerId}`; attachModParamListeners(layerEl.querySelector('.volume-controls'), basePath, v => v.toFixed(3), { base: 'gain.gain.value', rate: 'volumeLfo.source', depth: 'volumeLfo.gain.gain.value' }); attachModParamListeners(layerEl.querySelector('.osc-freq-controls'), basePath, v => `${Math.round(v)} Hz`, { base: 'sourceNode.frequency.value', rate: 'oscLfos.frequency.source', depth: 'oscLfos.frequency.gain.gain.value' }); attachModParamListeners(layerEl.querySelector('.osc-detune-controls'), basePath, v => v, { base: 'sourceNode.detune.value', rate: 'oscLfos.detune.source', depth: 'oscLfos.detune.gain.gain.value' }); }\n        function attachFilterEventListeners(filterEl) { const layerId = filterEl.closest('.layer').id; filterEl.querySelector('.remove-btn').addEventListener('click', () => { filterEl.remove(); rebuildGraphAndConfig(); }); const typeSelect = filterEl.querySelector('[data-prop=\"type\"]'); typeSelect.addEventListener('change', () => { filterEl.querySelector('.q-controls [data-prop=\"base\"]').disabled = !TYPES_WITH_Q.includes(typeSelect.value); filterEl.querySelector('.gain-controls [data-prop=\"base\"]').disabled = !TYPES_WITH_GAIN.includes(typeSelect.value); rebuildGraphAndConfig(); }); const basePath = `layers.${layerId}.filters.${filterEl.id}`; attachModParamListeners(filterEl.querySelector('.frequency-controls'), basePath, v => `${Math.round(v)} Hz`, { base: 'node.frequency.value', rate: 'lfos.frequency.source', depth: 'lfos.frequency.gain.gain.value' }); attachModParamListeners(filterEl.querySelector('.q-controls'), basePath, v => v.toFixed(3), { base: 'node.Q.value', rate: 'lfos.Q.source', depth: 'lfos.Q.gain.gain.value' }); attachModParamListeners(filterEl.querySelector('.gain-controls'), basePath, v => v.toFixed(1), { base: 'node.gain.value', rate: 'lfos.gain.source', depth: 'lfos.gain.gain.value' }); }\n        function attachModParamListeners(container, basePath, displayFormatter, pathMap) { container.querySelector('[data-prop=\"base\"]').addEventListener('input', e => { const value = parseFloat(e.target.value); e.target.previousElementSibling.querySelector('.value-display').textContent = displayFormatter(value); updateAudioParameter(`${basePath}.${pathMap.base}`, value); updateConfigOutput(); }); const lfoToggle = container.querySelector('[data-lfo-prop=\"enabled\"]'), lfoParams = container.querySelector('.lfo-params'); lfoToggle.addEventListener('change', () => { lfoParams.style.display = lfoToggle.checked ? 'block' : 'none'; rebuildGraphAndConfig(); }); const waveSelect = lfoParams.querySelector('[data-lfo-prop=\"wave\"]'), rateSlider = lfoParams.querySelector('[data-lfo-prop=\"rate\"]'); waveSelect.addEventListener('change', () => { const isPerlin = waveSelect.value === 'perlin'; rateSlider.min = isPerlin ? 0.01 : 0.1; rateSlider.max = isPerlin ? 5 : 20; rateSlider.step = isPerlin ? 0.01 : 0.1; rebuildGraphAndConfig(); }); rateSlider.addEventListener('input', e => { const value = parseFloat(e.target.value); const isPerlin = waveSelect.value === 'perlin'; e.target.previousElementSibling.querySelector('.value-display').textContent = `${value.toFixed(2)}${isPerlin ? 'x' : ' Hz'}`; const rateProp = isPerlin ? 'playbackRate.value' : 'frequency.value'; updateAudioParameter(`${basePath}.${pathMap.rate}.${rateProp}`, value); updateConfigOutput(); }); lfoParams.querySelector('[data-lfo-prop=\"depth\"]').addEventListener('input', e => { const value = parseFloat(e.target.value); e.target.previousElementSibling.querySelector('.value-display').textContent = value.toFixed(3); updateAudioParameter(`${basePath}.${pathMap.depth}`, value); updateConfigOutput(); }); }\n        function rebuildGraphAndConfig() { if (isPlaying) buildAudioGraph(); updateConfigOutput(); }\n        function populateModulatedParam(container, config) { if (!config) return; const baseSlider = container.querySelector('[data-prop=\"base\"]'); baseSlider.value = config.base; baseSlider.dispatchEvent(new Event('input')); if (config.lfo) { const lfoToggle = container.querySelector('[data-lfo-prop=\"enabled\"]'); lfoToggle.checked = true; const lfoParams = container.querySelector('.lfo-params'); lfoParams.style.display = 'block'; const waveSelect = lfoParams.querySelector('[data-lfo-prop=\"wave\"]'); waveSelect.value = config.lfo.wave; waveSelect.dispatchEvent(new Event('change')); const rateSlider = lfoParams.querySelector('[data-lfo-prop=\"rate\"]'); rateSlider.value = config.lfo.rate; rateSlider.dispatchEvent(new Event('input')); const depthSlider = lfoParams.querySelector('[data-lfo-prop=\"depth\"]'); depthSlider.value = config.lfo.depth; depthSlider.dispatchEvent(new Event('input')); } }\n        function loadConfig(configString) { let config; try { config = new Function(`return (${configString})`)(); if (typeof config.masterVolume !== 'number' || !Array.isArray(config.layers)) throw new Error(\"Invalid config structure.\"); } catch (e) { alert(`Error parsing configuration:\\n${e.message}`); return; } dom.layersContainer.innerHTML = ''; dom.masterVolume.value = config.masterVolume; dom.masterVolume.dispatchEvent(new Event('input')); config.layers.forEach(layerConfig => { const layerBlock = createLayerBlock(); populateModulatedParam(layerBlock.querySelector('.volume-controls'), layerConfig.volume); const sourceTypeRadio = layerBlock.querySelector(`[name^=\"source-type-\"][value=\"${layerConfig.source.type}\"]`); if(sourceTypeRadio) sourceTypeRadio.checked = true; layerBlock.querySelector('.osc-params').style.display = layerConfig.source.type === 'oscillator' ? 'block' : 'none'; if (layerConfig.source.type === 'oscillator') { layerBlock.querySelector('[data-prop=\"osc-wave\"]').value = layerConfig.source.wave; populateModulatedParam(layerBlock.querySelector('.osc-freq-controls'), layerConfig.source.freq); populateModulatedParam(layerBlock.querySelector('.osc-detune-controls'), layerConfig.source.detune); } const filterContainer = layerBlock.querySelector('.filters-container'); if(layerConfig.filters) { layerConfig.filters.forEach(filterConfig => { const filterBlock = createFilterBlock(); const typeSelect = filterBlock.querySelector('[data-prop=\"type\"]'); typeSelect.value = filterConfig.type; populateModulatedParam(filterBlock.querySelector('.frequency-controls'), filterConfig.frequency); populateModulatedParam(filterBlock.querySelector('.q-controls'), filterConfig.Q); populateModulatedParam(filterBlock.querySelector('.gain-controls'), filterConfig.gain); filterContainer.appendChild(filterBlock); attachFilterEventListeners(filterBlock); }); } dom.layersContainer.appendChild(layerBlock); attachLayerEventListeners(layerBlock); }); rebuildGraphAndConfig(); }\n        function togglePlayState() { initAudioContext().then(() => { if (audioContext.state === 'suspended') audioContext.resume(); isPlaying = !isPlaying; if (isPlaying) { dom.playPauseBtn.textContent = 'Pause'; noiseSource.start(0); rebuildGraphAndConfig(); } else { dom.playPauseBtn.textContent = 'Play'; destroyAudioGraph(); const oldBuffer = noiseSource.buffer; noiseSource.disconnect(); noiseSource = audioContext.createBufferSource(); noiseSource.buffer = oldBuffer; noiseSource.loop = true; } }); }\n        dom.playPauseBtn.addEventListener('click', togglePlayState);\n        dom.masterVolume.addEventListener('input', updateMasterVolume);\n        dom.addLayerBtn.addEventListener('click', () => { const newLayerBlock = createLayerBlock(); dom.layersContainer.appendChild(newLayerBlock); attachLayerEventListeners(newLayerBlock); const filterContainer = newLayerBlock.querySelector('.filters-container'); const newFilterBlock = createFilterBlock(); filterContainer.appendChild(newFilterBlock); attachFilterEventListeners(newFilterBlock); rebuildGraphAndConfig(); });\n        dom.copyConfigBtn.addEventListener('click', () => navigator.clipboard.writeText(dom.configOutput.textContent).then(() => { dom.copyConfigBtn.textContent = 'Copied!'; dom.copyConfigBtn.classList.add('success'); setTimeout(() => { dom.copyConfigBtn.textContent = 'Copy Config'; dom.copyConfigBtn.classList.remove('success'); }, 1500); }));\n        dom.loadConfigBtn.addEventListener('click', () => { dom.loadConfigModal.style.display = 'block'; });\n        dom.cancelLoadBtn.addEventListener('click', () => { dom.loadConfigModal.style.display = 'none'; });\n        dom.loadFromTextareaBtn.addEventListener('click', () => { loadConfig(dom.configPasteArea.value); dom.loadConfigModal.style.display = 'none'; });\n        dom.pasteFromClipboardBtn.addEventListener('click', () => { if (navigator.clipboard && navigator.clipboard.readText) { navigator.clipboard.readText().then(text => { dom.configPasteArea.value = text; loadConfig(text); dom.loadConfigModal.style.display = 'none'; }).catch(err => alert('Failed to read clipboard contents: ' + err)); } else { alert('Clipboard API not available in this browser.'); } });\n\n        dom.addLayerBtn.click();\n    </script>\n</body>\n</html>"
  },
  {
    "path": "dev-utils/spritesheet_generator/spritesheet.ts",
    "content": "// dev-utils/spritesheet_generator/spritesheet.ts\n\n/**\n * This script stores the spritesheet FOR THE CURRENT GAME,\n * and all the piece's texture coordinates within it.\n *\n * If no game is loaded, no spritesheet is loaded.\n */\n\nimport type { Board } from '../../../../../shared/chess/logic/gamefile.js';\nimport type { DoubleCoords } from '../../../../../shared/chess/util/coordutil.js';\n\nimport typeutil from '../../../../../shared/chess/util/typeutil.js';\nimport imagecache from '../../chess/rendering/imagecache.js';\nimport { GameBus } from '../GameBus.js';\nimport TextureLoader from '../../webgl/TextureLoader.js';\nimport { generateSpritesheet } from '../../chess/rendering/spritesheetGenerator.js';\n\n// Types --------------------------------------------------------------------------------\n\n/** A bounding box storing texture coords info. */\ninterface TextureData {\n\ttexleft: number;\n\ttexbottom: number;\n\ttexright: number;\n\ttextop: number;\n}\n\n// Variables ---------------------------------------------------------------------------\n\n/**\n * The spritesheet texture for rendering the pieces of the current game.\n *\n * Using a spritesheet instead of 1 texture for each piece allows us to\n * render all the pieces with a single mesh, and a single texture.\n */\nlet spritesheet: WebGLTexture | undefined; // Texture. Grid containing every texture of every piece, black and white.\n/**\n * Contains where each piece is located in the spritesheet (texture coord).\n * Texture coords of a piece range from 0-1, where (0,0) is the bottom-left corner.\n */\nlet spritesheetData:\n\t| {\n\t\t\t/** The width of each texture in the whole spritesheet, as a fraction. */\n\t\t\tpieceWidth: number;\n\t\t\t/**\n\t\t\t * The texture locations of each piece type in the spritesheet,\n\t\t\t * where (0,0) is the bottom-left corner of the spritesheet,\n\t\t\t * and the coordinates provided are the bottom-left corner of the corresponding type.\n\t\t\t */\n\t\t\ttexLocs: { [type: number]: DoubleCoords };\n\t  }\n\t| undefined;\n\n// Events ---------------------------------------------------------------------------\n\nGameBus.addEventListener('game-unloaded', () => {\n\tdeleteSpritesheet();\n});\n\n// Functions ---------------------------------------------------------------------------\n\nfunction getSpritesheet(): WebGLTexture {\n\tif (!spritesheet) throw new Error('Should not be getting the spritesheet when not loaded!');\n\treturn spritesheet;\n}\n\nfunction getSpritesheetDataPieceWidth(): number {\n\tif (!spritesheetData)\n\t\tthrow new Error('Should not be getting piece width when the spritesheet is not loaded!');\n\treturn spritesheetData.pieceWidth;\n}\n\nfunction getSpritesheetDataTexLocation(type: number): DoubleCoords {\n\tif (!spritesheetData)\n\t\tthrow new Error(\n\t\t\t'Should not be getting texture locations when the spritesheet is not loaded!',\n\t\t);\n\tif (!spritesheetData!.texLocs[type])\n\t\tthrow new Error('No texture location for piece type: ' + type);\n\treturn spritesheetData!.texLocs[type];\n}\n\n/** Loads the spritesheet texture we'll be using to render the provided gamefile's pieces */\nasync function initSpritesheetForGame(gl: WebGL2RenderingContext, boardsim: Board): Promise<void> {\n\t// Filter our voids from all types in the game.\n\tconst types: number[] = boardsim.existingTypes.filter(\n\t\t(type) => !typeutil.SVGLESS_TYPES.has(typeutil.getRawType(type)),\n\t);\n\n\t// Convert each SVG element to an Image\n\tconst readyImages: HTMLImageElement[] = types.map((t) => imagecache.getPieceImage(t));\n\n\tconst spritesheetAndSpritesheetData = await generateSpritesheet(gl, readyImages);\n\t// console.log(spritesheetAndSpritesheetData.spritesheetData);\n\n\t// Optional: Append the spritesheet to the document for debugging\n\t// spritesheetAndSpritesheetData.spritesheet.style.display = 'none';\n\t// document.body.appendChild(spritesheetAndSpritesheetData.spritesheet);\n\n\t// Load the texture into webgl and initiate our spritesheet\n\t// data that contains the texture coordinates of each piece!\n\tspritesheet = TextureLoader.loadTexture(gl, spritesheetAndSpritesheetData.spritesheet, {\n\t\tmipmaps: true,\n\t});\n\tspritesheetData = spritesheetAndSpritesheetData.spritesheetData;\n}\n\n/**\n * Call when the gameslot unloads the gamefile.\n * The spritesheet and data is no longer needed.\n */\nfunction deleteSpritesheet(): void {\n\tspritesheet = undefined;\n\tspritesheetData = undefined;\n}\n\n// Generating Texture Data For Going Into A Mesh ----------------------------------------------------\n\n/**\n * Returns the texture data of a piece type.\n */\nfunction getTexDataOfType(type: number, rotation: number = 1): TextureData {\n\tconst texLocation: DoubleCoords = getSpritesheetDataTexLocation(type);\n\tconst texWidth: number = getSpritesheetDataPieceWidth();\n\treturn getTexDataFromLocationAndWidth(texLocation, texWidth, rotation);\n}\n\n/**\n * Returns the texture data of a a single instance with texcoords [0,0].\n * THE INSTANCE-SPECIFIC data needs to further contain texcoord offsets!\n */\nfunction getTexDataGeneric(rotation = 1): TextureData {\n\tconst texLocation: DoubleCoords = [0, 0];\n\tconst texWidth: number = getSpritesheetDataPieceWidth();\n\treturn getTexDataFromLocationAndWidth(texLocation, texWidth, rotation);\n}\n\n/**\n * Returns the texture data from a given texture location and width.\n */\nfunction getTexDataFromLocationAndWidth(\n\ttexLocation: DoubleCoords,\n\ttexWidth: number,\n\trotation = 1,\n): TextureData {\n\tconst texleft = texLocation[0];\n\tconst texbottom = texLocation[1];\n\n\tif (rotation === 1) {\n\t\t// Regular rotation\n\t\treturn {\n\t\t\ttexleft,\n\t\t\ttexbottom,\n\t\t\ttexright: texleft + texWidth,\n\t\t\ttextop: texbottom + texWidth,\n\t\t};\n\t} else {\n\t\t// Inverted rotation\n\t\treturn {\n\t\t\ttexleft: texleft + texWidth,\n\t\t\ttexbottom: texbottom + texWidth,\n\t\t\ttexright: texleft,\n\t\t\ttextop: texbottom,\n\t\t};\n\t}\n}\n\n// Exports -------------------------------------------------------------------\n\nexport default {\n\tinitSpritesheetForGame,\n\tgetSpritesheet,\n\tgetSpritesheetDataPieceWidth,\n\tgetSpritesheetDataTexLocation,\n\t// Texture Data\n\tgetTexDataOfType,\n\tgetTexDataGeneric,\n};\n"
  },
  {
    "path": "dev-utils/spritesheet_generator/spritesheetGenerator.ts",
    "content": "// dev-utils/spritesheet_generator/spritesheetGenerator.ts\n\n/**\n * This script takes a list of images, and converts it into a renderable\n * spritesheet, also returning the texture locations of each image.\n */\n\nimport type { DoubleCoords } from '../../../../../shared/chess/util/coordutil.js';\n\ntype SpritesheetData = {\n\t/** A fraction 0-1 representing what percentage of the total spritesheet's width one piece takes up. */\n\tpieceWidth: number;\n\ttexLocs: { [key: number]: DoubleCoords };\n};\n\n/**\n * The preferred image width each pieces image in a spritesheet should be.\n * This may be a little higher, in order to make the spritesheet's total width a POWER OF 2.\n * BUT, the spritesheet's width will NEVER exceed WebGL's capacity!\n */\nconst PREFERRED_IMG_SIZE = 512;\n\n/**\n * Generates a spritesheet from an array of HTMLImageElement objects.\n * The spritesheet is created by arranging the images in the smallest square grid.\n * Each image is placed in a grid of 512x512px.\n * @param gl - The webgl rendering context that will be rendering this spritesheet. We need this to determine the maximum-supported size.\n * @param images - An array of HTMLImageElement objects to be merged into a spritesheet.\n * @returns A promise that resolves with the generated spritesheet as an HTMLImageElement.\n */\nasync function generateSpritesheet(\n\tgl: WebGL2RenderingContext,\n\timages: HTMLImageElement[],\n): Promise<{ spritesheet: HTMLImageElement; spritesheetData: SpritesheetData }> {\n\t// Ensure there are images provided\n\tif (images.length === 0) throw new Error('No images provided when generating spritesheet.');\n\n\t// Calculate the grid size: Find the smallest square grid to fit all images\n\tconst numImages = images.length;\n\tconst gridSize = Math.ceil(Math.sqrt(numImages)); // Square root of number of images, rounded up\n\n\tconst maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE); // Naviary's is 16,384\n\n\t/**\n\t * The actual maximum size each image could be before exceeding web GL's boundaries.\n\t * This is not how big we actually want to render the textures because we prefer they be 512x512.\n\t */\n\tconst maxImgSizePerMaxTextureSize = maxTextureSize / gridSize;\n\n\tconst spritesheetSizeIfPreferredImgSizeUsed = roundUpToNextPowerOf2(\n\t\tPREFERRED_IMG_SIZE * gridSize,\n\t); // Round up to nearest power of 2\n\tconst actualImgSizeIfUsingPreferredImgSize = spritesheetSizeIfPreferredImgSizeUsed / gridSize;\n\n\t/** Whichever is smaller of the two */\n\tconst actualImgSize = Math.min(\n\t\tactualImgSizeIfUsingPreferredImgSize,\n\t\tmaxImgSizePerMaxTextureSize,\n\t);\n\n\t// Calculate the total width and height of the canvas (spritesheet)\n\tconst canvasWidth = gridSize * actualImgSize;\n\tconst canvasHeight = gridSize * actualImgSize;\n\n\t// Create a canvas element for the spritesheet\n\tconst canvas = document.createElement('canvas');\n\tcanvas.width = canvasWidth;\n\tcanvas.height = canvasHeight;\n\tconst ctx = canvas.getContext('2d');\n\tif (ctx === null) throw new Error('2D context null.');\n\n\t// Positioning variables\n\tlet xIndex = 0;\n\tlet yIndex = 0;\n\n\t// Draw all the images onto the canvas\n\tfor (let i = 0; i < numImages; i++) {\n\t\tconst x = xIndex * actualImgSize;\n\t\tconst y = yIndex * actualImgSize;\n\n\t\t// Draw the image at the current position\n\t\tctx.drawImage(images[i]!, x, y, actualImgSize, actualImgSize);\n\n\t\t// Update the position for the next image\n\t\txIndex++;\n\t\tif (xIndex === gridSize) {\n\t\t\txIndex = 0;\n\t\t\tyIndex++;\n\t\t}\n\t}\n\n\t// Create an HTMLImageElement from the canvas\n\tconst spritesheetImage = new Image();\n\tspritesheetImage.src = canvas.toDataURL();\n\n\t// Return a promise that resolves when the image is loaded\n\tawait spritesheetImage.decode();\n\tconst spritesheetData = generateSpriteSheetData(images, gridSize);\n\n\treturn { spritesheet: spritesheetImage, spritesheetData };\n}\n\n/**\n * Generates the sprite sheet data (texture coordinates and width) for each image.\n * @param images - An array of HTMLImageElement objects to be merged into a spritesheet.\n * @param gridSize - How many images fit one-way.\n * @returns A sprite data object with texture coordinates for each image.\n */\nfunction generateSpriteSheetData(images: HTMLImageElement[], gridSize: number): SpritesheetData {\n\tconst pieceWidth = 1 / gridSize;\n\tconst texLocs: { [key: number]: DoubleCoords } = {};\n\n\t// Positioning variables\n\tlet x = 0;\n\tlet y = 0;\n\n\t// Loop through the images to create the sprite data\n\timages.forEach((image) => {\n\t\tconst texX = x / gridSize;\n\t\tconst texY = 1 - (y + 1) / gridSize;\n\n\t\t// Store the texture coordinates\n\t\t// Use the image id as the key for the data object\n\t\ttexLocs[Number(image.id)] = [texX, texY];\n\n\t\t// Update the position for the next image\n\t\tx++;\n\t\tif (x === gridSize) {\n\t\t\tx = 0;\n\t\t\ty++;\n\t\t}\n\t});\n\n\treturn {\n\t\tpieceWidth,\n\t\ttexLocs,\n\t};\n}\n\n/**\n * Rounds up the given number to the next lowest power of two.\n *\n * Time complexity O(1), because bitwise operations are extremely fast.\n * @param num - The number to round up.\n * @returns The nearest power of two greater than or equal to the given number.\n */\nfunction roundUpToNextPowerOf2(num: number): number {\n\tif (num <= 1) return 1; // Handle edge case for numbers 0 and 1\n\tnum--; // Step 1: Decrease by 1 to handle numbers like 8\n\tnum |= num >> 1; // Step 2: Propagate the most significant bit to the right\n\tnum |= num >> 2;\n\tnum |= num >> 4;\n\tnum |= num >> 8;\n\tnum |= num >> 16; // Additional shift for 32-bit numbers\n\treturn num + 1; // Step 3: Add 1 to get the next power of 2\n}\n\nexport { generateSpritesheet };\n"
  },
  {
    "path": "docs/COPYING.md",
    "content": "# Copying Infinite Chess\n\nAny file in this project that does not state otherwise and is not listed as an exception below is part of Infinite Chess and copyright © 2023-2026 Naviary (InfiniteChess.org).\n\nInfinite Chess is free software; you can redistribute and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version. Any works derived from this software must also be released under the same license.\n\nInfinite Chess is distributed WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.en.html) for more details.\n\nSee [LICENSE](../LICENSE) for a copy of the GNU Affero General Public License.\n\n## Exceptions (free)\n\n| Files                                                     | Author(s)                                                                               | License                                                           |\n| --------------------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |\n| dev-utils/pieces/themes/cburnett                          | [Cburnett](https://en.wikipedia.org/wiki/User:Cburnett)                                 | [CC BY-SA 3.0](http://creativecommons.org/licenses/by-sa/3.0/)    |\n| dev-utils/pieces/themes/green_chess                       | [Green Chess](https://greenchess.net/index.php)                                         | [CC BY-SA 3.0](http://creativecommons.org/licenses/by-sa/3.0/)    |\n| dev-utils/pieces/themes/stockfish                         | [Stockfish](https://github.com/official-stockfish/Stockfish)                            | [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html)             |\n| dev-utils/pieces/themes/pychess                           | [Pychess](https://github.com/pychess/pychess)                                           | [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html)             |\n| dev-utils/sounds/lichess                                  | [Lichess](https://github.com/lichess-org/lila)                                          | [AGPL v3.0](https://www.gnu.org/licenses/#AGPL)                   |\n| dev-utils/sounds/fesliyan_studios                         | [Fesliyan Studios](https://www.fesliyanstudios.com/)                                    | No credit required, but cannot be reposted elsewhere for download |\n| dev-utils/scripts/gl-matrix.js                            | Brandon Jones and Colin MacKenzie IV                                                    | [MIT](https://opensource.org/license/mit)                         |\n| src/client/scripts/esm/chess/logic/icn/icnconverter.ts    | [Andreas Tsevas](https://github.com/tsevasa) and [Naviary](https://github.com/Naviary2) | [Unlicense](https://en.wikipedia.org/wiki/Unlicense)              |\n| src/client/scripts/esm/chess/logic/icn/icncommentutils.ts | [Andreas Tsevas](https://github.com/tsevasa) and [Naviary](https://github.com/Naviary2) | [Unlicense](https://en.wikipedia.org/wiki/Unlicense)              |\n"
  },
  {
    "path": "docs/GRAPHICS.md",
    "content": "# Graphics Rendering Guide\n\n[← Back to Navigation Guide](./NAVIGATING.md) | [Contributing Guide](./GUIDELINES.md)\n\nThis guide explains how graphics rendering works on the board, and how to add new visuals. An infinite board provides a few unique considerations to the rendering system than typical 2D games.\n\nAll visuals are rendered with raw WebGL for maximum control. No external libraries are used, like for example Three.js.\n\n## Coordinate Spaces\n\nThere are two coordinate spaces to know of:\n\n### Grid Space (Coord/Model Space)\n\nGrid space uses integer coordinates where each unit is one chess square. The origin of a square is its center, so the coordinate `[3n, 5n]` refers to the middle of the square at (3,5). Piece rendering uses this coordinate space, including square highlights.\n\nWhen decimal precision is needed on top of BigInts (like knowing, for example, the exact coordinate the edges of the screen are at) we use **BigDecimals** from `@naviary/bigdecimal`, a custom designed number package that adds decimal precision to arbitrarily large coordinates. The package provides fully type safe arithmetic methods for working with them when needed.\n\nThe bounding box of the screen over the grid space can be retrieved with `boardtiles.gboundingBoxFloat()` (decimal precision) or `boardtiles.gboundingBox()` (rounded away from screen center to next integer coordinates).\n\n### World Space\n\nWorld space is the coordinate system the GPU and camera use. The camera is fixed at `[0, 0, 12]` at all times, looking straight down at the board, while the board moves and scales underneath it. The board spans the entire X/Y plane, and the Z axis is away from the board (or up when in perspective mode). This is the final coordinate space all vertex data is converted to before rendering.\n\nThe center of the screen is always `[0, 0]` in world space. The bounding box of the screen can be retrieved with `camera.getRespectiveScreenBox()`, which automatically expands the box to the horizon when in perspective mode. Panning/zooming the board has no effect on this box's coordinates, only resizing the window does. The horizon is `1500` (chebyshev) world space units away from the center of the screen, anything beyond that gets clipped. For this reason, arbitrarily large grid-space coordinates _always_ have to be converted to world space before rendering, and clamped to that range, to prevent visual artifacts.\n\n### Converting Between Spaces\n\n[`space.ts`](../src/client/scripts/esm/game/misc/space.ts) provides key conversion functions for converting from one coordinate space to the other.\n\n- `convertCoordToWorldSpace(coords)` — Grid → World. You may first have to cast BigInt coords to BigDecimal coords via `bdcoords.FromCoords(coords)`.\n- `convertWorldSpaceToCoords(worldCoords)` — World → Grid (includes decimal precision).\n- `convertWorldSpaceToCoords_Rounded(worldCoords)` — World → Grid, returning the integer tile coordinates the world space position is over.\n\n[`mouse.ts`](../src/client/scripts/esm/game/misc/mouse.ts) can be used to locate the mouse position in either coordinate space.\n\n- `getMouseWorld()` — Mouse position in world space.\n- `getTileMouseOver_Float()` — Mouse position in grid space (with decimal precision).\n- `getTileMouseOver_Integer()` — Mouse position in grid space, returning the integer tile coordinates the mouse is over.\n\n## Creating Vertex Data\n\nAll geometry rendered to the screen starts as an array of vertex data. Each vertex contains its attributes packed sequentially—position components first, then optionally color and/or texture coordinates. So for example, the vertex data of a red line from (-1,0) to (1,0) would be:\n\n```ts\n// prettier-ignore\nconst vertexData = [\n\t// x, y,   r, g, b, a\n\t  -1, 0,   1, 0, 0, 1, // Vertex 1\n\t   1, 0,   1, 0, 0, 1, // Vertex 2\n];\n```\n\nThe exact attributes you include in the vertex data depends on the shader you plan on rendering your object with, and whether you're using instanced rendering. More info below.\n\n### Primitives\n\n[`primitives.ts`](../src/client/scripts/esm/game/rendering/primitives.ts) provides many helpers for calculating the vertex data of various shapes: squares, rectangles, circles, etc. from just their dimensions and color.\n\n### Instanced Shape Data\n\n[`instancedshapes.ts`](../src/client/scripts/esm/game/rendering/instancedshapes.ts), if you're using instanced rendering (which is a lot simpler to create vertex & instance data for, if you're rendering many copies of the same shape), provides helpers for obtaining the vertex data of the shape you want to render: legal move square, dot, special rights plus sign, etc.\n\nIf you use instanced rendering, you bypass the need to calculate instance-specific vertex data, often only needing to specify the position offset of each of your objects in the instance data. This is used by piece rendering inside [`piecemodels.ts`](../src/client/scripts/esm/game/rendering/piecemodels.ts) (that example renders textures), and by legal move model generation inside [`legalmovemodel.ts`](../src/client/scripts/esm/game/rendering/highlights/legalmovemodel.ts).\n\n### Mesh Helpers\n\n[`meshes.ts`](../src/client/scripts/esm/game/rendering/meshes.ts) provides higher-level helpers for automatically generating the vertex data for you if all you have is the integer coordinate and color of the square you want vertex data for. It can also convert a grid space bounding box into world space for you.\n\n### Square Highlights\n\nFor the common task of highlighting squares on the board, [`squarerendering.genModel()`](../src/client/scripts/esm/game/rendering/highlights/squarerendering.ts) is high-level helper that internally handles the vertex data and instance data creation for you from just a list of integer coordinates and a color, returning a ready-to-render object.\n\n## Rendering Vertex Data\n\nOnce you have vertex data, pass it to [`createRenderable()`](../src/client/scripts/esm/webgl/Renderable.ts) or [`createRenderable_Instanced()`](../src/client/scripts/esm/webgl/Renderable.ts)\nto create a GPU-ready object that can instantly be rendered.\n\nThey accept arguments for vertex data, instance data (if using instanced rendering), information on how you packed your vertex data with the position & color attributes, the drawing mode to use ('TRIANGLES', 'LINES', etc.), and the name of the shader you want to use (see options below).\n\nThe returned `Renderable` object has a `render()` property for instantly rendering it. If you generated your vertex data in world space, you don't have to specify transformation arguments when rendering for the item to appear in the correct place. If however your vertex data is in grid space (which is common for instance rendering), you should provide the `position` and `scale` arguments when rendering. Position is dependent on the board position (`meshes.getModelPosition()`), and scale is dependant on the board scale (`boardpos.getBoardScaleAsNumber()`). The render method uses these to automatically transform the points to world space when rendering.\n\nThe `Renderable` object also has properties for updating its vertex/instance data internally, allowing you the option to skip generating a whole new Renderable every single frame. This is optimal when you have arbitrarily many objects to render, and their positions change infrequently. [`piecemodels.ts`](../src/client/scripts/esm/game/rendering/piecemodels.ts) for example does this when updating the model of the piece sprites.\n\n## Shader Picking\n\nDifferent shaders are compatible with different ways of packing vertex data. Some are compatible with rendering colored vertices, some with textured vertices, and another with both. There are many shaders the game uses, many custom made for specific object rendering, but here are the most common we use:\n\n| Shader Name          | Vertex Data Packing       | Instance Data Packing | When to Use                                  |\n| -------------------- | ------------------------- | --------------------- | -------------------------------------------- |\n| `'color'`            | position + color          | -                     | Solid colored shapes                         |\n| `'colorInstanced'`   | position + color          | position              | Solid colored shapes via instanced rendering |\n| `'texture'`          | position + texture coords | -                     | Textured shapes                              |\n| `'textureInstanced'` | position + texture coords | position              | Textured shapes with via instanced rendering |\n\nOther shaders can allow for more unique properties for each instance, such as `'arrows'` for the indicator arrows rendering, which allows a unique position, color (for opacity), and rotation, per arrow instance, or `'starfield'` which allows a unique position, color, and size, for each animated star. For a full list of available shaders and their compatible vertex data packing, see [`ProgramManager.ts`](../src/client/scripts/esm/webgl/ProgramManager.ts).\n\n## Integrating Into the Render Loop\n\nThe render loop lives in `game.ts`. The `renderScene()` function renders all items in the order:\n\n1. **Background** — Starfield / void rendering (uses masking)\n2. **Board** — Infinite tile grid, promotion lines\n3. **Below-piece overlays** — Square highlights, rays, check indicators, legal move highlights\n4. **Pieces** — All piece sprites\n5. **Above-piece overlays** — Arrows, animations, crosshair\n\nCall your script's render method in the appropriate section.\n\n## Conclusion\n\nUltimately, always refer to how the existing code renders objects for inspiration for rendering your own!\n"
  },
  {
    "path": "docs/GUIDELINES.md",
    "content": "# Pull Request Requirements and Guidelines\n\n[← Back to Navigation Guide](./NAVIGATING.md) | [Setup Guide](./SETUP.md) | [Graphics Guide](./GRAPHICS.md)\n\n### All pull requests should only add **one** feature, fix **one** bug, or perform **one** refactoring.\n\nIf your changes affect more than one feature, it **must** be refactored into multiple pull requests. If those additional PRs would depend on the code of the first PR, you must wait until the first one is merged before opening the additional ones. To avoid this, while you wait, try to work on features that have no overlap in the codebase, thus allowing multiple PRs at once.\n\n### Title & Description\n\nTitles must be clear to understand.\n\nDescription guidelines are in the automatic template when opening a new pull request.\n\n### Scopes you should NOT submit pull requests for:\n\nOnly Naviary should make these types of changes (but you may request me to do so):\n\nAdding/removing package dependancies.\n\nType or variable renames spanning several files (time consuming for me to review, but taking one minute to make the changes myself).\n\nMassive refactors covering dozens of files in the codebase, unless it's required to fulfill the prompt.\n\n## Code Standards\n\n> [!NOTE]\n> Any guidelines automatically enforced via our linter, prettifier, type checker, and builder, are not listed here. Fix them as you encounter them.\n\nThe use of AI to help you write and modify code is permitted, but you must carefully review and polish its output to ensure the quality of the code meets all standards of the project!\n\nKeep all coding languages to their respective files. For example, shader code goes inside `.glsl` files, and html goes inside `.html` or `.ejs` files, not scripts.\n\n`// prettier-ignore`s are permitted to bypass the prettifier, for any one code block, if you're style is easier to read.\n\n### No code duplication\n\nThere may not be any code redundancy. Always refactor to the simplest way things can be expressed.\n\nUse as many prexisting helper methods in the codebase as possible. At times, you may have to refactor out helpers out of existing codebase functions in order to satisfy this.\n\nNo dead code or functions that are never called.\n\n### Avoid Complexity\n\nDon't add unnecessary complexity. Use the minimum amount of code & features needed to get the job done.\n\n1. Identify the requirements for adding a new feature.\n2. Identify where the website currently lacks in those requirements.\n3. Make the **minimum** changes necessary to fulfill those requirements.\n\nStart minimal. Sometimes requirements may not be fully known until halfway through implementation. Start small and only increase requirements when needed.\n\n### Type Safety\n\nAll new scripts are required to be written in TypeScript, vs JavaScript.\n\nTo retain maximum type safety, no casting via `as` is permitted, only in rare circumstances when it is not simple to get typescript to infer the type, and we are 100% confident of the type. Try to use generics where you can.\n\nNo `// ts-ignore`s are permitted, either.\n\nFor arguments defined by user input, or needing to be sanitized from the client, use the `zod` package to achieve full type safety.\n\nFor all methods that accept a function callback for an argument, like `map()`, `filter()`, `forEach()`, `setTimeout()`, etc., to obtain type safety, don't pass in the function directly, but use a wrapper. For example, don't do `array.map(functionCallback)`, but do do `array.map((item) => functionCallback(item))`. The exception is when adding callbacks for event listeners, as we have to retain the reference to the original function in order to remove the listener later.\n\n### No magic strings\n\nThere must be no magic strings. All precise strings that are used in multiple locations must be stored in a constant variable. A string is considered magic if changing it in one place, but not everywhere else, would create a bug.\n\n### Single Responsibility Principle (SRP)\n\nEach script should have one responsibility only. If it has multiple, you **must** refactor it into multiple scripts.\n\nBe aware of context. A script in charge reading and managing the pieces inside the gamefile should not be in charge of knowing the fallback bounding box of the pieces, if there are none. Remember, one responsibility per script.\n\n### Target the Root Cause\n\nDo not opt for \"band-aid\" patches for bugs that only patch symptoms. Bugs are a sign of something not working how it's designed to. Find the root cause, patch that.\n\n### Functions\n\nShould have one single purpose. If it does multiple things, refactor it out into multiple functions. Aim for under 40 lines.\n\nRequire atleast one sentence of JSDoc. Do not make the documentation too verbose.\n\nArguments only need documentation if it is not common sense what they would be for, or what we should pass in for them (for example, `boardsim` is common sense and doesn't require documentation), or if they don't provide any additional information than what's already in the function description.\n\nFunction bodies should also have comments for documentation, to help understand what it's doing and how it works. Don't be too verbose.\n\n### Imports\n\nOpt for using `import type` over `import`, when an import is only used for its type.\n\nImports are automatically ordered according to the project standard when committing changes.\n\n### Exports\n\nIn general, use default exports (e.g. `export default { ... }`) over normal exports `export { ... }`. This reduces global scope pollution. The only exception is when a script has only one exported function, then it may export that function normally.\n\n## Naming\n\nAll files, types, and variable names should have clear and easy to understand names.\n\nWhen writing names, keep context in mind. For example, a script whos responsibility is to save board editor positions should not be named `save.ts`, as `save` doesn't infer any context about the board editor. A better name is `editorsave.ts`, or `esave.ts` for short. Another example: If a script named `guinavigation.ts` is using default exports, and we're writing a function that opens the navigation UI, then choose `open()` for the function name instead of say, `openNavigationUI()`, as for the latter, external application code calling the function would look like `guinavigation.openNavigationUI()`, which duplicates the needed context, vs the simpler `guinavigation.open()`.\n\n### Casing\n\n**Scripts**: Use either lowercase (e.g. `boardutil.ts`) or PascalCase (e.g. `AudioManager.ts`), depending on how universally professional and reusable it is. If it's scope could only ever be used in our repository and game, use lowercase. If it could be pulled out and reused in other projects without significant refactoring, use PascalCase.\n\n**Types**: Use PascalCase (e.g. `OrganizedPieces`)\n\n**Constants**: Use UpperSnakeCase (e.g. `SOUND_OFFSET`).\n\n**Variables**: Use either CamelCase (e.g. `playerColor`) or SnakeCase (e.g. `player_color`), depending on what the script you are working on is using more apparently. Remaining consistent is trump: if many other scripts create a local variable named `timeoutId`, choose that for your local variable name instead of `timeout_id`.\n"
  },
  {
    "path": "docs/NAVIGATING.md",
    "content": "# Navigation Guide\n\nThis guide gives you several pointers on the project structure, to help you get started with collaborating!\n\n[← Back to Setup Guide](./SETUP.md) | [Contributing Guide](./GUIDELINES.md) | [Graphics Guide](./GRAPHICS.md)\n\nIt is assumed you have already gone through the Setup Guide.\n\n## Terminal Output\n\nAfter starting up the server via `npm run dev`, there are a few different processes that run in parallel. The green `[build]` is in charge of compiling all scripts into javascript, bundling them together, and copying them into the `dist/` directory, along with all other client assets. This directory is deleted and rebuilt automatically on every file change. The grey `[server]` logs are the output of the actual running server, these are mainly what you're gonna be interested in. And the blue `[tsc]` logs report any typescript errors, those don't prevent the server from running, but any that pop up should be patched as you go.\n\n<img width=\"1324\" height=\"226\" alt=\"Screenshot 2025-09-19 at 10 05 44 PM\" src=\"https://github.com/user-attachments/assets/9bb6ecfc-90d2-4479-8010-551689c7759b\" />\n\n## Project Structure\n\nThe entire source code of the project is located in [`src`](../src/). This contains all code that is ever run by either the server or client, and contains assets that are served to the client.\n\n```\nsrc/\n├── client/     # Frontend code and assets\n├── server/     # Backend Node.js server\n└── shared/     # Common logic between client and server\n```\n\n| Directory                                                         | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |\n| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| [`src/client/`](../src/client/)                                   | Contains all clientside files and resources of the website, whether script, image, sound, etc. Any file inside here may be requested by and served to the client. No client-side code is ever imported by server-side scripts.                                                                                                                                                                                                                                                                                                                                    |\n| [`src/server/`](../src/server/)                                   | Contains all server-side files. The server begins running from [`server.js`](../src/server/server.js). This configures and starts our http, https, and websocket servers, and cleans up on closing.                                                                                                                                                                                                                                                                                                                                                               |\n| [`src/shared/`](../src/shared/)                                   | Contains all shared scripts between the server and client. This includes lots of chess logic that both need. No shared script should **ever** reference environment variables in the Node.js or browser environment. A couple examples are `document` or `window` in the browser.                                                                                                                                                                                                                                                                                 |\n| [`src/client/views/`](../src/client/views)                        | Contains our EJS documents, which are converted to HTMLs on startup. Modify these to change the content on the website pages. In order to support multiple languages, these documents reference many of the translations in [`en-US.toml`](../translation/en-US.toml). Any changes to the toml file requires you increment the version number at the top of it, and record the change you made inside [`changes.json`](../translation/changes.json). Additional information on working other languages of the website is in [TRANSLATIONS.md](./TRANSLATIONS.md). |\n| [`src/client/scripts/esm/game/`](../src/client/scripts/esm/game/) | Contains all our code for running the game on the play page of the website! It starts inside [`main.js`](../src/client/scripts/esm/game/main.js), which contains our game loop. Most scripts includes a basic description at the top. Feel free to ask for greater details on what a specific script does, or for help finding the code that does a specific task!                                                                                                                                                                                                |\n| [`src/server/game/`](../src/server/game/)                         | Contains the server-side code for running online play, including the invites manager and game manager. Both of these use websocket messaging to broadcast changes out to the clients in real-time.                                                                                                                                                                                                                                                                                                                                                                |\n| `database.db`                                                     | Automatically generated at the root level of the project. This stores all user accounts, login sessions, games, etc. You can view the contents of the database via the SQLite VSCode extension.                                                                                                                                                                                                                                                                                                                                                                   |\n\n## Accounts\n\nThere are 4 automatically generated accounts for you to test with. The password for every one of these accounts is `1`-\n\n- `Member`: Regular permissions.\n- `Patron`: At the moment this holds no difference to member accounts.\n- `Admin`: Is able to send commands on the admin panel page found at url `https://localhost:3443/admin`. Sending `help` will list the available commands.\n- `Owner`: The only current difference to admin accounts being that they are able to delete other admin accounts via the admin panel.\n\n## Debugging Keyboard Shortcuts\n\nWhile in-game, there are a few keys that will activate useful debugging modes-\n\n- `` ` ``: The backtick key (typically right below your escape button) will toggle the camera's debug mode. This places the camera position further back in space, allowing you to see a little beyond the normal screen edges. Useful for making sure rendered items don't exceed the edge!\n- `1`: If you are in a local game, this will toggle \"Edit Mode\", which allows you to move any piece anywhere else on the board, bar whether it's legal.\n- `2`: Prints the entire gamefile to the console. Useful for checking for expected properties.\n- `3`: Greatly slows the animation of pieces, and renders the spline path the piece will travel. Especially useful for debugging curved movement paths, such as the Rose.\n- `4`: Simulates 1 second of websocket message latency. This helps you to catch bugs caused by low ping, something you have zero of when developing.\n- `5`: Copies the currently loaded game as a single position, according to the move you are viewing. This strips the moves list from the resulting notation.\n- `6`: Toggles specialrights highlights. This displays a `+` sign next to what pieces still have their special ability (pawns that can double push, kings/rooks that can castle). In addition, this also highlights the square enpassant capture is legal on, if possible.\n- `7`: Toggles engine move generation highlights. This indicates all the moves the engine will consider in the position. Only works for the HydroChess engine.\n\n## Making changes to the repository\n\nAll pull requests MUST meet the standards outlined in the [Contributing Guide](./GUIDELINES.md)!\n\nPlease seek approval in the [discord server](https://discord.com/channels/1114425729569017918/1115358966642393190) before you start making changes you expect will be merged! I am very particular about what gets added, I have a vision for the course of the project. Generally, if you've spoken about the desired change with me, and we're on the same page about how it will be implemented, you don't have to worry! Also, check out the [list of open issues](https://github.com/Infinite-Chess/infinitechess.org/issues) for tasks you could claim!\n\nSometimes after you modify a file, the browser doesn't detect that it was changed, so it doesn't load the new code after a refresh. To avoid this, I highly recommend enabling automatic hard refreshing in your browser's developer tools. Here's how to do that in Chrome:\n\n<img width=\"1134\" alt=\"15\" src=\"https://github.com/Infinite-Chess/infinitechess.org/assets/163621561/8dafd293-4817-460f-a877-aca2825ba2fb\">\n\nAnd under the \"Preferences\" tab, check the box next to \"Disable cache (while DevTools is open)\".\n\n<img width=\"1131\" alt=\"16\" src=\"https://github.com/Infinite-Chess/infinitechess.org/assets/163621561/0be82a5a-c40f-43dc-8fc4-c2f0cc250b56\">\n\nNow, as long as you have developer tools open whenever you refresh, the game will always hard refresh and load your new code.\n\n## Mobile Testing\n\nMobile devices differ from pc behavior because the user interacts with touch events instead of mouse events. There are 2 ways you can test your code to make sure it works on mobile:\n\n- Chrome dev tools has a \"Toggle device toolbar\" button which allows you to interact with the page as if the mouse was your finger. It also easily lets you grow and shrink the size of the window to see how the content fits on each device width. However, it does not let you use multiple fingers. For that:\n\n- Connect to the web server with another device in your home network (like your phone). The machine you’re using to run the server is the only device that connects through `https://localhost:3443`. To connect from other devices in your home network, first they need to be connected to the same wifi, then you need to replace `localhost` with the IP address of your computer running the server. You can find your computers IP address within the network settings on your computer. An example of what your IP may look like is `192.168.1.2`. If this was your computer's IP address, then to connect to the web server on other devices you would go to `https://192.168.1.2:3443`.\n\n## Conclusion\n\nThose are the basics! Have at it! Check out the [Issues](https://github.com/Infinite-Chess/infinitechess.org/issues) for tasks you could assist with! Working on these directly helps the next update to come quicker! If you want easier ones, look for the ones with the \"simple\" tag!\n"
  },
  {
    "path": "docs/SETUP.md",
    "content": "# Setting up your workspace\n\nThis guide walks you through the initial setup phase of the infinitechess.org server on your machine.\n\n[← Back to README](../README.md) | [Navigation Guide](./NAVIGATING.md) | [Contributing Guide](./GUIDELINES.md) | [Translation Guide](./TRANSLATIONS.md)\n\nThis only needs to be done once. Afterward, you will be able to run the website locally on your computer, write and modify code, suggesting changes to the github!\n\n**This is a team project!!** Join [the discord](https://discord.gg/NFWFGZeNh5) server to work with others, discuss how to improve the website, and ask questions! If you have trouble during this setup process, many people are willing to assist you in the [#help](https://discord.com/channels/1114425729569017918/1257506171376504916) channel!\n\n**SUMMARY of the setup process for experienced users:** Install Node.js. Fork the repo and install the project dependencies via `npm i`. Now you can run `npm run dev` to launch a live infinite chess server at `https://localhost:3443`. Using the suggested [list of VSCode Extensions](#step-6-install-vscode-extensions) is highly recommended but optional. Read the [Navigation Guide](./NAVIGATING.md) to get a brief rundown of the project structure.\n\n## Step 1: Install Git\n\nLet's check to make sure you have Git already installed. Open a command prompt (windows) or terminal (mac), and enter the following:\n\n```\ngit version\n```\n\nIf this outputs a version number, you have it installed, proceed to the next step! If it outputted unknown command, [follow this guide](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) to install it!\n\n## Step 2: Download VSCode\n\nThis guide will use VSCode, which is **highly** recommended, but you may use another code editor if you wish, as long as it is compatible with Node, npm, and has source control features. This guide will walk you through the process using VSCode.\n\n[Go here](https://code.visualstudio.com/) to download and install VSCode. Be sure you have Visual Studio **Code**, and not Visual Studio (they are different).\n\n## Step 3: Install Node.js\n\n[Go here](https://nodejs.org/en/download) to download and install Node. Select version `v22.XX.X (LTS)`, `x64` for the architecture, then download the Installer (.msi on Windows or .pkg on Mac). Then run the installer.\n\n## Step 4: Forking the repository\n\nGo to the [repository's home page](https://github.com/Infinite-Chess/infinitechess.org), then click \"Fork\"! You will need a github account.\n\n<img width=\"818\" alt=\"21 copy\" src=\"https://github.com/Infinite-Chess/infinitechess.org/assets/163621561/287192ce-361e-4277-9ac2-249813852d2f\">\n\nOn the next page, click \"Create Fork\".\n\nNext, open VSCode, and click \"Clone Git Repository...\"\n\n<img width=\"1024\" alt=\"18\" src=\"https://github.com/Infinite-Chess/infinitechess.org/assets/163621561/282bc4e3-3f05-4160-9125-23fd9fb3ef58\">\n\nClick \"Clone from GitHub\".\n\n<img width=\"684\" alt=\"19 copy\" src=\"https://github.com/Infinite-Chess/infinitechess.org/assets/163621561/fd0f4b09-d2e0-4c1f-8363-5b87b7511f09\">\n\nThen click \"Allow\" to sign in with your github account, and, in the browser window that opened, click \"Open Visual Studio Code.app\".\n\nThe fork you just created should be at or near the top of the list, click on it! Be sure it has your github username on it! If it says \"Infinite-Chess\", don't click that one as it is the main repository, which you don't have write access to.\n\n<img width=\"674\" alt=\"Screen Shot 2024-07-02 at 1 03 01 PM\" src=\"https://github.com/Infinite-Chess/infinitechess.org/assets/163621561/25dff27c-f09f-444f-8fdd-2f68b344a1fb\">\n\nChoose a location on your machine to store the repository. Then when prompted whether to open the cloned repository, click \"Open\".\n\n## Step 5: Install project dependencies\n\nInside the opened VSCode project, open a terminal window within it by going to Terminal > New Terminal.\n\nRun the following command to auto-install all project dependancies:\n\n```\nnpm i\n```\n\nTo test run the server, and start it up from now on, enter the command:\n\n```\nnpm run dev\n```\n\nThe first time you run this, you should see something like:\n\n<img width=\"1187\" height=\"481\" alt=\"Screenshot 2025-09-19 at 9 52 21 PM\" src=\"https://github.com/user-attachments/assets/52e70488-2126-47ad-a93f-b72d9a614b5e\" />\n\nSubsequent startups will look something like:\n\n<img width=\"1185\" height=\"209\" alt=\"Screenshot 2025-09-19 at 9 53 17 PM\" src=\"https://github.com/user-attachments/assets/474184a5-493e-4bae-a7a4-3ebd0ba325df\" />\n\nYou should now be able to connect to the server through local host! Open a web browser and go to:\n\n```\nhttps://localhost:3443\n```\n\nIt will warn us our connection is not private.\n\n<img width=\"907\" alt=\"345182644-ffedcc95-7ca8-46ab-bf67-26ff96dbe0f4 copy\" src=\"https://github.com/Infinite-Chess/infinitechess.org/assets/163621561/d03048fb-ddc2-4015-8dca-5a406866eae0\">\n\nClick \"Advanced\", then \"Proceed to localhost (unsafe)\"!\n\n<img width=\"1029\" alt=\"Screen Shot 2024-07-02 at 1 57 05 PM copy\" src=\"https://github.com/Infinite-Chess/infinitechess.org/assets/163621561/f822ccdf-7cd9-495b-8e52-d65756b6a77c\">\n\nNow you should now be able to browse the website and all it’s contents! Hooray!\n\n<img width=\"1011\" alt=\"5 orig\" src=\"https://github.com/Infinite-Chess/infinitechess.org/assets/163621561/7d9cda30-bda9-4cde-8b17-a8dcc9185b0d\">\n\nDon't worry about the url bar telling you it's not secure. This can safely be ignored as you develop. It IS possible to get your computer to trust our newly created certificate, but it is not required, and these directions won’t include that. [This one guy](https://stackoverflow.com/a/49784278) was able to figure it out though.\n\nNow, stop the server by clicking in the VSCode terminal window to re-focus it, and hit Ctrl > C.\nIf done correctly, you should be met with the following. This means the server has stopped.\n\n<img width=\"667\" height=\"170\" alt=\"Screenshot 2025-09-19 at 9 56 26 PM\" src=\"https://github.com/user-attachments/assets/2e98bec2-8c1e-47e5-a1e3-a6139da03117\" />\n\n## Step 6: Install VSCode Extensions\n\n1. **ESLint**\n\nInstalling the ESLint VSCode extension will help your pull requests be approved quicker, by holding your code semantics to the standards of the project! ESLint will give you errors when you have undefined variables, missing semicolons, and other items, making it easier to catch bugs before runtime!\n\nGo to the extensions tab, search for \"eslint\", click the one by \"Microsoft\", then Click \"Install\"!\n\n<img width=\"1081\" alt=\"Screen Shot 2024-08-16 at 10 26 33 PM copy\" src=\"https://github.com/user-attachments/assets/7df938ff-da69-4675-934f-4a61e93e69c1\">\n<br>\n<br>\n\n2. **Prettier - Code formatter**\n\nUsing this extension will help your code changes be stylistically consistent with the rest of the codebase. After installing this extension, open your VScode settings, set Prettier as your default code formatter in `Editor: Default Formatter` and enable `Editor: Format On Save`. This will automatically \"prettify\" the style every time you save a file; for example, it will fix indentation issues and replace double quotation marks with single quotation marks. You can have Prettier ignore a code block via `// prettier-ignore` if you think your style is more readable!\n\n3. **SQLite**\n\nInstalling this extension will allow you to preview the contents of the database during development. The database stores all account information.\n\n4. **GitHub Pull Requests**\n\nInstalling this extension is not required, but highly recommended. It allows you to test run the code of other peoples pull requests on your system, so you can give collective feedback!\n\n### **You are all set up now to start developing!** 🥳\n\nLet's move on to learn how to suggest changes to the repository! Or, skip right to the [Conclusion](#conclusion).\n\n## Creating a Pull Request\n\nAll pull requests MUST meet the standards outlined in the [Contributing Guide](./GUIDELINES.md)!\n\nAfter you have made some changes to the code, you can push those changes to your personal fork by going to the Source Control tab.\n\n<img width=\"887\" alt=\"Screen Shot 2024-07-03 at 9 48 08 AM copy\" src=\"https://github.com/Infinite-Chess/infinitechess.org/assets/163621561/a2280180-dc4a-4cd4-a411-db026591b6a2\">\n\nOnly changes you \"stage\" will be sent to your fork! You can stage specific changes, or you can stage all your changes by clicking the \"+\" in the above image. Then click \"Commit\".\n\nEnter a brief commit message, then click the checkmark in the top-right corner.\n\n<img width=\"928\" alt=\"Screen Shot 2024-07-03 at 9 56 51 AM copy\" src=\"https://github.com/Infinite-Chess/infinitechess.org/assets/163621561/3f1f2351-62f7-450b-ae28-8a626dca4ab6\">\n\nNow click \"Sync Changes\" back in the top-left!\n\nIf you now visit the fork you created on your own github account, the changes you made should now be found there as well!\n\nNext, let's suggest this change to the official infinitechess.org repository by creating a \"Pull Request\"!\n\nOn the home page of the fork you created ON YOUR GITHUB account, click on \"Pull Requests\"\n\n<img width=\"816\" alt=\"26 copy\" src=\"https://github.com/Infinite-Chess/infinitechess.org/assets/163621561/4405b906-bd76-4e34-9431-b6b2d8a2cdfe\">\n\nNow click \"New pull request\", followed by \"Create pull request\"! Your changes will be reviewed soon and either be accepted, rejected, or commented on!\n\n## Conclusion\n\nInfinite Chess is a team project! Join [the discord](https://discord.gg/NFWFGZeNh5) to discuss with the others how we should best go about things!\n\nNext, read the [Navigation Guide](./NAVIGATING.md) to get a rundown of the project structure, where the game code is located, etc.!\n\nFor a list of available tasks, please see the [Issues](https://github.com/Infinite-Chess/infinitechess.org/issues), or inquire in the [discord server](https://discord.gg/NFWFGZeNh5).\n\nAlso, read the [Contributing Guide](./GUIDELINES.md) to adopt the coding standards of the project!\n"
  },
  {
    "path": "docs/TRANSLATIONS.md",
    "content": "# Translation guide\n\nThis guide will walk you through the process of translating [InfiniteChess.org](https://www.infinitechess.org) into another language.\n\n[← Back to README](../README.md) | [Setup Guide](./SETUP.md) | [Contributing Guide](./GUIDELINES.md) | [Translation Directory](../translation/) | [English TOML](../translation/en-US.toml) | [Changelog](../translation/changes.json) | [English News Posts](../translation/news/en-US/)\n\nIt is assumed you have already gone through the Setup Guide.\n\n## Navigation\n\nAnything that matters to you as a translator is located in the [translation](../translation/) directory. Translation files are stored in TOML format (you can read more about its syntax [here](https://toml.io/)). Generally, it is a very aproachable format, and you only need to understand the absolute basics of it, which are explained below.\n\n## Translation files\n\n### Name\n\nEach file is named after its language [BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag). BCP 47 tags are composed of this format (notice the capitalization):\n\n`lng-(script)-REGION-(extensions)`\n\nFor example, `en-US` for American English, `sv` for Swedish, `zh-Hant-HK` for Chinese spoken in Hong Kong written in traditional script.\n\nYou should name your file this way and only this way, otherwise it won't be correctly detected.\n\n### Content\n\nTranslation files in TOML format consist of keys, values, table headers and comments, like this:\n\n```toml\n[table-header]\n# Comment\nkey1 = \"value1\"\nkey2 = \"value2\"\n```\n\n> [!IMPORTANT]\n> **You should only change values. Please, leave everything else, including comments, unmodified when translating!**.\n\n## Translation process\n\nIn case you are translating a language that is currently not present in the project, you can start the process by copying [en-US.toml](../translation/en-US.toml) and renaming it as described above. If you are updating an existing language, the only thing you need to do is to update the `version` variable at the top of your TOML document to the value of the `version` variable in [en-US.toml](../translation/en-US.toml), indicating that the translation is up to date.\n\n> [!IMPORTANT]\n> You should always use [en-US.toml](../translation/en-US.toml) as a reference. It is the only file that is up to date and comes straight from the developers. Do not use any other files!\n\nThen you can start a test server with `npm run dev` and start translating. If you insert the address `https://localhost:3443` into your browser, the website should be there and it should automatically update as you make your changes (after reloading the page). Make sure that you have selected the language that you are editing in the settings in the top right.\n\nIn case you are updating an existing language and you aren't sure what has changed since the last update, you can view changes of `en-US.toml` in [the official changelog](../translation/changes.json) or in the [file commit history](https://github.com/Infinite-Chess/infinitechess.org/commits/main/translation/en-US.toml). In general, a translation is only considered up to date if the `version` variable on top matches the `version` value of the English TOML file.\n\n> [!IMPORTANT]\n> If there is an HTML tag in the value you want to translate, do not modify it!\n>\n> Example of an HTML tag:\n>\n> ```html\n> <a href=\"https://www.google.com\"> Hello World </a>\n> ```\n>\n> In this example you should only change the words _Hello World_.\n\n### Translating News Articles\n\nIn addition to the TOML translation files, you may optionally translate news articles located in the `translation/news/` directory, but this isn't required. Here are the steps to translate those:\n\n1. **Make a copy of the [translation/news/en-US](../translation/news/en-US/) folder**: Rename it to your language's BCP 47 tag.\n\n2. **Translate the content**: For each `.md` file within (e.g. `2024-09-11.md`), translate it from english into your language. Each news article supports [markdown](https://www.markdownguide.org/basic-syntax/), please don't modify hyperlinks, bullet points, headers indicated by `#`, or html tags (e.g. `<iframe>...</iframe>`).\n\n3. **Commit your changes**: Once the translations are complete, commit the changes as you would with TOML files.\n\nWhen you are finished, you should open a pull request as described in [SETUP.md](./SETUP.md).\n\n## Conclusion\n\nThank you for your contribution! In case of any trouble or questions, you can join [the discord](https://discord.gg/NFWFGZeNh5).\n"
  },
  {
    "path": "ecosystem.config.cjs",
    "content": "// ecosystem.config.cjs\n\n/*\n * PM2 process configuration for the Infinite Chess production server.\n */\n\nmodule.exports = {\n\tapps: [\n\t\t{\n\t\t\tname: 'infinitechess',\n\t\t\tscript: 'dist/server/server.js',\n\t\t\tmax_restarts: 10,\n\t\t\tmin_uptime: '10s',\n\t\t},\n\t],\n};\n"
  },
  {
    "path": "eslint.config.js",
    "content": "// eslint.config.js\n\nimport globals from 'globals';\nimport pluginJs from '@eslint/js';\nimport pluginTypescript from '@typescript-eslint/eslint-plugin';\nimport parserTypescript from '@typescript-eslint/parser';\nimport eslintConfigPrettier from 'eslint-config-prettier/flat';\n\nexport default [\n\tpluginJs.configs.recommended,\n\t{\n\t\tignores: ['dev-utils/**', 'dist/**', 'src/client/pkg/**'],\n\t},\n\t{\n\t\tfiles: ['**/*.js', '**/*.ts'], // Apply the following rule overrides to both js and ts files...\n\t\t// plugins: { \"@typescript-eslint\": pluginTypescript }, // Define plugins as an object.  SUPPOSEDLY THIS IS NOT NEEDED??\n\t\trules: {\n\t\t\t// Overrides the preset defined by \"pluginJs.configs.recommended\" above\n\t\t\t'no-undef': 'error', // Undefined variables not allowed\n\t\t\t// Unused variables give a warning\n\t\t\t'no-unused-vars': [\n\t\t\t\t'warn',\n\t\t\t\t{\n\t\t\t\t\targsIgnorePattern: '^_',\n\t\t\t\t\tvarsIgnorePattern: '^_',\n\t\t\t\t\tcaughtErrorsIgnorePattern: '^_',\n\t\t\t\t},\n\t\t\t],\n\t\t\tsemi: ['error', 'always'], // Enforces semicolons be present at the end of every line.\n\t\t\t// Enforces semicolons have a space after them if they are proceeded by other statements.\n\t\t\t'semi-spacing': [\n\t\t\t\t// Enforces semicolons have a space after them if they are proceeded by other statements.\n\t\t\t\t'error',\n\t\t\t\t{\n\t\t\t\t\tbefore: false,\n\t\t\t\t\tafter: true,\n\t\t\t\t},\n\t\t\t],\n\t\t\t// Requires a space be after if, else, for, and while's.\n\t\t\t'keyword-spacing': [\n\t\t\t\t'error',\n\t\t\t\t{\n\t\t\t\t\tbefore: true,\n\t\t\t\t\tafter: true,\n\t\t\t\t},\n\t\t\t],\n\t\t\t'space-before-function-paren': ['error', 'never'], // Enforces there be NO space between function DECLARATIONS and ()\n\t\t\t'space-before-blocks': ['error', 'always'], // Enforces there be a space between function parameters and the {} block\n\t\t\t'arrow-spacing': ['error', { before: true, after: true }], // Requires a space before and after \"=>\" in arrow functions\n\t\t\t'func-call-spacing': ['error', 'never'], // Enforces there be NO space between function CALLS and ()\n\t\t\t'space-infix-ops': ['error', { int32Hint: false }], // Enforces a space around infix operators, like \"=\" in assignments\n\t\t\t'no-eval': 'error', // Disallows use of `eval()`, as it can lead to security vulnerabilities and performance issues.\n\t\t\t// All indentation must use tabs\n\t\t\tindent: [\n\t\t\t\t'error',\n\t\t\t\t'tab',\n\t\t\t\t{\n\t\t\t\t\tSwitchCase: 1, // Enforce switch statements to have indentation (they don't by default)\n\t\t\t\t\tignoredNodes: ['ConditionalExpression', 'ArrayExpression'], // Ignore conditional expressions \"?\" & \":\" over multiple lines, AND array contents over multiple lines!\n\t\t\t\t},\n\t\t\t],\n\t\t\t'prefer-const': 'error', // \"let\" variables that are never redeclared must be declared as \"const\"\n\t\t\t'no-var': 'error', // Disallows declaring variables with \"var\", as they are function-scoped (not block), so hoisting is very confusing.\n\t\t\t// \"max-depth\": [\"warn\", 4], // Maximum number of nested blocks allowed.\n\t\t\teqeqeq: ['error', 'always'], // Disallows \"!=\" and \"==\" to remove type coercion bugs. Use \"!==\" and \"===\" instead.\n\t\t\t'dot-notation': 'error', // Forces dot notation `.` instead of bracket notation `[\"\"]` wherever possible\n\t\t\t'no-empty': 'off', // Disable the no-empty rule so blocks aren't entirely red just as we create them\n\t\t\t'no-prototype-builtins': 'off', // Allows Object.hasOwnProperty() to be used\n\t\t\t// \"no-multi-spaces\": \"error\", // Disallows multiple spaces that isn't indentation.\n\t\t\t// \"max-lines\": [\"warn\", 500] // Can choose to enable to place a cap on how big files can be, in lines.\n\t\t\t// \"complexity\": [\"warn\", { \"max\": 10 }] // Can choose to enable to cap the complexity, or number of independant paths, which can lead to methods.\n\t\t},\n\t\tlanguageOptions: {\n\t\t\tparser: parserTypescript, // Use the TypeScript parser\n\t\t\tsourceType: 'module', // Can also be \"commonjs\", but \"import\" and \"export\" statements will give an eslint error\n\t\t\tglobals: {\n\t\t\t\t...globals.node, // Defines \"require\" and \"exports\"\n\t\t\t\tNodeJS: 'readonly', // Manually add NodeJS namespace, BECAUSE FOR SOME REASON ESLINT DOESN'T KNOW IT\n\t\t\t\t...globals.browser, // Defines all browser environment variables for the game code\n\t\t\t\t// Game code scripts are considered public variables\n\t\t\t\t// MOST OF THE GAME SCRIPTS are ESM scripts, importing their own definitions, so we don't need to list them below.\n\t\t\t\ttranslations: 'readonly', // Injected into the html through ejs\n\t\t\t\theader: 'readonly',\n\t\t\t\thtmlscript: 'readonly',\n\t\t\t\tEventListener: 'readonly',\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\t// TYPESCRIPT SETTINGS THAT OVERWRITE THE ABOVE\n\t\tfiles: ['**/*.ts'],\n\t\t// Required for us to use the @typescript-eslint/explicit-function-return-type rule below\n\t\tplugins: { '@typescript-eslint': pluginTypescript },\n\t\trules: {\n\t\t\t'no-unused-vars': 'off', // Default rule causes false positives on Enums\n\t\t\t// Typescript-specific unused variable rule\n\t\t\t'@typescript-eslint/no-unused-vars': [\n\t\t\t\t'warn',\n\t\t\t\t{\n\t\t\t\t\targsIgnorePattern: '^_',\n\t\t\t\t\tvarsIgnorePattern: '^_',\n\t\t\t\t\tcaughtErrorsIgnorePattern: '^_',\n\t\t\t\t},\n\t\t\t],\n\t\t\t// Disables dot-notation, as bracket notation is required by TS compiler if the keys of an object are STRINGS\n\t\t\t'dot-notation': 'off',\n\t\t\t'no-undef': 'off', // Prevent ESLint from flagging TypeScript types as undefined\n\t\t\t// Enforces all functions to declare their return type\n\t\t\t'@typescript-eslint/explicit-function-return-type': [\n\t\t\t\t'error',\n\t\t\t\t{\n\t\t\t\t\tallowExpressions: true, // Adds arrow functions as exceptions, as their return types are usually inferred\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\t},\n\teslintConfigPrettier,\n];\n"
  },
  {
    "path": "nodemon.json",
    "content": "{\n  \"watch\": [\n    \"dist/server\",\n    \"dist/shared\",\n    \"src/client/views\",\n    \"src/client/scripts/cjs/game/htmlscript.js\"\n  ],\n  \"ext\": \"js,ejs,html\",\n  \"exec\": \"node --enable-source-maps dist/server/server.js\",\n  \"delay\": \"200\"\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"infinite-chess-server\",\n  \"version\": \"1.4.3\",\n  \"description\": \"infinitechess.org server\",\n  \"author\": \"Naviary\",\n  \"license\": \"AGPL\",\n  \"main\": \"dist/server/server.js\",\n  \"type\": \"module\",\n  \"dependencies\": {\n    \"@aws-sdk/client-sesv2\": \"^3.985.0\",\n    \"@aws-sdk/credential-providers\": \"^3.985.0\",\n    \"@naviary/bigdecimal\": \"^1.0.1\",\n    \"abort-controller\": \"^3.0.0\",\n    \"bcrypt\": \"^6.0.0\",\n    \"better-sqlite3\": \"^11.5.0\",\n    \"cookie-parser\": \"^1.4.6\",\n    \"cors\": \"^2.8.5\",\n    \"date-fns\": \"^2.23.0\",\n    \"dotenv\": \"^16.0.3\",\n    \"ejs\": \"^3.1.10\",\n    \"express\": \"^4.18.2\",\n    \"express-rate-limit\": \"^8.2.1\",\n    \"helmet\": \"^8.1.0\",\n    \"i18next\": \"^23.12.1\",\n    \"i18next-http-middleware\": \"^3.6.0\",\n    \"jsonwebtoken\": \"^9.0.3\",\n    \"marked\": \"^14.1.2\",\n    \"node-email-verifier\": \"^2.0.0\",\n    \"node-forge\": \"^1.3.1\",\n    \"nodemailer\": \"^7.0.11\",\n    \"obscenity\": \"^0.4.5\",\n    \"proper-lockfile\": \"^4.1.2\",\n    \"smol-toml\": \"^1.2.2\",\n    \"sns-validator\": \"^0.3.5\",\n    \"uuid\": \"^8.3.2\",\n    \"ws\": \"^8.16.0\",\n    \"xss\": \"^1.0.15\",\n    \"zod\": \"^4.0.5\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.39.1\",\n    \"@swc/core\": \"^1.7.0\",\n    \"@types/bcrypt\": \"^5.0.2\",\n    \"@types/better-sqlite3\": \"^7.6.12\",\n    \"@types/cookie-parser\": \"^1.4.10\",\n    \"@types/cors\": \"^2.8.19\",\n    \"@types/ejs\": \"^3.1.5\",\n    \"@types/express\": \"^5.0.0\",\n    \"@types/express-rate-limit\": \"^5.1.3\",\n    \"@types/jsonwebtoken\": \"^9.0.10\",\n    \"@types/madge\": \"^5.0.3\",\n    \"@types/node\": \"^22.10.1\",\n    \"@types/node-forge\": \"^1.3.14\",\n    \"@types/nodemailer\": \"^6.4.17\",\n    \"@types/proper-lockfile\": \"^4.1.4\",\n    \"@types/sns-validator\": \"^0.3.3\",\n    \"@types/supertest\": \"^6.0.3\",\n    \"@types/uuid\": \"^10.0.0\",\n    \"@types/ws\": \"^8.5.13\",\n    \"@typescript-eslint/eslint-plugin\": \"^8.37.0\",\n    \"@typescript-eslint/parser\": \"^8.37.0\",\n    \"browserslist\": \"^4.23.2\",\n    \"concurrently\": \"^9.2.1\",\n    \"cpx\": \"^1.5.0\",\n    \"esbuild\": \"^0.25.0\",\n    \"eslint\": \"^9.39.1\",\n    \"eslint-config-prettier\": \"^10.1.8\",\n    \"fake-indexeddb\": \"^6.2.5\",\n    \"glob\": \"^11.0.0\",\n    \"globals\": \"^15.15.0\",\n    \"glsl-strip-comments\": \"^1.0.0\",\n    \"husky\": \"^9.1.7\",\n    \"lightningcss\": \"^1.25.1\",\n    \"lint-staged\": \"^16.2.7\",\n    \"madge\": \"^8.0.0\",\n    \"nodemon\": \"^3.1.10\",\n    \"prettier\": \"^3.7.3\",\n    \"rimraf\": \"^6.0.1\",\n    \"sharp\": \"^0.33.4\",\n    \"supertest\": \"^7.1.4\",\n    \"tsx\": \"^4.20.6\",\n    \"typescript\": \"^5.7.2\",\n    \"typescript-eslint\": \"^8.48.0\",\n    \"vitest\": \"^4.0.2\",\n    \"wait-on\": \"^9.0.0\"\n  },\n  \"scripts\": {\n    \"clean\": \"rimraf dist\",\n    \"build\": \"npm run generate:types && npm run clean && npm run copy:views && npm run prod:assets && tsx build/index.ts\",\n    \"start\": \"node dist/server/server.js\",\n    \"prod:assets\": \"cpx \\\"src/**/*.{css,png,jpg,webp,avif,svg,ico,gif,json,mp3,wav,opus,glsl,md,woff2,woff}\\\" dist\",\n    \"copy:views\": \"cpx \\\"src/client/views/**/*\\\" dist/client/views\",\n    \"dev\": \"npm run clean && concurrently -k \\\"npm:dev:*\\\" --prefix-colors \\\"green,blue,grey,white\\\"\",\n    \"dev:build\": \"npm run generate:types && npm run copy:views && tsx build/index.ts --dev\",\n    \"dev:tsc\": \"tsc --noEmit --watch --preserveWatchOutput\",\n    \"dev:server\": \"wait-on dist/server/server.js && nodemon\",\n    \"dev:assets\": \"cpx \\\"src/**/*.{css,png,jpg,webp,avif,svg,ico,gif,json,mp3,wav,opus,glsl,md,woff2,woff}\\\" dist --watch\",\n    \"generate:types\": \"tsx scripts/generate-translation-types.ts\",\n    \"optimize-images\": \"tsx scripts/optimize-images.ts\",\n    \"generate-dependency-graph\": \"tsx src/server/utility/generateDependancyGraph.ts\",\n    \"test\": \"vitest run\",\n    \"test:watch\": \"vitest\",\n    \"format\": \"prettier . --write\",\n    \"format:check\": \"prettier . --check\",\n    \"lint\": \"eslint .\",\n    \"prepare\": \"husky || true\"\n  },\n  \"lint-staged\": {\n    \"**/*\": \"prettier --write --ignore-unknown\",\n    \"**/*.ts\": \"tsx scripts/organize-imports.ts\",\n    \"**/*.{js,ts,cjs}\": \"tsx scripts/add-file-paths.ts\"\n  }\n}\n"
  },
  {
    "path": "scripts/add-file-paths.ts",
    "content": "// scripts/add-file-paths.ts\n\n/**\n * This script ensures all .js and .ts files have their relative file path\n * on the first line in the format: `// <relative-path>`\n * followed by an empty line.\n *\n * It intelligently detects existing path comments (correct or incorrect)\n * and updates them as needed to avoid duplicates.\n */\n\nimport { relative, resolve } from 'node:path';\nimport { readFileSync, writeFileSync } from 'node:fs';\n\n/**\n * Checks if a line looks like a file path comment.\n * Returns the path if it matches the pattern, otherwise null.\n */\nfunction extractPathFromComment(line: string): string | null {\n\tconst match = line.match(/^\\/\\/\\s*(.+)$/);\n\tif (!match || !match[1]) return null;\n\n\tconst content = match[1].trim();\n\t// Check if it looks like a file path (ends with .ts, .js, or .cjs)\n\tif (content.match(/\\.(ts|js|cjs)$/)) {\n\t\treturn content;\n\t}\n\treturn null;\n}\n\n/** Processes a single file to ensure it has the correct path comment. */\nfunction processFile(filePath: string): void {\n\tconst content = readFileSync(filePath, 'utf-8');\n\tconst lines = content.split('\\n');\n\n\t// Calculate the correct relative path from repo root\n\tconst repoRoot = process.cwd();\n\tconst absolutePath = resolve(filePath);\n\tconst relativePath = relative(repoRoot, absolutePath);\n\tconst correctPath = relativePath.replace(/\\\\/g, '/');\n\tconst correctComment = `// ${correctPath}`;\n\n\t// Check the first line\n\tconst firstLine = lines[0] || '';\n\tconst existingPath = extractPathFromComment(firstLine);\n\n\t// Determine what changes are needed\n\tlet needsUpdate = false;\n\tlet newLines: string[];\n\n\tif (existingPath === null) {\n\t\t// No path comment exists on line 1\n\t\t// Check if line 1 is empty and line 2 might have a path comment\n\t\tif (firstLine === '' && lines.length > 1) {\n\t\t\tconst secondLinePath = extractPathFromComment(lines[1] || '');\n\t\t\tif (secondLinePath !== null) {\n\t\t\t\t// There's an empty line followed by a path comment - fix it\n\t\t\t\tlines.shift(); // Remove the empty first line\n\t\t\t\tif (secondLinePath !== correctPath) {\n\t\t\t\t\t// Incorrect path on line 2 (now line 1)\n\t\t\t\t\tlines[0] = correctComment;\n\t\t\t\t\tneedsUpdate = true;\n\t\t\t\t}\n\t\t\t\t// Ensure empty line after path\n\t\t\t\tif (lines.length < 2 || lines[1] !== '') {\n\t\t\t\t\tlines.splice(1, 0, '');\n\t\t\t\t\tneedsUpdate = true;\n\t\t\t\t}\n\t\t\t\tnewLines = lines;\n\t\t\t} else {\n\t\t\t\t// Empty first line but no path comment - add path comment at the beginning\n\t\t\t\tnewLines = [correctComment, '', ...lines];\n\t\t\t\tneedsUpdate = true;\n\t\t\t}\n\t\t} else {\n\t\t\t// No path comment at all - add it at the beginning\n\t\t\tnewLines = [correctComment, '', ...lines];\n\t\t\tneedsUpdate = true;\n\t\t}\n\t} else {\n\t\t// Path comment exists on line 1\n\t\tif (existingPath !== correctPath) {\n\t\t\t// Incorrect path - update it\n\t\t\tlines[0] = correctComment;\n\t\t\tneedsUpdate = true;\n\t\t}\n\n\t\t// Ensure there's an empty line after the path comment\n\t\tif (lines.length < 2 || lines[1] !== '') {\n\t\t\tlines.splice(1, 0, '');\n\t\t\tneedsUpdate = true;\n\t\t}\n\n\t\tnewLines = lines;\n\t}\n\n\t// Write the file if changes were made\n\tif (needsUpdate) {\n\t\tconst newContent = newLines.join('\\n');\n\t\twriteFileSync(filePath, newContent, 'utf-8');\n\t\tconsole.log(filePath);\n\t}\n}\n\n/** Main entry point for the script. */\nfunction main(): void {\n\tconst args = process.argv.slice(2);\n\n\tif (args.length === 0) {\n\t\tconsole.error('No files provided. Usage: tsx add-file-paths.ts <file1> <file2> ...');\n\t\tprocess.exit(1);\n\t}\n\n\t// Filter for only .js, .ts, and .cjs files\n\tconst jsAndTsFiles = args.filter((file) => file.match(/\\.(js|ts|cjs)$/));\n\n\tfor (const file of jsAndTsFiles) {\n\t\ttry {\n\t\t\tprocessFile(file);\n\t\t} catch (error) {\n\t\t\tconsole.error(`Error processing ${file}:`, error);\n\t\t}\n\t}\n\n\tconsole.log(`Updated path in ${jsAndTsFiles.length} files.`);\n}\n\nmain();\n"
  },
  {
    "path": "scripts/generate-translation-types.ts",
    "content": "// scripts/generate-translation-types.ts\n\n/**\n * Generates TypeScript types from the English translation TOML file.\n * Creates two type structures:\n * 1. Flat dot-notation union type for server-side i18next\n * 2. Nested object type for client-side property access\n */\n\nimport fs from 'fs';\nimport path from 'path';\nimport { fileURLToPath } from 'node:url';\nimport { parse, TomlTable } from 'smol-toml';\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\nconst translationFile = path.join(__dirname, '../translation/en-US.toml');\nconst relativeOutputFilePath = 'src/types/translations.ts';\nconst outputFile = path.join(__dirname, `../${relativeOutputFilePath}`);\n\n/**\n * Recursively generates all dot-notation paths for a nested object.\n * @param obj - The object to traverse\n * @param prefix - The current path prefix\n * @returns Array of dot-notation paths\n */\nfunction generateDotPaths(obj: TomlTable | any, prefix = ''): string[] {\n\tconst paths: string[] = [];\n\n\tfor (const [key, value] of Object.entries(obj)) {\n\t\tconst path = prefix ? `${prefix}.${key}` : key;\n\n\t\tif (value !== null && typeof value === 'object' && !Array.isArray(value)) {\n\t\t\t// Recursively traverse nested objects\n\t\t\tpaths.push(...generateDotPaths(value, path));\n\t\t} else {\n\t\t\t// Leaf node - add the path\n\t\t\tpaths.push(path);\n\t\t}\n\t}\n\n\treturn paths;\n}\n\n/**\n * Generates a TypeScript interface from a nested object structure.\n * @param obj - The object to convert to a type\n * @param indentLevel - Current indentation level\n * @returns TypeScript interface string\n */\nfunction generateNestedType(obj: TomlTable | any, indentLevel = 1): string {\n\tconst indent = '\\t'.repeat(indentLevel);\n\tconst lines: string[] = [];\n\n\tfor (const [key, value] of Object.entries(obj)) {\n\t\t// Handle keys with hyphens or other special characters\n\t\tconst safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : `'${key}'`;\n\n\t\tif (value !== null && typeof value === 'object' && !Array.isArray(value)) {\n\t\t\t// Nested object - add index signature for dynamic access\n\t\t\tlines.push(`${indent}${safeKey}: {`);\n\t\t\tlines.push(generateNestedType(value, indentLevel + 1));\n\t\t\tlines.push(`${indent}};`);\n\t\t} else if (Array.isArray(value)) {\n\t\t\t// Array type\n\t\t\tlines.push(`${indent}${safeKey}: string[];`);\n\t\t} else {\n\t\t\t// Primitive type\n\t\t\tlines.push(`${indent}${safeKey}: string;`);\n\t\t}\n\t}\n\n\treturn lines.join('\\n');\n}\n\n/** Main function to generate translation types. */\nfunction generateTypes(): void {\n\tconst tomlContent = fs.readFileSync(translationFile, 'utf-8');\n\tconst parsed = parse(tomlContent);\n\tconst dotPaths = generateDotPaths(parsed);\n\tconst nestedType = generateNestedType(parsed);\n\n\t// Create the output TypeScript file\n\tconst output = `// ${relativeOutputFilePath}\n\n/**\n * This file is auto-generated by scripts/generate-translation-types.ts on build.\n * Do NOT edit manually!\n */\n\n/**\n * Flat dot-notation union type for server-side i18next.\n * Use with i18next.t() function.\n * @example\n * i18next.t(\"play.javascript.termination.checkmate\")\n */\nexport type TranslationKeys =${dotPaths.map((p) => `\\n\\t| '${p}'`).join('')};\n\n/**\n * Nested object type for client-side translation access.\n * Represents the full structure of the translation object.\n */\nexport interface TranslationsObject {\n${nestedType}\n}\n`;\n\n\tfs.writeFileSync(outputFile, output, 'utf-8');\n\n\tconsole.log(\n\t\t`[generate-translation-types] Generated translation types (${dotPaths.length} keys).`,\n\t);\n}\n\n// Run the generator\ntry {\n\tgenerateTypes();\n} catch (error) {\n\tconsole.error('Error generating translation types:', error);\n\tprocess.exit(1);\n}\n"
  },
  {
    "path": "scripts/optimize-images.ts",
    "content": "// scripts/optimize-images.ts\n\n/**\n * This script automatically finds and compresses all images from the source\n * directory that haven't already been fully optimized in the destination directory.\n *\n * Steps:\n *\n * 1. Place new or updated images in `dev-utils/image-sources/`.\n *    The same subdirectory structure will be maintained.\n *\n * 2. Run the command:\n *    npm run optimize-images\n *\n * Any images that already have at least one version .webp, .png, or .avif\n * in `src/client/img/` will be skipped. This is because sometimes we only need one format.\n */\n\nimport path from 'path';\nimport sharp from 'sharp';\nimport { fileURLToPath } from 'node:url';\nimport { existsSync, readdirSync, statSync, mkdirSync } from 'node:fs';\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\n// --- CONFIGURATION ---\n\n// Effort values. Higher mean better compression but longer processing time.\nconst webp_options = {\n\teffort: 6, // 0-6\n\tquality: 100, // Controls visual quality (1-100). Default if not specified: 80. USE 100 FOR NOISE TEXTURES!\n};\nconst png_options = {\n\teffort: 10, // 1-10. LOWER YIELDS BETTER COMPRESSION??? But lower image quality.\n\tquality: 100, // Default if not specified: 100.\n};\nconst avif_options = {\n\teffort: 9, // 0-9\n\tquality: 100, // Default if not specified: 50.\n};\n\n// Source folder for original images\nconst src_path = path.join(__dirname, `dev-utils/image-sources/`);\n// Destination folder for compressed images\nconst dest_path = path.join(__dirname, `src/client/img/`);\n\nconst supportedExtensions = ['.png', '.jpg', '.jpeg'];\n\n// --- LOGIC ---\n\n/**\n * Recursively finds all image files in a directory.\n * @param {string} dirPath The directory to search.\n * @returns {string[]} An array of full paths to image files.\n */\nfunction getAllImagePaths(dirPath: string): string[] {\n\tconst allEntries = readdirSync(dirPath);\n\tconst files: string[] = [];\n\n\tfor (const entry of allEntries) {\n\t\tconst fullPath = path.join(dirPath, entry);\n\t\tconst stats = statSync(fullPath);\n\n\t\tif (stats.isDirectory()) {\n\t\t\tfiles.push(...getAllImagePaths(fullPath)); // Recurse into subdirectories\n\t\t} else if (supportedExtensions.includes(path.extname(entry).toLowerCase())) {\n\t\t\tfiles.push(fullPath);\n\t\t}\n\t}\n\treturn files;\n}\n\nconsole.log('Scanning for images to process...');\n\n// 1. Find all source images\nconst allSourceImages = getAllImagePaths(src_path);\n\n// 2. Filter out images that are already fully optimized\nconst imagesToProcess = allSourceImages.filter((sourceImagePath) => {\n\t// Get the path relative to the source directory (e.g., 'badges/my-badge.png')\n\tconst relativePath = path.relative(src_path, sourceImagePath);\n\t// Remove the original extension to create a base path for output files\n\tconst destBasePath = path.join(dest_path, relativePath.replace(/\\.[^/.]+$/, ''));\n\n\t// Check if all three target formats already exist\n\tconst webpExists = existsSync(`${destBasePath}.webp`);\n\tconst pngExists = existsSync(`${destBasePath}.png`);\n\tconst avifExists = existsSync(`${destBasePath}.avif`);\n\n\t// If at least one exists, we can skip it. Otherwise, it needs processing.\n\treturn !(webpExists || pngExists || avifExists);\n});\n\nif (imagesToProcess.length === 0) {\n\tconsole.log('All images are already up-to-date. Nothing to do.');\n\tprocess.exit(0);\n}\n\nconsole.log(`Found ${imagesToProcess.length} image(s) that need optimization.`);\n\n// 3. Process the filtered images\nlet finished_images = 0;\nconst total_images = imagesToProcess.length * 3;\n\nfunction logProgress(imageName: string, format: string): void {\n\tfinished_images += 1;\n\tconst percentage = Math.round((finished_images / total_images) * 100);\n\tconsole.log(\n\t\t`[${percentage}%] Optimized ${path.basename(imageName)} to ${format.toUpperCase()}`,\n\t);\n\n\tif (finished_images === total_images) {\n\t\tconsole.log('\\nDone. All images have been processed.');\n\t}\n}\n\nconsole.log('Converting images...');\n\nfor (const sourceImagePath of imagesToProcess) {\n\tconst relativePath = path.relative(src_path, sourceImagePath);\n\tconst destBasePath = path.join(dest_path, relativePath.replace(/\\.[^/.]+$/, ''));\n\n\t// Ensure the output directory exists before writing files\n\tconst outputDir = path.dirname(destBasePath);\n\tif (!existsSync(outputDir)) {\n\t\tmkdirSync(outputDir, { recursive: true });\n\t}\n\n\tconst imageProcessor = sharp(sourceImagePath);\n\n\t// Generate .webp\n\timageProcessor.webp(webp_options).toFile(`${destBasePath}.webp`, (err) => {\n\t\tif (err) console.error(`Error converting ${relativePath} to WEBP:`, err);\n\t\tlogProgress(relativePath, 'webp');\n\t});\n\n\t// Generate .png (re-optimizing the original)\n\timageProcessor.png(png_options).toFile(`${destBasePath}.png`, (err) => {\n\t\tif (err) console.error(`Error converting ${relativePath} to PNG:`, err);\n\t\tlogProgress(relativePath, 'png');\n\t});\n\n\t// Generate .avif\n\timageProcessor.avif(avif_options).toFile(`${destBasePath}.avif`, (err) => {\n\t\tif (err) console.error(`Error converting ${relativePath} to AVIF:`, err);\n\t\tlogProgress(relativePath, 'avif');\n\t});\n}\n"
  },
  {
    "path": "scripts/organize-imports.ts",
    "content": "// scripts/organize-imports.ts\n\n/**\n * TypeScript Import Organizer\n *\n * PREREQUISITES:\n * - All import statements must end in a semicolon `;`\n *\n * Usage: tsx scripts/organize-imports.ts <file1> <file2> ...\n *\n * Run on all files:\n * npx tsx scripts/organize-imports.ts $(find build src scripts -name \"*.ts\") *.ts\n *\n * ========================================\n * IMPORT ORGANIZATION RULES\n * ========================================\n *\n * BOUNDARY DETECTION:\n * - Import section starts at the first import statement\n * - Import section ends at the last import statement, or where we encounter the first non-import, non-comment line.\n * - Everything above and below is preserved as-is\n * - All comments within the import boundary (except @ts-ignore, and inline comments on import lines) are deleted\n *\n * GROUPING (groups separated by blank line):\n * 1. Type imports (package and source together, no separation)\n * 2. Regular package imports\n * 3. Regular source imports from shared (src/shared/)\n * 4. Regular source imports from client (src/client/)\n * 5. Regular source imports from tests (src/tests/)\n * 6. Regular source imports from server (src/server/)\n * 7. Side-effect imports\n *\n * SORTING WITHIN GROUPS:\n * - Multi-line imports last\n * - Then by length before \"from\"\n *\n * SPACING:\n * - One blank line above imports (unless at file top)\n * - One blank line below imports\n * - Blank lines between groups\n */\n\nimport * as fs from 'fs';\nimport * as path from 'path';\n\n// Constants ---------------------------------------------------------------\n\n/** Regex pattern to match \" from \" followed by a quote in import statements */\nconst FROM_WITH_QUOTE_PATTERN = /\\sfrom\\s+['\"]/;\n\n/** Path to the shared directory */\nconst SHARED_DIR = path.resolve(process.cwd(), 'src/shared');\n/** Path to the client directory */\nconst CLIENT_DIR = path.resolve(process.cwd(), 'src/client');\n/** Path to the tests directory */\nconst TESTS_DIR = path.resolve(process.cwd(), 'src/tests');\n/** Path to the server directory */\nconst SERVER_DIR = path.resolve(process.cwd(), 'src/server');\n\n// Types -------------------------------------------------------------------\n\ninterface Import {\n\traw: string;\n\tisType: boolean;\n\tisPackage: boolean;\n\tisSideEffect: boolean;\n\tisMultiLine: boolean;\n\tlengthBeforeFrom: number;\n\t/** Which source directory this relative import belongs to, or null if it's a package import or not in shared/client/tests/server directories */\n\tsourceDir: 'shared' | 'client' | 'tests' | 'server' | null;\n}\n\n// Helper Functions --------------------------------------------------------\n\n/**\n * Resolves an import path from the current file and determines which source directory it belongs to.\n * @param currentFilePath - Absolute path to the file being processed\n * @param importPath - The path from the import statement (e.g., '../../../shared/util/timeutil.js')\n * @returns 'shared', 'client', 'tests', 'server', or null if not in any of these directories\n */\nfunction resolveImportSourceDir(\n\tcurrentFilePath: string,\n\timportPath: string,\n): 'shared' | 'client' | 'tests' | 'server' | null {\n\t// Don't process package imports\n\tif (!importPath.startsWith('.') && !path.isAbsolute(importPath)) {\n\t\treturn null;\n\t}\n\n\t// Resolve the import path relative to the current file's directory\n\tconst currentFileDir = path.dirname(currentFilePath);\n\tconst resolvedImportPath = path.resolve(currentFileDir, importPath);\n\n\t// Check if the resolved path is within one of our source directories\n\t// We need to ensure proper directory boundaries (not just string prefix matching)\n\tconst sharedDirWithSep = SHARED_DIR + path.sep;\n\tconst clientDirWithSep = CLIENT_DIR + path.sep;\n\tconst testsDirWithSep = TESTS_DIR + path.sep;\n\tconst serverDirWithSep = SERVER_DIR + path.sep;\n\n\tif (resolvedImportPath === SHARED_DIR || resolvedImportPath.startsWith(sharedDirWithSep)) {\n\t\treturn 'shared';\n\t} else if (\n\t\tresolvedImportPath === CLIENT_DIR ||\n\t\tresolvedImportPath.startsWith(clientDirWithSep)\n\t) {\n\t\treturn 'client';\n\t} else if (resolvedImportPath === TESTS_DIR || resolvedImportPath.startsWith(testsDirWithSep)) {\n\t\treturn 'tests';\n\t} else if (\n\t\tresolvedImportPath === SERVER_DIR ||\n\t\tresolvedImportPath.startsWith(serverDirWithSep)\n\t) {\n\t\treturn 'server';\n\t}\n\n\treturn null;\n}\n\nfunction parseImport(importText: string, hasTsIgnore: boolean, currentFilePath: string): Import {\n\tconst lines = importText.split('\\n');\n\tconst importLine = hasTsIgnore ? lines[lines.length - 1]! : lines[0]!;\n\tconst trimmed = importLine.trim();\n\n\t// Check if type import\n\tconst isType = trimmed.startsWith('import type ') || trimmed.startsWith('import type{');\n\n\t// Check if side-effect import (no bindings)\n\tconst isSideEffect = /^import\\s+['\"][^'\"]+['\"];?/.test(trimmed);\n\n\t// Determine if package or source\n\tconst fromMatch = importText.match(/from\\s+(['\"])(.*?)\\1/);\n\tconst fromPath = fromMatch ? fromMatch[2]! : '';\n\tconst isPackage = !!fromPath && !fromPath.startsWith('.') && !fromPath.startsWith('/');\n\n\t// Determine which source directory the import belongs to\n\tconst sourceDir = isPackage ? null : resolveImportSourceDir(currentFilePath, fromPath);\n\n\t// Calculate length before \"from\" followed by whitespace and a quote\n\t// For ts-ignore imports, calculate from the import line only, not including the comment\n\tconst textForLength = hasTsIgnore ? importLine : importText;\n\tconst match = FROM_WITH_QUOTE_PATTERN.exec(textForLength);\n\tconst lengthBeforeFrom = match ? match.index : textForLength.length;\n\n\t// Check if multi-line\n\tconst isMultiLine = importText.includes('\\n') && !hasTsIgnore;\n\n\treturn {\n\t\traw: importText,\n\t\tisType,\n\t\tisPackage,\n\t\tisSideEffect,\n\t\tisMultiLine,\n\t\tlengthBeforeFrom,\n\t\tsourceDir,\n\t};\n}\n\nfunction compareImports(a: Import, b: Import): number {\n\t// First: multi-line imports come last\n\tif (a.isMultiLine !== b.isMultiLine) {\n\t\treturn a.isMultiLine ? 1 : -1;\n\t}\n\n\t// Second: by length before \"from\"\n\treturn a.lengthBeforeFrom - b.lengthBeforeFrom;\n}\n\n// Import Extraction -------------------------------------------------------\n\nfunction findImportBoundaries(lines: string[]): { start: number; end: number } | null {\n\tlet start = -1;\n\tlet end = -1;\n\n\tfor (let i = 0; i < lines.length; i++) {\n\t\tconst trimmed = lines[i]!.trim();\n\n\t\t// Skip empty lines\n\t\tif (!trimmed) continue;\n\t\t// Check for comments\n\t\telse if (trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*')) {\n\t\t\tcontinue;\n\t\t}\n\t\t// Check for import\n\t\telse if (trimmed.startsWith('import ')) {\n\t\t\tif (start === -1) start = i;\n\t\t\tend = i;\n\n\t\t\t// Handle multi-line imports\n\t\t\t// Imports end at a non-commented semicolon\n\t\t\twhile (i < lines.length - 1 && !lines[i]!.split('//')[0]!.includes(';')) {\n\t\t\t\ti++;\n\t\t\t\tend = i;\n\t\t\t}\n\t\t} else {\n\t\t\t// SAFETY STOP: We hit code that is NOT an import and NOT a comment.\n\t\t\t// If we have found an import block already, stop looking.\n\t\t\tif (start !== -1) break;\n\t\t}\n\t}\n\n\treturn start !== -1 ? { start, end } : null;\n}\n\nfunction extractImports(\n\tcontent: string,\n\tfilePath: string,\n): {\n\timports: Import[];\n\tbeforeImports: string;\n\tafterImports: string;\n} {\n\tconst lines = content.split('\\n');\n\tconst boundaries = findImportBoundaries(lines);\n\n\t// console.log('Import boundaries:', boundaries);\n\n\tif (!boundaries) {\n\t\treturn {\n\t\t\timports: [],\n\t\t\tbeforeImports: content,\n\t\t\tafterImports: '',\n\t\t};\n\t}\n\n\t// Find all @ts-ignore lines before the start\n\tlet actualStart = boundaries.start;\n\twhile (actualStart > 0 && lines[actualStart - 1]!.trim().startsWith('// @ts-ignore')) {\n\t\tactualStart--;\n\t}\n\n\tconst beforeImports = lines.slice(0, actualStart).join('\\n');\n\tconst afterImports = lines.slice(boundaries.end + 1).join('\\n');\n\n\t// Extract imports within boundaries\n\tconst imports: Import[] = [];\n\tlet i = actualStart;\n\tlet hasTsIgnore = false; // Move outside loop\n\tlet tsIgnoreLine = ''; // Move outside loop\n\n\twhile (i <= boundaries.end) {\n\t\tconst line = lines[i]!;\n\t\tconst trimmed = line.trim();\n\n\t\t// Check for @ts-ignore\n\t\tif (trimmed.startsWith('// @ts-ignore')) {\n\t\t\thasTsIgnore = true;\n\t\t\ttsIgnoreLine = line;\n\t\t\ti++;\n\t\t\tif (i > boundaries.end) break;\n\t\t\tcontinue; // Continue to next line\n\t\t}\n\n\t\t// Skip empty lines and all comments (except we already handled @ts-ignore)\n\t\tif (!trimmed || (trimmed.startsWith('//') && !trimmed.startsWith('import '))) {\n\t\t\ti++;\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Skip multi-line comments\n\t\tif (trimmed.startsWith('/*') || trimmed.startsWith('/**')) {\n\t\t\twhile (i <= boundaries.end && !lines[i]!.includes('*/')) {\n\t\t\t\ti++;\n\t\t\t}\n\t\t\ti++; // Skip the closing */\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Process import statement\n\t\tif (trimmed.startsWith('import ')) {\n\t\t\tlet importText = line;\n\t\t\ti++;\n\n\t\t\t// Collect multi-line import\n\t\t\t// TODO: WHY IS THIS SO WEIRD?!\n\t\t\t// \"Stop if the previous line contains a semicolon\"?!\n\t\t\twhile (i <= boundaries.end && !lines[i - 1]!.split('//')[0]!.includes(';')) {\n\t\t\t\timportText += '\\n' + lines[i]; // Add the next line\n\t\t\t\ti++;\n\t\t\t\t// console.log('Collecting multi-line import:', lines[i]);\n\t\t\t}\n\n\t\t\t// Prepend ts-ignore if present\n\t\t\tif (hasTsIgnore) {\n\t\t\t\timportText = tsIgnoreLine + '\\n' + importText;\n\t\t\t}\n\n\t\t\t// console.log('Whole import:');\n\t\t\t// console.log(importText);\n\t\t\t// console.log('\\n');\n\n\t\t\tconst parsedImport = parseImport(importText, hasTsIgnore, filePath);\n\n\t\t\t// console.log('Parsed import:', parsedImport, '\\n');\n\n\t\t\timports.push(parsedImport);\n\n\t\t\t// Reset ts-ignore flag after using it\n\t\t\thasTsIgnore = false;\n\t\t\ttsIgnoreLine = '';\n\t\t} else {\n\t\t\ti++;\n\t\t}\n\t}\n\n\treturn { imports, beforeImports, afterImports };\n}\n\n// Import Sorting ----------------------------------------------------------\n\nfunction organizeImports(imports: Import[]): string {\n\t// Group imports\n\tconst typeImports: Import[] = [];\n\tconst packageImports: Import[] = [];\n\tconst sharedImports: Import[] = [];\n\tconst clientImports: Import[] = [];\n\tconst testsImports: Import[] = [];\n\tconst serverImports: Import[] = [];\n\tconst otherSourceImports: Import[] = []; // For relative imports outside shared/client/tests/server (e.g., from src/types)\n\tconst sideEffectImports: Import[] = [];\n\n\tfor (const imp of imports) {\n\t\tif (imp.isSideEffect) {\n\t\t\tsideEffectImports.push(imp);\n\t\t} else if (imp.isType) {\n\t\t\ttypeImports.push(imp);\n\t\t} else if (imp.isPackage) {\n\t\t\tpackageImports.push(imp);\n\t\t} else {\n\t\t\t// Source imports - categorize by directory\n\t\t\tif (imp.sourceDir === 'shared') {\n\t\t\t\tsharedImports.push(imp);\n\t\t\t} else if (imp.sourceDir === 'client') {\n\t\t\t\tclientImports.push(imp);\n\t\t\t} else if (imp.sourceDir === 'tests') {\n\t\t\t\ttestsImports.push(imp);\n\t\t\t} else if (imp.sourceDir === 'server') {\n\t\t\t\tserverImports.push(imp);\n\t\t\t} else {\n\t\t\t\totherSourceImports.push(imp);\n\t\t\t}\n\t\t}\n\t}\n\n\t// Sort each group\n\ttypeImports.sort((a, b) => {\n\t\t// Within types: package before source\n\t\tif (a.isPackage !== b.isPackage) {\n\t\t\treturn a.isPackage ? -1 : 1;\n\t\t}\n\t\treturn compareImports(a, b);\n\t});\n\n\tpackageImports.sort(compareImports);\n\tsharedImports.sort(compareImports);\n\tclientImports.sort(compareImports);\n\ttestsImports.sort(compareImports);\n\tserverImports.sort(compareImports);\n\totherSourceImports.sort(compareImports);\n\tsideEffectImports.sort((a, b) => a.raw.length - b.raw.length);\n\n\t// Build groups array\n\tconst groups: string[] = [];\n\n\tif (typeImports.length > 0) {\n\t\tgroups.push(typeImports.map((i) => i.raw).join('\\n'));\n\t}\n\n\tif (packageImports.length > 0) {\n\t\tgroups.push(packageImports.map((i) => i.raw).join('\\n'));\n\t}\n\n\t// Add source imports in order: shared, client, tests, server\n\tif (sharedImports.length > 0) {\n\t\tgroups.push(sharedImports.map((i) => i.raw).join('\\n'));\n\t}\n\n\tif (clientImports.length > 0) {\n\t\tgroups.push(clientImports.map((i) => i.raw).join('\\n'));\n\t}\n\n\tif (testsImports.length > 0) {\n\t\tgroups.push(testsImports.map((i) => i.raw).join('\\n'));\n\t}\n\n\tif (serverImports.length > 0) {\n\t\tgroups.push(serverImports.map((i) => i.raw).join('\\n'));\n\t}\n\n\t// Other source imports that don't belong to shared/client/server\n\tif (otherSourceImports.length > 0) {\n\t\tgroups.push(otherSourceImports.map((i) => i.raw).join('\\n'));\n\t}\n\n\tif (sideEffectImports.length > 0) {\n\t\tgroups.push(sideEffectImports.map((i) => i.raw).join('\\n'));\n\t}\n\n\t// Join groups with blank lines\n\treturn groups.join('\\n\\n');\n}\n\n// File Processing ---------------------------------------------------------\n\nfunction processFile(filePath: string): boolean {\n\ttry {\n\t\tconst content = fs.readFileSync(filePath, 'utf-8');\n\t\tconst absoluteFilePath = path.resolve(filePath);\n\t\tconst { imports, beforeImports, afterImports } = extractImports(content, absoluteFilePath);\n\n\t\tif (imports.length === 0) {\n\t\t\treturn false;\n\t\t}\n\n\t\tconst organizedImports = organizeImports(imports);\n\n\t\t// Build new content\n\t\tlet newContent = '';\n\n\t\t// Add content before imports\n\t\tif (beforeImports) {\n\t\t\tnewContent = beforeImports.trimEnd() + '\\n\\n';\n\t\t}\n\n\t\t// Add organized imports\n\t\tnewContent += organizedImports;\n\n\t\t// Add content after imports\n\t\tif (afterImports) {\n\t\t\tnewContent += '\\n\\n' + afterImports.trimStart();\n\t\t}\n\n\t\t// Write if changed\n\t\tif (content !== newContent) {\n\t\t\tfs.writeFileSync(filePath, newContent, 'utf-8');\n\t\t\treturn true;\n\t\t}\n\t} catch (error) {\n\t\tconsole.error(`Error processing ${filePath}:`, error);\n\t}\n\n\treturn false;\n}\n\n// Main Execution ----------------------------------------------------------\n\nfunction main(): void {\n\tconst args = process.argv.slice(2);\n\n\tif (args.length === 0) {\n\t\tconsole.error('No files provided. Usage: tsx organize-imports.ts <file1> <file2> ...');\n\t\tprocess.exit(1);\n\t}\n\n\t// Filter for only .ts files\n\tconst tsFiles = args.filter((f) => f.endsWith('.ts'));\n\n\tlet changed = 0;\n\n\tfor (const file of tsFiles) {\n\t\tif (!fs.existsSync(file)) continue;\n\t\tif (!processFile(file)) continue;\n\n\t\tconst relative = path.isAbsolute(file) ? path.relative(process.cwd(), file) : file;\n\t\tconsole.log(relative);\n\t\tchanged++;\n\t}\n\n\tif (changed > 0) {\n\t\tconsole.log(`Organized imports in ${changed} file(s).`);\n\t}\n}\n\nmain();\n"
  },
  {
    "path": "scripts/readme.md",
    "content": "This directory contains scripts used explicitly by project configuration, such as npm scripts, or pre-commit hooks. They are not imported into any source code.\n"
  },
  {
    "path": "src/client/css/404.css",
    "content": "* {\n\tmargin: 0;\n\tpadding: 0;\n}\n\nbody {\n\tpadding-top: 30px;\n\tbackground-color: aliceblue;\n\ttext-align: center;\n}\n\nh1 {\n\tfont-size: 40px;\n}\n\np {\n\tmargin: 1em;\n}\n"
  },
  {
    "path": "src/client/css/admin.css",
    "content": "* {\n\tbackground-color: rgb(20, 20, 20);\n\tcolor: white;\n}\nbody {\n\tpadding: 30px 40px;\n}\ntextarea {\n\twidth: 100%;\n\theight: 80vh;\n}\n.inputContainer {\n\tdisplay: flex;\n\tflex-direction: row;\n\twidth: 100%;\n}\ninput {\n\twidth: 400px;\n}\nbutton {\n\twidth: 150px;\n\tmargin-left: 4px;\n}\n"
  },
  {
    "path": "src/client/css/createaccount.css",
    "content": "* {\n\tmargin: 0;\n\tpadding: 0;\n\tfont-family: Verdana;\n\tborder: 0;\n\t/* Enable temporarily during dev to see the borders of all elements */\n\t/* outline: 1px solid rgba(0, 0, 0, 0.191); */\n}\n\nhtml {\n\theight: 100%;\n\tbackground-color: rgb(33, 33, 33);\n}\n\nmain {\n\tbackground-color: #fff;\n\t/* Using PNG because it was the smallest after compression */\n\tbackground-image: url('/img/blank_board.png');\n\tbackground-position: center;\n\tbackground-repeat: no-repeat;\n\tbackground-size: cover;\n\t-webkit-background-size: cover;\n\t-moz-background-size: cover;\n\t-o-background-size: cover;\n\tbackground-attachment: fixed;\n\n\tmargin-top: 40px;\n\tmin-height: 450px;\n}\n\n#content {\n\tbackground-color: rgba(255, 255, 255, 0.805);\n\tmin-height: 450px;\n\tmargin: auto;\n\tbox-shadow: 0 0 10px rgba(0, 0, 0, 0.522);\n\tpadding: 30px 20px;\n}\n\n#content h1 {\n\tfont-size: 40px;\n\tfont-family: georgia;\n\tmargin-bottom: 60px;\n}\n\n.formfield {\n\twidth: fit-content;\n\ttext-align: center;\n\tmargin: auto;\n}\n\n#username-input-line {\n\ttext-align: center;\n\tline-height: 2.2em;\n}\n\n#emailinputline,\n#password-input-line {\n\tvertical-align: middle;\n\tmargin-top: 12px;\n}\n\n.line {\n\twidth: fit-content;\n\tdisplay: inline-block;\n\ttext-align: center;\n\tline-height: 2.2em;\n}\n\nlabel {\n\tfont-size: 18px;\n\tvertical-align: middle;\n\tmargin-right: 2px;\n}\n\nform input {\n\tborder: 0;\n\tborder-radius: 4px;\n\tpadding: 0.4em;\n\tbox-shadow: 0 0 8px rgba(0, 0, 0, 0.63);\n\tfont-size: 15px;\n\twidth: 180px; /* Must also change div.error width! */\n}\n\nform input:focus {\n\toutline: solid 1px black;\n}\n\nform input[type='text']:hover,\nform input[type='email']:hover,\nform input[type='password']:hover {\n\tbox-shadow: 0 0 8px rgb(0, 0, 0);\n}\n\ndiv.error {\n\tdisplay: inline-block;\n\tfont-size: 12px;\n\ttext-align: left;\n\tcolor: red;\n\twidth: 192px; /* Must be exactly 12 pixels more than input width! */\n\tline-height: 1.2em;\n}\n\nform input[type='submit'] {\n\theight: 30px;\n\tmin-width: 0;\n\twidth: fit-content;\n\theight: fit-content;\n\tbackground-color: white;\n\tfont-size: 16px;\n\ttransition: 0.1s;\n\tmargin-top: 25px;\n\toutline: 0;\n}\n\nform input[type='submit'].ready:hover {\n\ttransition: 0.1s;\n\tfont-size: 18px;\n\tbox-shadow: 0 0 8px rgb(0, 0, 0);\n\tmargin-top: 23px;\n}\n\nform input[type='submit'].ready:focus {\n\toutline: solid 1px black;\n}\n\n/* Honeypot Bot Catcher: visually hidden but still present in DOM and form submission */\n.visually-hidden {\n\tposition: absolute !important;\n\twidth: 1px !important;\n\theight: 1px !important;\n\tpadding: 0 !important;\n\tmargin: -1px !important;\n\toverflow: hidden !important;\n\tclip: rect(0 0 0 0) !important;\n\twhite-space: nowrap !important;\n\tborder: 0 !important;\n}\n\n.agreement {\n\tmargin: 1em 0 0 0;\n\tfont-size: 13px;\n\tcolor: rgb(68, 68, 68);\n\tline-height: 1.5;\n\tmargin: 20px 0px;\n}\n\n.center {\n\ttext-align: center;\n}\n\na {\n\t-webkit-tap-highlight-color: rgba(0, 0, 0, 0.099);\n}\n\n.unavailable {\n\tcolor: rgba(0, 0, 0, 0.199);\n}\n\n/* Right align error bars */\n@media only screen and (min-width: 345px) {\n\t.formfield {\n\t\ttext-align: right;\n\t}\n}\n\n/* Start increasing header links width */\n@media only screen and (min-width: 450px) {\n\t#content h1 {\n\t\tfont-size: calc(40px + 0.027 * (100vw - 450px));\n\t}\n\n\tform input {\n\t\twidth: calc(180px + 0.3 * (100vw - 450px)); /* Must also change div.error width! */\n\t}\n\n\tdiv.error {\n\t\t/* Must be exactly 12 pixels more than input width! */\n\t\twidth: calc(192px + 0.3 * (100vw - 450px));\n\t}\n}\n\n/* Stop increasing header links width */\n@media only screen and (min-width: 715px) {\n\tform input {\n\t\twidth: 260px; /* Must also change div.error width! */\n\t}\n\n\tdiv.error {\n\t\twidth: 272px; /* Must be exactly 12 pixels more than input width! */\n\t}\n}\n\n/* Cap content width size, revealing image on the sides */\n@media only screen and (min-width: 810px) {\n\t#content {\n\t\tmax-width: calc(810px - 60px); /* 60px less than 810 to account for padding */\n\t\tpadding: 40px 30px;\n\t\tmin-height: 800px;\n\t}\n\n\t#content h1 {\n\t\tfont-size: 50px;\n\t\tmargin-bottom: 70px;\n\t}\n}\n"
  },
  {
    "path": "src/client/css/credits.css",
    "content": "* {\n\tmargin: 0;\n\tpadding: 0;\n\tfont-family: Verdana;\n\tborder: 0;\n\t/* Enable temporarily during dev to see the borders of all elements */\n\t/* outline: 1px solid rgba(0, 0, 0, 0.191); */\n}\n\nhtml {\n\theight: 100%;\n\tbackground-color: rgb(33, 33, 33);\n}\n\nmain {\n\tbackground-color: #fff;\n\t/* Using PNG because it was the smallest after compression */\n\tbackground-image: url('/img/blank_board.png');\n\tbackground-position: center;\n\tbackground-repeat: no-repeat;\n\tbackground-size: cover;\n\t-webkit-background-size: cover;\n\t-moz-background-size: cover;\n\t-o-background-size: cover;\n\tbackground-attachment: fixed;\n\n\tmargin-top: 40px;\n\tmin-height: 400px;\n}\n\n#content {\n\tbackground-color: rgba(255, 255, 255, 0.805);\n\tmin-height: 450px;\n\tmargin: auto;\n\tbox-shadow: 0 0 10px rgba(0, 0, 0, 0.522);\n\tpadding: 30px 20px;\n}\n\n#content h1 {\n\tfont-size: 40px;\n\tfont-family: georgia;\n\tmargin-bottom: 40px;\n}\n\nh2 {\n\ttext-align: center;\n\tfont-size: 25px;\n\tmargin-top: 1.5em;\n}\n\nh3 {\n\t/* Thank you! */\n\tfont-size: 18px;\n\ttext-align: center;\n\tfont-weight: normal;\n}\n\n#content p {\n\tline-height: 1.5;\n\tfont-size: 16px;\n\tmargin: 10px 0px;\n}\n\na {\n\tcolor: black;\n}\n\n.grey {\n\tcolor: rgba(0, 0, 0, 0.345);\n}\n\n.center {\n\ttext-align: center;\n}\n\na {\n\t-webkit-tap-highlight-color: rgba(0, 0, 0, 0.099);\n}\n\n/* Start increasing header links width */\n@media only screen and (min-width: 450px) {\n\t#content h1 {\n\t\tfont-size: calc(40px + 0.028 * (100vw - 450px));\n\t}\n}\n\n/* Cap content width size, revealing image on the sides */\n@media only screen and (min-width: 810px) {\n\t#content {\n\t\tmax-width: calc(810px - 60px); /* 60px less than 810 to account for padding */\n\t\tpadding: 40px 30px;\n\t\tmin-height: 800px;\n\t}\n\n\t#content h1 {\n\t\tfont-size: 50px;\n\t\tmargin-bottom: 50px;\n\t}\n}\n"
  },
  {
    "path": "src/client/css/footer.css",
    "content": "footer {\n\ttext-align: center;\n\tpadding: 10px 0;\n}\n\nfooter a {\n\tdisplay: inline-block;\n\tcolor: rgb(207, 207, 207);\n\tmargin: 10px 10px;\n\ttext-decoration: underline;\n}\n\nfooter label {\n\tfont-size: 16px;\n\tcolor: rgb(207, 207, 207);\n\tmargin: 10px 2px 10px 10px;\n}\n\nfooter select {\n\twidth: min-content;\n\tcolor: rgb(207, 207, 207);\n\tfont-size: 1em;\n\tbackground-color: rgba(0, 0, 0, 0);\n\tmargin: 10px 10px 10px 0px;\n\tcursor: pointer;\n}\n\n/* This ONLY changes the color of the text in the language-selection dropdown list so that the contrast is easier to read on Windows */\nfooter .language-option {\n\tcolor: rgb(33, 33, 33);\n}\n"
  },
  {
    "path": "src/client/css/guide.css",
    "content": "/* Guide page styles */\n\n* {\n\tmargin: 0;\n\tpadding: 0;\n\tfont-family: Verdana;\n\tborder: 0;\n}\n\nhtml {\n\theight: 100%;\n\tbackground-color: rgb(33, 33, 33);\n}\n\nmain {\n\tbackground-color: #fff;\n\tbackground-image: url('/img/blank_board.png');\n\tbackground-position: center;\n\tbackground-repeat: no-repeat;\n\tbackground-size: cover;\n\t-webkit-background-size: cover;\n\t-moz-background-size: cover;\n\t-o-background-size: cover;\n\tbackground-attachment: fixed;\n\n\tmargin-top: 40px;\n\tmin-height: 400px;\n}\n\n.content {\n\tbackground-color: rgba(255, 255, 255, 0.805);\n\tmin-height: 450px;\n\tmargin: auto;\n\tbox-shadow: 0 0 10px rgba(0, 0, 0, 0.522);\n\tpadding: 30px 20px;\n}\n\n.center {\n\ttext-align: center;\n}\n\n.guide-header {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n\tmargin-bottom: 0;\n\tmax-width: 100%;\n}\n\n.content h1 {\n\tfont-size: 40px;\n\tfont-family: georgia;\n\ttext-transform: uppercase;\n\tmargin: 0px;\n\tpadding: 0;\n\ttext-align: center;\n\tflex: 1;\n}\n\n.back-arrow {\n\twidth: 40px;\n\theight: 40px;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tcursor: pointer;\n\ttransition: background-color 0.2s;\n\tborder-radius: 0.5em;\n\tflex-shrink: 0;\n}\n\n.back-arrow-spacer {\n\twidth: 40px;\n\theight: 40px;\n\tflex-shrink: 0;\n}\n\n.back-arrow:hover {\n\tbackground-color: rgba(0, 0, 0, 0.05);\n}\n\n.back-arrow:active {\n\tbackground-color: rgba(0, 0, 0, 0.1);\n}\n\n.back-arrow svg {\n\twidth: 100%;\n\theight: 100%;\n\tpadding: 8px;\n}\n\n.content h2 {\n\tmargin: 2.25em 0 0;\n\tfont-weight: normal;\n}\n\n.content .line-break {\n\tborder: 0;\n\tborder-top: 1px solid #adadad;\n\tmargin: 0.5em 0 1em;\n}\n\n.content {\n\tline-height: 1.2;\n}\n\n.content p {\n\tmargin: 1.5em 0;\n}\n\n.content li {\n\tmargin: 0.75em 0 0.5em 0.5em;\n}\n\n.clear-float {\n\tclear: both;\n}\n\n.content img {\n\tbox-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.157);\n\tborder-radius: 0.7em;\n\tborder: 2px solid rgb(101, 101, 101);\n\tbox-sizing: content-box;\n\tmax-width: 100%;\n\theight: auto;\n}\n\n.img-promotionlines {\n\tmargin: 0.75em 0 0.75em 1.5em;\n\twidth: 50%;\n\tfloat: right;\n}\n\n.img-kingrookfork {\n\tmargin: 0.75em 1.5em 0.75em 0;\n\tfloat: left;\n\twidth: 42%;\n}\n\n.img-arrowindicators {\n\tmargin: 0.75em 0 0.75em 1.5em;\n\twidth: 25%;\n\tfloat: right;\n}\n\n.fairy-pieces {\n\tdisplay: flex;\n\theight: min(35vmin, 400px);\n\talign-items: stretch;\n\tjustify-content: center;\n\tmargin: 1.5em 0;\n}\n\n.img-fairymoveset {\n\tbox-sizing: border-box;\n\tmargin: 0 1em 0 0;\n\theight: 100%;\n\taspect-ratio: 1 / 1;\n}\n\n.img-fairymoveset img {\n\twidth: 100%;\n\theight: 100%;\n\tobject-fit: contain;\n}\n\n.fairy-card-container {\n\tfont-size: min(2vmin, 20px);\n\tdisplay: flex;\n\tbox-sizing: border-box;\n}\n\n.left-arrow,\n.right-arrow {\n\tdisplay: flex;\n\twidth: 13%;\n\tflex-shrink: 0;\n\tcursor: pointer;\n}\n\n.left-arrow svg,\n.right-arrow svg {\n\tbox-sizing: border-box;\n\twidth: 100%;\n\tpadding: 5%;\n\tborder-radius: 0.5em;\n}\n\n.left-arrow svg:hover,\n.right-arrow svg:hover {\n\tbackground-color: rgb(234, 234, 234);\n}\n\n.left-arrow svg:active,\n.right-arrow svg:active {\n\tbackground-color: rgb(221, 221, 221);\n}\n\n.opacity-0_25 {\n\topacity: 0.25;\n}\n\n.fairy-card {\n\tmargin: 0 1em;\n\tdisplay: flex;\n\tflex-direction: column;\n}\n\n.space-1 {\n\tflex-grow: 1;\n}\n\n.fairy-card-title {\n\ttext-align: center;\n\tfont-size: 1.6em;\n\tfont-weight: bold;\n\tmargin: 0 0 1em;\n\ttext-shadow: 0 0.12em 0.2em rgba(0, 0, 0, 0.203);\n\tflex-grow: 0;\n}\n\n.fairy-card-description {\n\tmargin: 0;\n\tfont-size: 1em;\n\tflex-grow: 0;\n}\n\n.space-2 {\n\tflex-grow: 2;\n}\n\n/* Responsive styles */\n\n/* Cap content width size, revealing image on the sides */\n@media only screen and (min-width: 810px) {\n\t.content {\n\t\tmax-width: calc(870px - 60px); /* 60px less than 810 to account for padding */\n\t\tpadding: 40px 30px;\n\t\tmin-height: 800px;\n\t}\n\n\t.content h1 {\n\t\tfont-size: 50px;\n\t\tmargin: 0px;\n\t}\n}\n\n@media only screen and (max-width: 700px) {\n\t.img-promotionlines,\n\t.img-kingrookfork {\n\t\tfloat: none;\n\t\twidth: 95%;\n\t\tmargin: 0.75em auto;\n\t\tdisplay: block;\n\t}\n}\n\n@media only screen and (max-width: 500px) {\n\t.img-arrowindicators {\n\t\twidth: 96px;\n\t}\n}\n\n@media only screen and (max-width: 600px), (max-height: 648px) {\n\t.img-fairymoveset {\n\t\twidth: 95%;\n\t\theight: unset;\n\t\tmargin: 0 0 0.75em;\n\t}\n\n\t.fairy-card-container {\n\t\tpadding-bottom: 0.75em;\n\t\tmin-height: 18em;\n\t}\n\n\t.left-arrow,\n\t.right-arrow {\n\t\tdisplay: flex;\n\t\tmax-width: 50px;\n\t}\n\n\t.fairy-pieces {\n\t\tflex-wrap: wrap;\n\t\theight: unset;\n\t}\n\n\t.fairy-card-title {\n\t\tfont-size: 2.4em;\n\t}\n\n\t.fairy-card-description {\n\t\tfont-size: 1.5em;\n\t}\n}\n"
  },
  {
    "path": "src/client/css/header.css",
    "content": ":root {\n\t/* THIS GETS OVERWRITTEN BY JAVASCRIPT in header.js that correctly sets the\n    viewport height based on how much screen space the home button bar takes up! */\n\t--vh: 100vh;\n\t--header-height: 40px;\n\t--dropdown-item-height: 43px;\n\t--header-link-hover-color: rgb(230, 230, 230);\n\t--currPage-background-color: rgb(237, 237, 237);\n\t--switch-on-color: rgb(97, 97, 97); /* Default value. Can be modified using javascript */\n\t--header-link-max-padding: 16px;\n\t--header-link-min-padding: 8px;\n\t--CBC-in: cubic-bezier(0, 1.05, 0.47, 1); /* Settings dropdown IN curve */\n\t--CBC-out: cubic-bezier(0.54, 0, 1, 0.97); /* Settings dropdown OUT curve */\n\t--CBC-CM-in: cubic-bezier(0.09, 1.61, 0.36, 1); /* Checkmark IN curve */\n\t--CBC-CM-out: cubic-bezier(0, 1.1, 1, 1); /* Checkmark OUT curve */\n\t--CBC-switch: cubic-bezier(0, 1.05, 0.47, 1); /* Toggle switch curve */\n}\n\nheader {\n\t/* \n    box-shadow: 0px 1px 5px rgb(107, 107, 107);\n    overflow: scroll;\n    white-space: nowrap;\n    text-align: center;\n    background-color: white;\n    z-index: 1; */\n\n\tposition: fixed;\n\tleft: 0;\n\ttop: 0;\n\tright: 0;\n\tz-index: 1;\n\tdisplay: flex;\n\tjustify-content: space-between;\n\theight: var(--header-height);\n\tbackground-color: white;\n\tborder-bottom: 1px solid black;\n\tbox-shadow: 0 3px 4px rgba(0, 0, 0, 0.08);\n\tfont-size: 16px;\n\talign-items: center;\n\tuser-select: none; /* Prevent text selection */\n}\n\nheader a {\n\ttext-decoration: none;\n\tcolor: black;\n\tdisplay: flex;\n\talign-items: center;\n}\n\nheader label {\n\tfont-size: inherit; /* Prevents createaccount.css changing the font size. */\n}\n\n.italic {\n\tfont-style: italic;\n}\n\n/* All SVG settings. (Most settings dropdown SVGs are the same width in the document, we just scale them here to make them all VISUALLY the same size */\n\n.svg-pawn {\n\t/* The pawn svg and loading animation that we use in several spots */\n\tposition: relative;\n\tbottom: 3px;\n\theight: 65%;\n\taspect-ratio: 1;\n\tstroke: #666;\n\tfill: #666;\n}\n\n/* The spinny pawn animation */\n.spinny-pawn {\n\ttransform-origin: 50% 60%; /* Rotate around the center of mass (slightly downward) */\n\tanimation: spin 0.65s linear infinite; /* Spin animation with continuous loop */\n}\n\n.svg-language,\n.svg-board,\n.svg-legalmove,\n.svg-perspective,\n.svg-selection,\n.svg-squares,\n.svg-mouse,\n.svg-sound,\n.svg-camera,\n.checkmark {\n\twidth: 19px;\n\taspect-ratio: 1;\n\tpadding: 0 2px;\n}\n\n.svg-language {\n\ttransform: scale(1.21);\n}\n\n.svg-perspective {\n\ttransform: scale(1.1);\n}\n\n.svg-mouse {\n\ttransform: scale(1.47);\n}\n\n.svg-sound {\n\ttransform: scale(1.25);\n}\n\n.svg-camera {\n\ttransform: scale(1.3);\n}\n\n.svg-undo {\n\ttransform: scale(1.8);\n\taspect-ratio: 1;\n}\n\n/* The Infinite Chess text and logo, left side of header */\n\n.home {\n\tdisplay: flex;\n\tgap: 5px;\n\theight: 100%;\n\talign-items: center;\n\tpadding: 0 8px;\n\twhite-space: nowrap; /* Prevent text from wrapping */\n\toverflow: hidden; /* Hide overflow if needed */\n}\n\n.home picture {\n\theight: 90%;\n}\n\n.home picture img {\n\theight: 100%;\n}\n\n.home p {\n\tfont-family: georgia;\n\tfont-size: 24px;\n}\n\n.home:hover p {\n\ttext-decoration: underline;\n}\n\n/* Hide the \"Infinite Chess\" text when we are at compactness level 1 */\n.home.compact-1 p {\n\tdisplay: none;\n}\n\n.home.compact-1:hover {\n\tbackground-color: var(--header-link-hover-color);\n}\n\n/* The navigation hyperlinks, middle of header. Play, News, Leaderboard, Login, Create Account */\n\nnav {\n\tdisplay: flex;\n\theight: 100%;\n}\n\nnav a {\n\tpadding: 0 calc(var(--header-link-max-padding)) 0;\n\twhite-space: nowrap; /* Prevent text from wrapping */\n\toverflow: clip; /* Hide overflow if needed. FIREFOX NEEDS THIS TO BE \"CLIP\" */\n\tposition: relative; /* Required for correct absolute positioning of news notification badge */\n}\n\nnav span {\n\tpadding-left: 4px;\n}\n\nnav .svg-pawn {\n\tbottom: 1px;\n}\n\nnav #svg-news {\n\theight: 55%;\n\tpadding: 0 5px;\n}\n\nnav #svg-leaderboard {\n\theight: 55%;\n\tpadding-left: 4px;\n}\n\nnav #svg-login {\n\theight: 60%;\n\tpadding-left: 4px;\n}\n\nnav #svg-profile {\n\theight: 47.5%;\n\tpadding: 0 4px 0 6px;\n}\n\nnav #svg-createaccount {\n\theight: 50%;\n\tpadding-left: 7px;\n\tposition: relative;\n\ttop: 1px;\n}\n\nnav #svg-logout {\n\theight: 63%;\n\tpadding-left: 5px;\n}\n\nnav a:hover {\n\tbackground-color: var(--header-link-hover-color);\n}\n\n/* Hide the navigation SVGs when we are at compactness level 2 */\nnav.compact-2 svg {\n\tdisplay: none;\n}\nnav.compact-2 span {\n\tpadding: 0 4px;\n}\n\n/* Navigation SVGs are visible again, but not the text */\nnav.compact-3 span {\n\tdisplay: none;\n}\nnav.compact-3 #svg-news {\n\tpadding: 0 4px;\n}\nnav.compact-3 #svg-leaderboard {\n\tpadding: 0 4px;\n}\nnav.compact-3 #svg-profile {\n\tpadding: 0 4px;\n}\nnav.compact-3 #svg-createaccount {\n\tpadding-left: 5px;\n}\nnav.compact-3 #svg-logout {\n\tpadding-left: 4px;\n}\n\n/* The gear and settings dropdown menu, right side of header. */\n\n.settings {\n\theight: 100%;\n\twidth: var(--header-height);\n\tdisplay: flex;\n\tjustify-content: center;\n\talign-items: center;\n\tcursor: pointer;\n}\n\n.settings.open {\n\tbackground-color: var(--currPage-background-color);\n}\n\n.settings:hover {\n\tbackground-color: var(--header-link-hover-color);\n}\n\n.settings:active {\n\t/* Prevents blue highlight when holding finger over the gear button */\n\tbackground-color: var(--header-link-hover-color);\n\t-webkit-tap-highlight-color: transparent;\n}\n\n.gear {\n\twidth: 45%;\n\ttransition: transform 0.2s var(--CBC-out);\n}\n\n.settings.open .gear {\n\ttransition: transform 0.3s var(--CBC-in);\n\ttransform: rotate(60deg);\n}\n\n.dropdown {\n\tposition: absolute; /* Position relative to the nearest positioned ancestor */\n\ttop: 100%; /* Aligns the top of the dropdown content to the bottom of the gear */\n\tright: 0;\n\tmin-width: 195px;\n\twidth: fit-content; /* Polish needs to be able to fit content because it's a little bit wider */\n\t/* Can't enable these because words \"Perspective Sensitivity\" won't wrap but instead increase the length of the whole dropdown. */\n\t/* min-width: 195px;\n    width: fit-content; */\n\tbackground-color: white;\n\tbox-shadow: -2px 3px 4px rgba(0, 0, 0, 0.1);\n\tz-index: 1;\n\tborder: 1px solid black;\n\tborder-right: unset;\n\tcursor: auto;\n\tborder-radius: 0 0 0 5px;\n\toverflow: hidden; /* Prevent children from rendering outside the border */\n\ttransform: translateX(0); /* Slide into view */\n\ttransition:\n\t\ttransform 0.3s var(--CBC-in),\n\t\tvisibility 0s,\n\t\topacity 0.25s ease-in-out;\n}\n\n.dropdown.visibility-hidden {\n\ttransform: translateX(\n\t\t100%\n\t); /* Just off screen to the right, to start out, until it's animated in. */\n\ttransition:\n\t\ttransform 0.2s var(--CBC-out),\n\t\tvisibility 0s 0.2s,\n\t\topacity 0.25s ease-in-out;\n}\n\n.dropdown-title {\n\t/* The back button at the top of 2+ deep dropdown */\n\tdisplay: flex;\n\talign-items: center;\n\theight: var(--dropdown-item-height);\n\tpadding: 0 15px;\n\tcursor: pointer;\n\tborder-bottom: 1px solid grey;\n}\n\n.dropdown-title:hover,\n.settings-dropdown-item:hover,\n.language-dropdown-item:hover,\n.legalmove-option:hover,\n.boolean-option:hover {\n\tbackground-color: var(--header-link-hover-color);\n}\n\n/* Dropdown items */\n.settings-dropdown-item {\n\tdisplay: flex;\n\talign-items: center;\n\theight: var(--dropdown-item-height);\n\tpadding: 0 15px 0 8px;\n\tcursor: pointer;\n}\n\np.text {\n\tpadding: 10px 6px;\n\tmax-width: 150px;\n\tmargin-right: auto;\n}\n\nspan.arrow-head-right,\nspan.arrow-head-left {\n\twidth: 8px;\n\theight: 8px;\n\tborder-right: 3px solid #666;\n\tborder-top: 3px solid #666;\n}\nspan.arrow-head-right {\n\tmargin-left: auto;\n\ttransform: rotate(45deg) /* skew(10deg, 10deg); */;\n}\nspan.arrow-head-left {\n\tmargin-right: auto;\n\ttransform: rotate(225deg) /* skew(10deg, 10deg); */;\n}\n\n.checkmark {\n\twidth: 30px;\n\taspect-ratio: 1;\n\tmargin-left: auto;\n\tfill: #444;\n\ttransition: transform 0.5s var(--CBC-CM-in);\n\ttransform: scale(1);\n}\n\n.checkmark.visibility-hidden {\n\ttransition:\n\t\ttransform 0.2s var(--CBC-CM-out),\n\t\tvisibility 0s 0.5s;\n\ttransform: scale(0);\n}\n\n/* Switch toggles */\n\n.switch {\n\tposition: relative;\n}\n\n.switch input {\n\tdisplay: none;\n}\n\n.switch > input + * {\n\tposition: absolute;\n\tinset: 0;\n\tborder-radius: 14px;\n\tbackground-color: #777;\n\tborder: 2px solid #777;\n\ttransition: 0.2s var(--CBC-switch);\n\ttransition-property: background-color, border-color;\n}\n\n.switch > input + ::before {\n\tcontent: '';\n\tdisplay: block;\n\tborder-radius: 14px;\n\tbackground-color: white;\n\twidth: 50%;\n\theight: 100%;\n\ttransition: transform 0.2s var(--CBC-switch);\n\tbox-shadow: 0px 1px 2px #00000076;\n}\n\n.switch input:checked + ::before {\n\ttransform: translateX(100%);\n}\n.switch input:checked + * {\n\tbackground-color: var(--switch-on-color);\n\tborder-color: var(--switch-on-color);\n}\n\n/* Language nested dropdown */\n\n.language-dropdown-item,\n.legalmove-option,\n.boolean-option {\n\tdisplay: flex;\n\talign-items: center;\n\tcursor: pointer;\n}\n\n.language-dropdown-item {\n\theight: 48px;\n\tpadding: 0 15px;\n}\n\n/* .language-dropdown-item p.name {\n\n} */\n\n.language-dropdown-item p.englishName {\n\tcolor: grey;\n\tfont-size: 0.7em;\n}\n\n/* Board theme nested dropdown */\n\n.dropdown-scrollable {\n\t/* Set max height to bottom of screen */\n\tmax-height: calc(var(--vh) - var(--header-height) - var(--dropdown-item-height));\n\toverflow-y: auto;\n}\n\n.appearance-dropdown {\n\twidth: 211px;\n}\n\np.theme-title {\n\ttext-align: center;\n\tpadding-top: 8px;\n}\n\n.theme-list {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(auto-fill, 45.5px);\n\tjustify-content: center;\n\tgap: 14px; /* Combined margin from both axes (7px) */\n\tpadding: 12px 0px 16px;\n\tborder-bottom: 1px solid grey;\n}\n\n.theme-list img {\n\twidth: 45.5px;\n\timage-rendering: pixelated;\n\tborder-radius: 2px;\n\toutline: 3px solid rgb(97, 97, 97);\n\tcursor: pointer;\n\tjustify-self: center;\n\talign-self: center;\n}\n\n.theme-list img:hover,\n.theme-list img.selected {\n\toutline: 5px solid black;\n}\n\n/* Legalmove shape nested dropdown */\n\n/* .legalmove-dropdown {\n    \n} */\n\n.legalmove-option {\n\theight: 43px;\n\tpadding: 0 15px 0 8px;\n}\n\n/* Gameplay dropdown */\n\n.selection-option-title {\n\tdisplay: flex;\n\tjustify-content: center;\n\talign-items: center;\n\theight: 35px;\n}\n\n.boolean-option p.text {\n\tpadding: 10px 6px 10px 3px;\n}\n\n.boolean-option {\n\tmin-height: 43px;\n\tpadding: 0 8px 0 8px;\n}\n\n.boolean-option .switch {\n\twidth: 36px;\n\theight: 20px;\n\tmargin: 0 2px 0 4px;\n}\n\n/* Perspective & Sound dropdowns */\n\n.perspective-option,\n.sound-option {\n\tfont-size: 14px;\n\tpadding: 5px 0 10px;\n}\n\n.perspective-option .perspective-option-title,\n.sound-option .sound-option-title {\n\tdisplay: flex;\n\tjustify-content: center;\n\talign-items: center;\n\theight: 35px;\n}\n\n.perspective-option .perspective-option-title p {\n\t/* \"Mouse Sensitivity\", \"Field of View\" */\n\tpadding-left: 6px;\n}\n\n.perspective-option .slider-container,\n.sound-option .slider-container {\n\tdisplay: flex;\n\tmargin-left: 8px;\n}\n\n.perspective-option .slider,\n.sound-option .slider {\n\twidth: 100%;\n}\n\n.perspective-option .slider:hover,\n.sound-option .slider:hover {\n\tcursor: pointer;\n}\n\n.perspective-option .value,\n.sound-option .value {\n\tpadding-left: 5px;\n\ttext-align: left;\n\tflex-shrink: 0;\n}\n\n.perspective-option.mouse-sensitivity .value,\n.sound-option.master-volume .value {\n\twidth: 50px;\n}\n\n.perspective-option.fov .value {\n\twidth: 35px;\n}\n\n/* Reset default buttons */\n\n.reset-default-container {\n\tdisplay: flex;\n\tjustify-content: center;\n\twidth: 100%;\n\tmargin-top: 5px;\n}\n\n.reset-default {\n\tdisplay: flex;\n\talign-items: center;\n\twidth: fit-content;\n\theight: fit-content;\n\tborder-radius: 15px;\n\tpadding: 3px 8px;\n}\n\n.reset-default:hover {\n\tbackground-color: rgb(233, 233, 233);\n\tcursor: pointer;\n}\n\n.reset-default span {\n\tpadding-left: 2px;\n}\n\n.reset-default-container .svg-undo {\n\twidth: 19px;\n\ttransform-origin: 70% 55%;\n}\n\n/* Ping Meter */\n\n.ping-meter {\n\tdisplay: flex;\n\tjustify-content: space-between;\n\talign-items: center;\n\theight: 50px;\n\tpadding: 0 15px;\n\tborder-top: 1px solid grey;\n\toverflow: hidden; /* Don't let the connection bars glow effect leak above */\n\tfont-size: 0px; /* Prevents a small amount of margin between each element */\n}\n\n.ping-meter .ping {\n\tfont-size: 15px;\n}\n\n.ping-meter .ping-value {\n\tfont-size: 15px;\n\tpadding: 0 3px 0 6px;\n}\n\n.ping-meter .ms {\n\tfont-size: 13px;\n}\n\n/* .ping-bars {\n    \n} */\n\n.ping-bar {\n\toutline: 1px solid #0000008c;\n\tdisplay: inline-block;\n\twidth: 9px;\n\t/* box-shadow: 0px 0px 5px 0px #0000007a; */\n\tbackground-color: rgb(210, 210, 210);\n}\n\n.ping-bar.green {\n\tbackground-color: #78ff78;\n}\n\n.ping-bar.yellow {\n\tbackground-color: #f8f878;\n}\n\n.ping-bar.red {\n\tbackground-color: #ff8b8b;\n}\n\n.ping-glow {\n\t/* Relatively positioned 0-space element that only glows */\n\tbox-shadow: 0px 0px 80px 30px #000000c4;\n\tposition: relative;\n\tbottom: 7px;\n\tz-index: -1; /* Places glow behind all bars */\n\tleft: 10px;\n\toverflow: hidden;\n\t/* transform: scaleY(0.7); */\n}\n\n/* Miscellaneous (some of these can probably be put in a universal stylesheet for all pages, not just the header stylesheet) */\n\n/* Greys the background of the navigation hyperlink we are currently in */\n.currPage {\n\tbackground-color: var(--currPage-background-color);\n}\n\n.hidden {\n\tdisplay: none;\n}\n\n.center {\n\ttext-align: center;\n}\n\n.visibility-hidden {\n\tvisibility: hidden;\n}\n\n.transparent {\n\topacity: 0%;\n\tpointer-events: none;\n}\n\n/* Used for disallowing changing your coordinates in an online game */\n.set-cursor-to-not-allowed {\n\tcursor: not-allowed;\n}\n\n.unselectable {\n\t/* Makes text inside the element unselectable (sometimes worsens the experience if you don't intent to) */\n\tuser-select: none;\n\t-moz-user-select: none;\n\t-webkit-user-select: none;\n\t-ms-user-select: none;\n}\n\n.selectable {\n\t/* Makes text inside the elements with the .unselectable class re-selectable */\n\tuser-select: text;\n\t-moz-user-select: text;\n\t-webkit-user-select: text;\n\t-ms-user-select: text;\n}\n\n/* Animations */\n\n@keyframes spin {\n\t0% {\n\t\ttransform: rotate(0deg); /* Start at 0 degrees */\n\t}\n\t100% {\n\t\ttransform: rotate(360deg); /* Complete a full 360 degree rotation */\n\t}\n}\n\n/*\n * Tooltips\n *\n * The JS tooltip system injects a fixed #tooltip-popup div and #tooltip-arrow div\n * directly into document.body, so no chance of being clipped by their parent containers.\n */\n\n/* Tooltip box */\n#tooltip-popup {\n\tposition: fixed;\n\tbackground-color: black;\n\tcolor: rgb(236, 236, 236);\n\ttext-align: center;\n\tborder-radius: 6px;\n\tfont-size: 12px;\n\twidth: max-content;\n\tmax-width: 150px;\n\tpadding: 5px;\n\topacity: 0;\n\ttransition: opacity 0.1s ease-in-out;\n\tpointer-events: none;\n\tword-wrap: break-word;\n\tword-break: break-word;\n\twhite-space: normal;\n\tz-index: 10000;\n}\n\n/* Tooltip arrow */\n#tooltip-arrow {\n\tposition: fixed;\n\twidth: 0;\n\theight: 0;\n\tborder-width: 5px; /* MUST match ARROW_HALF in tooltips.ts */\n\tborder-style: solid;\n\topacity: 0;\n\ttransition: opacity 0.1s ease-in-out;\n\tpointer-events: none;\n\tz-index: 10000;\n}\n\n/* Arrow pointing downward (used for tooltip-d / tooltip-dl / tooltip-dr) */\n#tooltip-arrow.tooltip-arrow-down {\n\tborder-color: transparent transparent black transparent;\n}\n\n/* Arrow pointing upward (used for tooltip-u / tooltip-ul / tooltip-ur) */\n#tooltip-arrow.tooltip-arrow-up {\n\tborder-color: black transparent transparent transparent;\n}\n\n/* Badge shine properties - needed both for play and for member page */\n\n#checkmate-badge-bronze {\n\t--shine-color: rgba(229, 203, 180, 0.3);\n}\n\n#checkmate-badge-silver {\n\t--shine-color: rgba(192, 192, 192, 0.22);\n}\n\n#checkmate-badge-gold {\n\t--shine-color: rgba(255, 215, 0, 0.24);\n}\n\n.badge .shine-clockwise,\n.badge .shine-anticlockwise {\n\tposition: absolute;\n\ttop: 50%;\n\tleft: 50%;\n\twidth: 200%;\n\theight: 200%;\n\ttransform: translate(-50%, -50%);\n\t/* Conic gradient produces the rays */\n\tbackground: repeating-conic-gradient(var(--shine-color) 0deg 15deg, transparent 15deg 40deg);\n\t/* Use a radial mask to fade the rays proportional to distance */\n\tmask-image: radial-gradient(circle, black 30%, transparent 55%);\n\t-webkit-mask-image: radial-gradient(circle, black 30%, transparent 55%);\n\topacity: 0;\n\ttransition: opacity 0.4s ease;\n\tanimation: rotateShine linear infinite;\n\tpointer-events: none; /* This prevents the shine from being part of the hover area */\n\tz-index: -1;\n}\n\n.badge .shine-clockwise {\n\tanimation-direction: normal;\n\tanimation-duration: 13s;\n}\n\n.badge .shine-anticlockwise {\n\tanimation-direction: reverse;\n\tanimation-duration: 26s;\n}\n\n.badge:hover .shine-clockwise,\n.badge:hover .shine-anticlockwise {\n\topacity: 1;\n}\n\n@keyframes rotateShine {\n\tfrom {\n\t\ttransform: translate(-50%, -50%) rotate(0deg);\n\t}\n\tto {\n\t\ttransform: translate(-50%, -50%) rotate(360deg);\n\t}\n}\n\n/* Username Embed Containers */\n\n.username-embed {\n\tdisplay: flex;\n\talign-items: center;\n\t/* flex-wrap: wrap; */\n\tgap: 0.3em;\n\twidth: fit-content;\n}\n\n.username-embed .svg-profile,\n.svg-engine {\n\twidth: 1em;\n\theight: 1em;\n\taspect-ratio: 1;\n}\n\n.username-embed .username {\n\tfont-size: 1em;\n\tfont-weight: bold;\n\tcolor: #000;\n\ttext-decoration: none;\n}\n\n.username-embed .elo {\n\tcolor: #666;\n\tfont-size: 0.9em;\n}\n\n.username-embed .eloChange {\n\tfont-size: 0.9em;\n}\n\n.username-embed .eloChange.positive {\n\tcolor: green;\n}\n\n.username-embed .eloChange.negative {\n\tcolor: red;\n}\n\n/* Fades the right side of the element away to hide overflow text */\n.fade-element {\n\t/* prettier-ignore */\n\tmask-image: linear-gradient(\n      to right,\n      black 0%,\n      black calc(100% - 20px),\n      transparent 100%\n    );\n\t-webkit-mask-image: linear-gradient(\n\t\tto right,\n\t\tblack 0%,\n\t\tblack calc(100% - 20px),\n\t\ttransparent 100%\n\t);\n}\n\n.justify-content-right {\n\tjustify-content: right;\n}\n"
  },
  {
    "path": "src/client/css/icnvalidator.css",
    "content": ":root {\n\t--bg-color: #191919;\n\t--text-color: #d4d4d4;\n\t--primary-color: #4a90e2;\n\t--secondary-color: #252526;\n\t--border-color: #333;\n\t--accent-color: #569cd6;\n\t--success-color: #4caf50;\n\t--warning-color: #ff9800;\n\t--danger-color: #f44336;\n\t--font-family:\n\t\t-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;\n}\n\nbody {\n\tfont-family: var(--font-family);\n\tbackground-color: var(--bg-color);\n\tcolor: var(--text-color);\n\tmargin: 0;\n\tpadding: 2rem;\n\tdisplay: flex;\n\tjustify-content: center;\n}\n\n.container {\n\twidth: 100%;\n\tmax-width: 1200px;\n}\n\nh1,\nh2 {\n\tcolor: var(--primary-color);\n\tborder-bottom: 2px solid var(--border-color);\n\tpadding-bottom: 0.5rem;\n}\n\n.upload-section {\n\tbackground-color: var(--secondary-color);\n\tborder: 2px dashed var(--border-color);\n\tborder-radius: 8px;\n\tpadding: 2rem;\n\ttext-align: center;\n\tmargin-bottom: 2rem;\n\ttransition: border-color 0.3s;\n}\n\n.upload-section:hover {\n\tborder-color: var(--primary-color);\n}\n\n.upload-section.drag-over {\n\tborder-color: var(--accent-color);\n\tbackground-color: rgba(74, 144, 226, 0.1);\n}\n\ninput[type='file'] {\n\tdisplay: none;\n}\n\n.file-label {\n\tdisplay: inline-block;\n\tbackground-color: var(--primary-color);\n\tcolor: white;\n\tpadding: 1rem 2rem;\n\tborder-radius: 4px;\n\tcursor: pointer;\n\tfont-weight: bold;\n\ttransition: background-color 0.2s;\n}\n\n.file-label:hover {\n\tbackground-color: var(--accent-color);\n}\n\nbutton {\n\tbackground-color: var(--primary-color);\n\tcolor: white;\n\tborder: none;\n\tpadding: 0.75rem 1.5rem;\n\tfont-weight: bold;\n\tcursor: pointer;\n\ttransition: background-color 0.2s;\n\tborder-radius: 4px;\n\tfont-size: 1em;\n}\n\nbutton:hover {\n\tbackground-color: var(--accent-color);\n}\n\nbutton:disabled {\n\tbackground-color: #666;\n\tcursor: not-allowed;\n}\n\n.progress-section {\n\tbackground-color: var(--secondary-color);\n\tborder: 1px solid var(--border-color);\n\tborder-radius: 8px;\n\tpadding: 1.5rem;\n\tmargin-bottom: 2rem;\n\tdisplay: none;\n}\n\n.progress-bar {\n\twidth: 100%;\n\theight: 30px;\n\tbackground-color: var(--bg-color);\n\tborder-radius: 4px;\n\toverflow: hidden;\n\tmargin: 1rem 0;\n}\n\n.progress-fill {\n\theight: 100%;\n\tbackground-color: var(--primary-color);\n\ttransition: width 0.3s;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tcolor: white;\n\tfont-weight: bold;\n}\n\n.summary-section {\n\tbackground-color: var(--secondary-color);\n\tborder: 1px solid var(--border-color);\n\tborder-radius: 8px;\n\tpadding: 1.5rem;\n\tmargin-bottom: 2rem;\n\tdisplay: none;\n}\n\n.summary-hero {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tbackground-color: var(--bg-color);\n\tborder: 1px solid var(--border-color);\n\tborder-radius: 8px;\n\tpadding: 2rem;\n\tmargin-bottom: 2rem;\n\tgap: 3rem;\n}\n\n.hero-stat {\n\tdisplay: flex;\n\tflex-direction: column;\n\talign-items: center;\n}\n\n.hero-value {\n\tfont-size: 3.5rem;\n\tfont-weight: 800;\n\tline-height: 1;\n\tmargin-bottom: 0.5rem;\n}\n\n.hero-label {\n\tcolor: var(--text-color);\n\topacity: 0.7;\n\tfont-size: 0.9rem;\n\tfont-weight: bold;\n\tletter-spacing: 1px;\n}\n\n.hero-divider {\n\twidth: 2px;\n\theight: 60px;\n\tbackground-color: var(--border-color);\n}\n\n/* Dynamic colors for the percentage */\n.hero-value.perfect {\n\tcolor: var(--success-color);\n}\n.hero-value.good {\n\tcolor: var(--accent-color);\n}\n.hero-value.bad {\n\tcolor: var(--warning-color);\n}\n.hero-value.terrible {\n\tcolor: var(--danger-color);\n}\n\n.stat-grid {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(auto-fit, minmax(220px, 1fr));\n\tgap: 1rem;\n\tmargin-top: 1rem;\n}\n\n.stat-card {\n\tbackground-color: var(--bg-color);\n\tborder: 1px solid var(--border-color);\n\tborder-radius: 4px;\n\tpadding: 1rem;\n}\n\n.stat-card h3 {\n\tmargin: 0 0 0.5rem 0;\n\tcolor: var(--accent-color);\n\tfont-size: 0.9em;\n\ttext-transform: uppercase;\n}\n\n.stat-value {\n\tfont-size: 2em;\n\tfont-weight: bold;\n\tmargin: 0;\n}\n\n.stat-value.success {\n\tcolor: var(--success-color);\n}\n\n.stat-value.error {\n\tcolor: var(--danger-color);\n}\n\n.stat-value.warning {\n\tcolor: var(--warning-color);\n}\n\n.details-section {\n\tbackground-color: var(--secondary-color);\n\tborder: 1px solid var(--border-color);\n\tborder-radius: 8px;\n\tpadding: 1.5rem;\n\tmargin-bottom: 1rem;\n}\n\n.error-list {\n\tmax-height: 800px;\n\toverflow-y: auto;\n\tmargin-top: 1rem;\n}\n\n.error-item {\n\tbackground-color: var(--bg-color);\n\tborder-left: 4px solid var(--danger-color);\n\tpadding: 1rem;\n\tmargin-bottom: 1rem;\n\tborder-radius: 4px;\n}\n\n.error-item.icnconverter {\n\tborder-left-color: var(--warning-color);\n}\n\n.error-item.formulator {\n\tborder-left-color: var(--danger-color);\n}\n\n.error-item.illegal-move {\n\tborder-left-color: #e91e63;\n}\n\n.error-item.termination-mismatch {\n\tborder-left-color: #9c27b0;\n}\n\n.error-header {\n\tfont-weight: bold;\n\tmargin-bottom: 0.5rem;\n\tdisplay: flex;\n\tjustify-content: space-between;\n\talign-items: center;\n}\n\n.error-type {\n\tpadding: 0.25rem 0.5rem;\n\tborder-radius: 4px;\n\tfont-size: 0.8em;\n\ttext-transform: uppercase;\n}\n\n.error-type.icnconverter {\n\tbackground-color: var(--warning-color);\n\tcolor: var(--bg-color);\n}\n\n.error-type.formulator {\n\tbackground-color: var(--danger-color);\n\tcolor: white;\n}\n\n.error-type.illegal-move {\n\tbackground-color: #e91e63;\n\tcolor: white;\n}\n\n.error-type.termination-mismatch {\n\tbackground-color: #9c27b0;\n\tcolor: white;\n}\n\n.error-message {\n\tfont-family: 'Courier New', Courier, monospace;\n\tfont-size: 0.9em;\n\twhite-space: pre-wrap;\n\tword-break: break-all;\n\tmargin-top: 0.5rem;\n\tpadding: 0.5rem;\n\tbackground-color: rgba(0, 0, 0, 0.3);\n\tborder-radius: 4px;\n}\n\n.variant-stats {\n\tmargin-top: 1rem;\n}\n\n.variant-item {\n\tdisplay: flex;\n\tflex-direction: column;\n\tpadding: 0.75rem;\n\tmargin-bottom: 0.5rem;\n\tbackground-color: var(--bg-color);\n\tborder-radius: 4px;\n}\n\n.variant-header {\n\tdisplay: flex;\n\tjustify-content: space-between;\n\tfont-weight: bold;\n\tmargin-bottom: 0.5rem;\n}\n\n.variant-details {\n\tdisplay: flex;\n\tgap: 0.5rem;\n\tflex-wrap: wrap;\n\tfont-size: 0.85em;\n}\n\n.v-stat {\n\tbackground-color: #333;\n\tpadding: 2px 8px;\n\tborder-radius: 4px;\n\tcolor: #aaa;\n}\n\n.v-stat.active {\n\tbackground-color: rgba(255, 255, 255, 0.1);\n\tcolor: var(--text-color);\n}\n\n.v-stat span {\n\tfont-weight: bold;\n}\n\n.v-stat.warn span {\n\tcolor: var(--warning-color);\n}\n\n.v-stat.err span {\n\tcolor: var(--danger-color);\n}\n\n.variant-errors.warn {\n\tcolor: var(--warning-color);\n}\n\n.variant-errors.err {\n\tcolor: var(--danger-color);\n}\n\npre {\n\tbackground-color: #111;\n\tborder: 1px solid var(--border-color);\n\tborder-radius: 4px;\n\tpadding: 1rem;\n\twhite-space: pre-wrap;\n\tword-break: break-all;\n\tfont-family: 'Courier New', Courier, monospace;\n\tfont-size: 0.9em;\n}\n\n.log-section {\n\tbackground-color: var(--secondary-color);\n\tborder: 1px solid var(--border-color);\n\tborder-radius: 8px;\n\tpadding: 1.5rem;\n\tmargin-top: 2rem;\n}\n\n.log-output {\n\tmax-height: 300px;\n\toverflow-y: auto;\n\tbackground-color: #111;\n\tborder: 1px solid var(--border-color);\n\tborder-radius: 4px;\n\tpadding: 1rem;\n\tfont-family: 'Courier New', Courier, monospace;\n\tfont-size: 0.85em;\n}\n\n.log-entry {\n\tpadding: 0.25rem 0;\n\tborder-bottom: 1px solid #222;\n}\n\n.log-entry:last-child {\n\tborder-bottom: none;\n}\n\n.log-entry.error {\n\tcolor: var(--danger-color);\n}\n\n.log-entry.warning {\n\tcolor: var(--warning-color);\n}\n\n.log-entry.success {\n\tcolor: var(--success-color);\n}\n\n.log-entry.info {\n\tcolor: var(--accent-color);\n}\n"
  },
  {
    "path": "src/client/css/index.css",
    "content": "* {\n\tmargin: 0;\n\tpadding: 0;\n\tfont-family: Verdana;\n\tborder: 0;\n\t/* Enable temporarily during dev to see the borders of all elements */\n\t/* outline: 1px solid rgba(0, 0, 0, 0.102); */\n}\n\nhtml {\n\theight: 100%;\n\tbackground-color: rgb(33, 33, 33);\n}\n\nmain {\n\tbackground-color: #fff;\n\t/* Using PNG because it was the smallest after compression */\n\tbackground-image: url('/img/blank_board.png');\n\tbackground-position: center;\n\tbackground-repeat: no-repeat;\n\tbackground-size: cover;\n\t-webkit-background-size: cover;\n\t-moz-background-size: cover;\n\t-o-background-size: cover;\n\tbackground-attachment: fixed;\n\n\tmargin-top: 40px;\n}\n\n.content {\n\tbackground-color: rgba(255, 255, 255, 0.805);\n\tmargin: auto;\n\tbox-shadow: 0 0 10px rgba(0, 0, 0, 0.522);\n\tpadding: 30px 20px;\n}\n\n.logo {\n\ttext-align: center;\n\tmargin-bottom: 40px;\n}\n\n.logo h1 {\n\tdisplay: inline-block;\n\tvertical-align: bottom;\n\tfont-size: 40px;\n\tfont-family: georgia;\n\ttext-shadow: 0 0 5px rgba(0, 0, 0, 0.318);\n}\n\n.logo img {\n\tdisplay: inline-block;\n\tvertical-align: bottom;\n\tdisplay: none;\n\twidth: 70px;\n}\n\n.logo p {\n\tmargin-top: 15px;\n}\n\niframe {\n\t--videoWidth: 85vw;\n\twidth: var(--videoWidth);\n\theight: calc(var(--videoWidth) * 9 / 16);\n}\n\n.content h2 {\n\tfont-size: 30px;\n\tmargin: 35px 0 20px;\n}\n\n.content h3 {\n\tfont-size: 25px;\n\tmargin: 35px 0 20px;\n}\n\n.content p {\n\tline-height: 1.5;\n\tfont-size: 17px;\n\tmargin: 20px 0px;\n}\n\n.patreon-container {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\tjustify-content: center;\n}\n\n.content .patreon-container p {\n\tfont-size: 18px;\n\tbackground-color: rgb(244, 244, 244);\n\tpadding: 0.5em 0.8em;\n\tborder-radius: 0.5em;\n\tbox-shadow: 0 0 18px rgba(0, 0, 0, 0.325);\n\t/* text-shadow: 0 2px 3px rgba(0, 0, 0, 0.16); */\n\ttransition:\n\t\tbox-shadow 0.25s,\n\t\ttransform 0.25s;\n\tmargin: 10px 10px;\n\tcursor: default;\n}\n\n.content .patreon-container p:hover {\n\ttransform: translate(0%, 0%) scale(1.1);\n\tbox-shadow: 0 0 18px rgba(0, 0, 0, 0.54);\n}\n\n.IM {\n\tcolor: rgb(0, 38, 255);\n\tfont-weight: bold;\n\tfont-size: 0.9em;\n\ttext-shadow: -2px 0px 0.6em rgba(0, 38, 255, 0.2);\n}\n\n/* Play button */\n\n.play-button {\n\tposition: relative;\n\tdisplay: inline-block;\n\tpadding: 0.6em 1.5em;\n\tfont-size: 2em;\n\ttext-align: center;\n\ttext-decoration: none;\n\tcolor: black;\n\tbackground-color: rgb(233, 233, 233);\n\tborder: 2px solid #2e2e2e;\n\tborder-radius: 0.5em;\n\tmargin-bottom: 50px;\n\toverflow: hidden;\n\ttransition:\n\t\tbackground-color 0.3s ease,\n\t\ttransform 0.2s ease,\n\t\tbox-shadow 0.2s ease;\n\tbox-shadow: 0 0 50px rgb(0 0 0 / 25%);\n}\n\n.play-button::before {\n\tcontent: '';\n\tposition: absolute;\n\ttop: 0;\n\tleft: -75%;\n\twidth: 50%;\n\theight: 100%;\n\tbackground: linear-gradient(\n\t\t120deg,\n\t\trgba(255, 255, 255, 0.4) 0%,\n\t\trgba(255, 255, 255, 0.8) 50%,\n\t\trgba(255, 255, 255, 0.4) 100%\n\t);\n\ttransform: skewX(-20deg);\n}\n\n.play-button:hover {\n\ttransform: scale(1.05);\n\tbox-shadow: 0 0 50px rgb(0 0 0 / 45%);\n}\n\n.play-button:hover::before {\n\tanimation: shine 0.75s ease-in-out;\n}\n\n@keyframes shine {\n\tfrom {\n\t\tleft: -75%;\n\t}\n\tto {\n\t\tleft: 125%;\n\t}\n}\n\n/* GitHub Contributors */\n\n.github-container {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\tjustify-content: center;\n\tgap: 1em;\n}\n\n.github-container a {\n\tposition: relative;\n\tbox-shadow: 0px 0px 15px 0 #0000003d;\n}\n\n.github-container a,\n.github-container img {\n\tborder-radius: 50%;\n\twidth: 80px;\n\theight: 80px;\n}\n\n.github-container .github-stats {\n\tdisplay: flex;\n\tposition: absolute;\n\ttop: 0;\n\tjustify-content: center;\n\tflex-direction: column;\n\tborder-radius: 50%;\n\theight: 100%;\n\twidth: 100%;\n\topacity: 0;\n\tbackground-color: rgb(33, 33, 33);\n\tcolor: #fff;\n\ttransition: opacity 200ms;\n}\n\n.github-container a:hover .github-stats {\n\topacity: 0.7;\n}\n\n.github-container .github-stats p {\n\tpadding: 0 4px;\n\tword-wrap: break-word; /* Allows words to break onto the next line */\n\tline-height: 1;\n\ttext-align: center;\n\tmargin: 0;\n}\n\n.github-container .github-stats p.name {\n\tfont-weight: bold;\n\tfont-size: 10px;\n\tmargin: 0 0 5px 0;\n}\n\n.github-container .github-stats p.contribution-count {\n\tfont-size: 9px;\n}\n\n.grey {\n\tcolor: rgba(0, 0, 0, 0.345);\n}\n\n.center {\n\ttext-align: center;\n}\n\na {\n\t-webkit-tap-highlight-color: rgba(0, 0, 0, 0.099);\n}\n\n.bold {\n\tfont-weight: bold;\n}\n\n/* Reveal pictures in logo */\n@media only screen and (min-width: 480px) {\n\t.logo img {\n\t\tdisplay: unset;\n\t\twidth: calc(70px + 0.09 * (100vw - 475px));\n\t}\n\n\t.logo h1 {\n\t\tfont-size: calc(40px + 0.059 * (100vw - 475px));\n\t}\n}\n\n/* Cap content width size */\n@media only screen and (min-width: 810px) {\n\t.content {\n\t\tmax-width: calc(810px - 60px); /* 60px less than 810 to account for padding */\n\t\tpadding: 40px 30px 100px;\n\t\tmin-height: 800px;\n\t}\n\n\t.logo h1 {\n\t\tfont-size: 60px;\n\t}\n\n\t.logo img {\n\t\twidth: 100px;\n\t}\n\n\tiframe {\n\t\twidth: 700px;\n\t\theight: 394px;\n\t}\n}\n"
  },
  {
    "path": "src/client/css/leaderboard.css",
    "content": "* {\n\tmargin: 0;\n\tpadding: 0;\n\tfont-family: Verdana;\n\tborder: 0;\n\t/* Enable temporarily during dev to see the borders of all elements */\n\t/* outline: 1px solid rgba(0, 0, 0, 0.191); */\n}\n\nhtml {\n\theight: 100%;\n\tbackground-color: rgb(33, 33, 33);\n}\n\nmain {\n\tbackground-color: #fff;\n\t/* Using PNG because it was the smallest after compression */\n\tbackground-image: url('/img/blank_board.png');\n\tbackground-position: center;\n\tbackground-repeat: no-repeat;\n\tbackground-size: cover;\n\t-webkit-background-size: cover;\n\t-moz-background-size: cover;\n\t-o-background-size: cover;\n\tbackground-attachment: fixed;\n\n\tmargin-top: 40px;\n\tmin-height: 400px;\n}\n\n.content {\n\tbackground-color: rgba(255, 255, 255, 0.805);\n\tmin-height: 450px;\n\tmargin: auto;\n\tbox-shadow: 0 0 10px rgba(0, 0, 0, 0.522);\n\tpadding: 30px 20px;\n}\n\n.content h1 {\n\tfont-size: 40px;\n\tfont-family: georgia;\n\ttext-transform: uppercase;\n\tmargin-bottom: 40px;\n}\n\n.content p {\n\tline-height: 1.5;\n\tfont-size: 12px;\n\tcolor: gray;\n\tmargin: 1em;\n}\n\n/* Don't hide borders on hr tags */\nhr {\n\tborder: 1px solid #00000057;\n\tmargin-bottom: 18px;\n}\n\n#user_ranking_container {\n\tfont-size: 18px;\n\tfont-weight: bold;\n}\n\n#user_ranking_text {\n\tmargin-right: 10px;\n}\n\n/* Table styling */\n\ntable {\n\twidth: 95%;\n\tborder-collapse: collapse;\n\tmargin: 20px;\n\talign-self: center;\n\ttable-layout: fixed; /* Makes table width work for small screens */\n}\n\nthead tr {\n\tborder: none;\n}\n\ntr {\n\tborder: 1px solid #d2d2d2;\n}\n\ntr:nth-child(even) {\n\tbackground-color: #f7f7f7;\n}\n\n.logged_in_user_entry {\n\t/* background-color: rgba(0, 128, 0, 0.3) !important; */\n\t--mid: #cbccff;\n\t--end: #eaeaff;\n\tbackground: linear-gradient(0deg, var(--end), var(--mid), var(--mid), var(--mid), var(--end));\n}\n\nth,\ntd {\n\tpadding: 8px 12px;\n\ttext-align: left;\n}\n\n/* Column widths (usernames might be long) */\nth:nth-child(1),\ntd:nth-child(1) {\n\twidth: 20%;\n}\nth:nth-child(2),\ntd:nth-child(2) {\n\twidth: 50%;\n}\nth:nth-child(3),\ntd:nth-child(3) {\n\twidth: 30%;\n}\n\n/* Show More Button */\n\n.button-wrapper {\n\tmargin-bottom: 1em;\n}\n\n.button-wrapper button {\n\tpadding: 10px 16px;\n\tfont-size: 17px;\n\tcursor: pointer;\n\tborder: 2px solid #767676;\n\tborder-radius: 12px;\n\tbackground-color: #f4f4f4;\n}\n\n.button-wrapper button:hover {\n\tbackground-color: #dfdbdb;\n}\n\n/* Start increasing header links width */\n@media only screen and (min-width: 450px) {\n\t.content h1 {\n\t\tfont-size: calc(40px + 0.028 * (100vw - 450px));\n\t}\n}\n\n/* Cap content width size, revealing image on the sides */\n@media only screen and (min-width: 810px) {\n\t.content {\n\t\tmax-width: calc(810px - 60px); /* 60px less than 810 to account for padding */\n\t\tpadding: 40px 30px;\n\t\tmin-height: 800px;\n\t}\n\n\t.content h1 {\n\t\tfont-size: 50px;\n\t\tmargin-bottom: 50px;\n\t}\n}\n"
  },
  {
    "path": "src/client/css/login.css",
    "content": "* {\n\tmargin: 0;\n\tpadding: 0;\n\tfont-family: Verdana;\n\tborder: 0;\n\t/* Enable temporarily during dev to see the borders of all elements */\n\t/* outline: 1px solid rgba(0, 0, 0, 0.214); */\n}\n\nhtml {\n\theight: 100%;\n\tbackground-color: rgb(33, 33, 33);\n}\n\nmain {\n\tbackground-color: #fff;\n\t/* Using PNG because it was the smallest after compression */\n\tbackground-image: url('/img/blank_board.png');\n\tbackground-position: center;\n\tbackground-repeat: no-repeat;\n\tbackground-size: cover;\n\t-webkit-background-size: cover;\n\t-moz-background-size: cover;\n\t-o-background-size: cover;\n\tbackground-attachment: fixed;\n\n\tmargin-top: 40px;\n\tmin-height: 425px;\n}\n\n#content {\n\tbackground-color: rgba(255, 255, 255, 0.805);\n\tmin-height: 425px;\n\tmargin: auto;\n\tbox-shadow: 0 0 10px rgba(0, 0, 0, 0.522);\n\tpadding: 30px 20px;\n}\n\n#content h1 {\n\tfont-size: 40px;\n\tfont-family: georgia;\n\tmargin-bottom: 60px;\n}\n\n.formfield {\n\twidth: fit-content;\n\ttext-align: right;\n\tmargin: auto;\n\tline-height: 2.2em;\n}\n\n#username-input-line,\n#password-input-line,\n#email-input-line {\n\ttext-align: center;\n}\n\n#password-input-line {\n\tmargin-top: 12px;\n\tdisplay: inline-block;\n}\n\n#confirm-password-line {\n\tmargin-top: 12px;\n}\n\n.formfield label {\n\tfont-size: 18px;\n\tvertical-align: middle;\n\tmargin-right: 2px;\n}\n\nform input {\n\tborder: 0;\n\tborder-radius: 4px;\n\tpadding: 0.4em;\n\tbox-shadow: 0 0 8px rgba(0, 0, 0, 0.63);\n\tfont-size: 15px;\n\twidth: 180px;\n}\n\nform input:focus {\n\toutline: solid 1px black;\n}\n\nform input:not([type='submit']):hover {\n\tbox-shadow: 0 0 8px rgb(0, 0, 0);\n}\n\n/* The instructions for the request password reset form */\n.form-instruction {\n\tfont-size: 16px;\n\tcolor: #333; /* A dark grey, easy to read */\n\tmargin-bottom: 20px; /* Add some space between the text and the email input */\n\tmax-width: 350px; /* Optional: Constrain width on wider screens */\n\tmargin-left: auto; /* Optional: Center the text block */\n\tmargin-right: auto; /* Optional: Center the text block */\n\tline-height: 1.4em;\n}\n\ndiv.error {\n\tfont-size: 16px;\n\ttext-align: center;\n\tcolor: red;\n\tmargin-top: 1em;\n\tline-height: 1em;\n}\n\nform input[type='submit'] {\n\theight: 30px;\n\tmin-width: 0;\n\twidth: fit-content;\n\theight: fit-content;\n\tbackground-color: white;\n\tfont-size: 16px;\n\ttransition: 0.1s;\n\tmargin-top: 25px;\n\toutline: 0;\n\ttransition: 0.1s;\n}\n\nform input[type='submit'].ready:hover {\n\tbox-shadow: 0 0 8px rgb(0, 0, 0);\n\ttransform: scale(1.125);\n}\n\nform input[type='submit'].ready:focus {\n\toutline: solid 1px black;\n}\n\n.center {\n\ttext-align: center;\n}\n\n.unavailable {\n\tcolor: rgba(0, 0, 0, 0.199);\n}\n\na {\n\t-webkit-tap-highlight-color: rgba(0, 0, 0, 0.099);\n\tcolor: black;\n}\n\n/* Start increasing header links width */\n@media only screen and (min-width: 450px) {\n\t#content h1 {\n\t\tfont-size: calc(40px + 0.027 * (100vw - 450px));\n\t}\n\n\tform input {\n\t\twidth: calc(180px + 0.15 * (100vw - 450px));\n\t}\n}\n\n/* Stop increasing header links width */\n@media only screen and (min-width: 715px) {\n\tform input {\n\t\twidth: 220px;\n\t}\n}\n\n/* Cap content width size, revealing image on the sides */\n@media only screen and (min-width: 810px) {\n\t#content {\n\t\tmax-width: calc(810px - 60px); /* 60px less than 810 to account for padding */\n\t\tpadding: 40px 30px;\n\t\tmin-height: 800px;\n\t}\n\n\t#content h1 {\n\t\tfont-size: 50px;\n\t\tmargin-bottom: 70px;\n\t}\n}\n\n/* Password Reset Form */\n\n/* Container for the \"Forgot?\" and \"Back to Login\" links */\n.forgot-link-container {\n\tmargin-top: 24px;\n\tfont-size: 14px;\n}\n\n/* Style for the links to make them look clickable */\n.forgot-link-container a {\n\tcolor: #0056b3; /* A standard hyperlink blue */\n\ttext-decoration: underline;\n\tcursor: pointer;\n}\n\n.forgot-link-container a:hover {\n\tcolor: #003d7a;\n}\n\n/* Style for the success message (e.g., after sending the email) */\ndiv.success {\n\tfont-size: 16px;\n\ttext-align: center;\n\tcolor: green;\n\tmargin-top: 1em;\n\tline-height: 1em;\n}\n"
  },
  {
    "path": "src/client/css/member.css",
    "content": "* {\n\tmargin: 0;\n\tpadding: 0;\n\tfont-family: Verdana;\n\tborder: 0;\n\t/* Enable temporarily during dev to see the borders of all elements */\n\t/* outline: 1px solid rgba(0, 0, 0, 0.145); */\n}\n\nhtml {\n\theight: 100%;\n\tbackground-color: rgb(33, 33, 33);\n}\n\nmain {\n\tbackground-color: #fff;\n\t/* Using PNG because it was the smallest after compression */\n\tbackground-image: url('/img/blank_board.png');\n\tbackground-position: center;\n\tbackground-repeat: no-repeat;\n\tbackground-size: cover;\n\t-webkit-background-size: cover;\n\t-moz-background-size: cover;\n\t-o-background-size: cover;\n\tbackground-attachment: fixed;\n\n\tmargin-top: 40px;\n\tmin-height: 400px;\n}\n\n#content {\n\tdisplay: flex;\n\tflex-direction: column;\n\tbackground-color: rgba(255, 255, 255, 0.805);\n\tmin-height: 450px;\n\tmargin: auto;\n\tbox-shadow: 0 0 10px rgba(0, 0, 0, 0.522);\n\tpadding: 30px 20px;\n\ttext-align: center;\n}\n\n#verifyerror h2 {\n\tfont-size: 16px;\n}\n\n#verifyerror p {\n\tfont-size: 11px;\n\tmargin-top: 0.5em;\n\tmargin-bottom: 20px;\n}\n\n#verifyconfirm {\n\tfont-size: 16px;\n\tmargin-bottom: 20px;\n}\n\n#content a {\n\tcolor: rgb(0, 0, 0);\n}\n\n#sendemail:hover {\n\tcursor: pointer;\n}\n\n.member,\nsection {\n\tbackground-color: rgba(238, 238, 238, 0.655);\n\tborder-radius: 6px;\n\tborder: solid 1px rgba(0, 0, 0, 0.123);\n\tmargin-bottom: 20px;\n\tpadding: 12px;\n}\n\n.member {\n\tdisplay: flex;\n\tgap: 4%;\n}\n\n.member img {\n\tdisplay: inline-block;\n\theight: 100px;\n\tvertical-align: top;\n}\n\n.membername-container {\n\tdisplay: flex;\n\tflex-direction: column;\n\tjustify-content: end;\n}\n\n.member h1 {\n\tfont-size: 16px;\n\tfont-family: georgia;\n}\n\n/* Badges */\n\n#badgelist {\n\tdisplay: flex;\n\theight: 60px;\n}\n\n#badgelist img {\n\theight: 100%;\n}\n\n.badge {\n\tposition: relative;\n\ttransition: transform 0.4s ease;\n\tuser-select: none;\n\twidth: 60px;\n\theight: 60px;\n}\n\n.badge:hover {\n\ttransform: scale(1.1);\n}\n\n/* Badge shine properties are in header.css since they are shared with badges on play page */\n\n.stats {\n\tpadding: 12px 12px;\n}\n\n.stats p {\n\tdisplay: inline-block;\n\tmargin: 0px 16px;\n\tline-height: 2em;\n}\n\n#content-container {\n\tdisplay: flex;\n\tflex-direction: column;\n\tflex-grow: 2;\n}\n\n.action-button {\n\tmargin-bottom: 20px;\n\tpadding: 0.7em 1em;\n\tborder-radius: 0.5em;\n\tbackground-color: white;\n\tbox-shadow: 0 0 8px rgba(0, 0, 0, 0.502);\n\ttransition: 0.15s;\n}\n\n.action-button:hover {\n\tpadding: 0.8em 1.15em;\n\tbox-shadow: 0 0 8px rgba(0, 0, 0, 0.799);\n\tcursor: pointer;\n}\n\n#delete-account {\n\tmargin-bottom: 0;\n\tbackground-color: #fff1f1;\n\tcolor: red;\n\tfont-weight: bold;\n\tborder: 1.5px solid red;\n\tbox-shadow: 0 0 8px rgba(148, 0, 0, 0.502);\n}\n\n#action-button:hover {\n\ttransform: scale(1.1);\n\tbox-shadow: 0 0 8px rgba(255, 211, 211, 0.799);\n}\n\n#show-account-info:active {\n\tbox-shadow: 0 0 8px rgb(0, 0, 0);\n}\n\n#delete-account:active {\n\tbackground-color: #ffcaca;\n}\n\n#accountinfo {\n\t/* background-color: rgba(219, 219, 219, 0.655); */\n\tpadding: 10px 16px 12px;\n\ttext-align: left;\n}\n\n#accountinfo h6 {\n\ttext-transform: uppercase;\n\tmargin-bottom: 6px;\n}\n\n.hidden {\n\tdisplay: none;\n}\n\n.currPage {\n\tbackground-color: rgb(236, 236, 236);\n}\n\n.center {\n\ttext-align: center;\n}\n\n.red {\n\tcolor: red;\n}\n\n.green {\n\tcolor: rgb(0, 162, 0);\n}\n\n.underline {\n\ttext-decoration: underline;\n}\n\na {\n\t-webkit-tap-highlight-color: rgba(0, 0, 0, 0.099);\n}\n\n/* Start increasing header links width */\n@media only screen and (min-width: 450px) {\n\t.member {\n\t\tpadding: calc(12px + (100vw - 450px) * 0.05);\n\t}\n\n\t.member img {\n\t\theight: calc(100px + (100vw - 450px) * 0.165);\n\t}\n}\n\n/* Stop increasing header links width */\n@media only screen and (min-width: 715px) {\n\t#verifyerror h2,\n\t#verifyconfirm {\n\t\tfont-size: 20px;\n\t}\n\n\t#verifyerror p {\n\t\tfont-size: 13px;\n\t}\n}\n\n/* Cap content width size, revealing image on the sides */\n@media only screen and (min-width: 810px) {\n\t#content {\n\t\tmax-width: calc(810px - 60px); /* 60px less than 810 to account for padding */\n\t\tpadding: 40px 30px;\n\t\tmin-height: calc(100vh - 182px);\n\t}\n\n\t.member {\n\t\tpadding: 30px;\n\t}\n\n\t.member img {\n\t\theight: 160px;\n\t}\n}\n"
  },
  {
    "path": "src/client/css/news.css",
    "content": "* {\n\tmargin: 0;\n\tpadding: 0;\n\tfont-family: Verdana;\n\tborder: 0;\n\t/* Enable temporarily during dev to see the borders of all elements */\n\t/* outline: 1px solid rgba(0, 0, 0, 0.191); */\n}\n\nhtml {\n\theight: 100%;\n\tbackground-color: rgb(33, 33, 33);\n}\n\nmain {\n\tbackground-color: #fff;\n\t/* Using PNG because it was the smallest after compression */\n\tbackground-image: url('/img/blank_board.png');\n\tbackground-position: center;\n\tbackground-repeat: no-repeat;\n\tbackground-size: cover;\n\t-webkit-background-size: cover;\n\t-moz-background-size: cover;\n\t-o-background-size: cover;\n\tbackground-attachment: fixed;\n\n\tmargin-top: 40px;\n\tmin-height: 400px;\n}\n\n.content {\n\tbackground-color: rgba(255, 255, 255, 0.805);\n\tmin-height: 450px;\n\tmargin: auto;\n\tbox-shadow: 0 0 10px rgba(0, 0, 0, 0.522);\n\tpadding: 30px 20px;\n}\n\n.content h1 {\n\tfont-size: 40px;\n\tfont-family: georgia;\n\ttext-transform: uppercase;\n\tmargin-bottom: 40px;\n}\n\n.news-posts .news-post h1 {\n\tfont-size: 1.7em;\n\tmargin-bottom: 1em;\n\tfont-family: inherit;\n\ttext-transform: none;\n}\n\n.content h2,\n.content h3 {\n\tmargin: 1em 0;\n}\n\n.content p {\n\tline-height: 1.5;\n\tfont-size: 17px;\n\tmargin-bottom: 1em;\n}\n\n.content p.status {\n\tfont-style: italic;\n\tfont-size: 16px;\n\tmargin-bottom: 35px;\n}\n\n.content p.date {\n\tmargin-top: 35px;\n\tfont-weight: bold;\n\tfont-size: 18px;\n}\n\n.content ul li {\n\tmargin-bottom: 0.7em;\n}\n\n.center {\n\ttext-align: center;\n}\n\na {\n\t-webkit-tap-highlight-color: rgba(0, 0, 0, 0.099);\n}\n\n.red {\n\tcolor: red;\n}\n\n/* Don't hide borders on hr tags */\nhr {\n\tborder: 1px solid #00000057;\n}\n\n.news-post {\n\tdisplay: flex;\n\tflex-direction: column;\n\twidth: 100%;\n\theight: 100%;\n}\n\n.news-post-date {\n\tfont-size: 0.75em;\n\tfont-weight: 700;\n\tcolor: rgb(0 0 0 / 47%);\n\tmargin-top: 1em;\n}\n\n.news-post-markdown {\n\tmargin: 1em 0;\n}\n\niframe {\n\twidth: 350px; /* Makes the iframe take the full width of the parent */\n\tmax-width: 100%;\n\taspect-ratio: 16 / 9; /* Set your desired aspect ratio (16:9 in this case) */\n\theight: auto; /* Automatically adjust the height based on the aspect ratio */\n}\n\n/* Start increasing header links width */\n@media only screen and (min-width: 450px) {\n\t.content h1 {\n\t\tfont-size: calc(40px + 0.028 * (100vw - 450px));\n\t}\n}\n\n/* Cap content width size, revealing image on the sides */\n@media only screen and (min-width: 810px) {\n\t.content {\n\t\tmax-width: calc(810px - 60px); /* 60px less than 810 to account for padding */\n\t\tpadding: 40px 30px;\n\t\tmin-height: 800px;\n\t}\n\n\t.content h1 {\n\t\tfont-size: 50px;\n\t\tmargin-bottom: 50px;\n\t}\n}\n"
  },
  {
    "path": "src/client/css/play.css",
    "content": "* {\n\tmargin: 0;\n\tpadding: 0;\n\tfont-family: Verdana;\n\tborder: 0;\n\t-webkit-tap-highlight-color: transparent; /* Suppress blue flash on tap */\n\t/* Enable temporarily during dev to see the borders of all elements */\n\t/* outline: 1px solid rgba(0, 0, 0, 0.191); */\n}\n\nhtml {\n\tbackground-color: rgb(33, 33, 33);\n}\n\n/* Variables */\n:root {\n\t/* 100vw, but with a maximum, so some UIs don't get too big. */\n\t--vw-capped: clamp(0px, 100vw, 1086px);\n\t--nav-bar-height: 41px; /* 40 + 1 for border */\n\t/* The viewport height, subtract the navigation bar height. */\n\t--vh-sub-nav: calc(100vh - var(--nav-bar-height));\n\t/* The viewport height on phones can change. */\n\t--dvh-sub-nav: calc(100dvh - var(--nav-bar-height));\n\t/* The minimum between the viewport width and height */\n\t--vwh: min(var(--vh-sub-nav), var(--vw-capped));\n}\n\n/* Everything besides the top navigation bar */\n\nmain {\n\tposition: fixed;\n\ttop: var(--nav-bar-height);\n\tbottom: 0;\n\tleft: 0;\n\tright: 0;\n\tdisplay: flex;\n\tflex-direction: row;\n\t/* Board editor sidebar dimensions */\n\t--editor-sidebar-width: 240px;\n\t--editor-tab-width: 28px;\n\t--editor-menu-shadow: 3px 0px 10px 0px rgba(0, 0, 0, 0.4);\n\t--editor-transition: transform 0.25s ease;\n}\n\nbutton {\n\tcursor: pointer;\n}\n\n/* Left vertical bar of Board Editor */\n\n.editor-menu {\n\tposition: relative;\n\theight: 100%;\n\toverflow-x: clip;\n\toverflow-y: auto;\n\tbox-shadow: var(--editor-menu-shadow);\n\tbackground-color: #ffffff;\n\tz-index: 4; /* Stops nav bar shadow from being overtop the editor menu */\n\t/* Controls the size of all its children. */\n\tfont-size: 16px;\n\twidth: var(--editor-sidebar-width);\n}\n\n/* Toggle button: only visible on narrow screens, positioned as a sibling of the sidebar */\n/* Uses transform (GPU-accelerated) to stay perfectly in sync with the sidebar animation */\n.editor-menu-toggle {\n\tdisplay: none;\n\tposition: absolute;\n\tleft: 0;\n\ttop: 50%;\n\ttransform: translateY(-50%);\n\twidth: var(--editor-tab-width);\n\theight: 70px;\n\tbackground-color: #ffffff;\n\tborder-radius: 0 6px 6px 0;\n\tbox-shadow: var(--editor-menu-shadow);\n\t/* Clip any leftward shadow bleed so it doesn't visually separate from the sidebar */\n\tclip-path: inset(-100px -100px -100px 0);\n\talign-items: center;\n\tjustify-content: center;\n\tz-index: 4; /* Must be above .load-position-modal */\n\tfont-size: 14px;\n\tcolor: #333;\n\ttransition: var(--editor-transition);\n}\n\n.editor-menu-toggle::after {\n\tcontent: '▶';\n}\n\n/*\n * Narrow screens: sidebar becomes a collapsible overlay.\n * MUST MATCH guifloatingwindow.NARROW_THRESHOLD\n */\n@media only screen and (max-width: 727px) {\n\t.editor-menu {\n\t\tposition: absolute;\n\t\ttransform: translateX(-100%);\n\t\ttransition: var(--editor-transition);\n\t}\n\n\t.editor-menu.expanded {\n\t\ttransform: translateX(0);\n\t}\n\n\t/* Show the toggle whenever the editor is open (sidebar not hidden) */\n\t.editor-menu:not(.hidden) ~ .editor-menu-toggle {\n\t\tdisplay: flex;\n\t}\n\n\t/* Collapsed: only vertical centering — tab sits flush at left: 0 */\n\t/* (inherits base transform: translateY(-50%) — no override needed) */\n\n\t/* Expanded: tab slides to the right edge of the sidebar */\n\t.editor-menu.expanded ~ .editor-menu-toggle {\n\t\ttransform: translateX(var(--editor-sidebar-width)) translateY(-50%);\n\t}\n\n\t.editor-menu.expanded ~ .editor-menu-toggle::after {\n\t\tcontent: '◀';\n\t}\n}\n\n.editor-header {\n\ttext-align: center;\n\tfont-weight: bold;\n\tmargin: 0.5em 0;\n\tcolor: #333;\n}\n\n.editor-separator {\n\tborder: none;\n\tborder-top: 1px solid #ccc;\n\tmargin: 0.2em 0.2em;\n}\n\n/* The row containing the position name and dirty indicator. */\n.editor-positionname-row {\n\tdisplay: flex;\n\tjustify-content: center;\n\talign-items: center;\n\tgap: 0.35em;\n\tpadding: 0.3em 1em;\n}\n\n/* Dirty (unsaved changes) indicator: an amber dot immediately left of the position name */\n.dirty-indicator {\n\tflex-shrink: 0;\n\twidth: 0.6em;\n\theight: 0.6em;\n\tborder-radius: 50%;\n\tbackground-color: #f59e0b;\n}\n\n.editor-positionname {\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n}\n\n/* SVG resizings */\n\n.svg-reset {\n\ttransform: scale(0.85) translate(2%, 0%);\n}\n\n.svg-delete {\n\taspect-ratio: 1;\n\ttransform: scale(0.8);\n}\n\n.svg-load-position {\n\ttransform: scale(0.75);\n}\n\n.svg-save-position-as {\n\ttransform: scale(0.82);\n}\n\n.svg-save-position {\n\ttransform: scale(0.82);\n}\n\n.svg-copy-notation {\n\ttransform: scale(-0.75);\n}\n\n.svg-paste-notation {\n\ttransform: scale(0.75);\n}\n\n.svg-gamerules {\n\ttransform: scale(0.75);\n}\n\n.svg-start-local-game {\n\ttransform: scale(0.9);\n}\n\n.svg-start-engine-game {\n\ttransform: scale(0.8);\n}\n\n.svg-normal {\n\ttransform: scale(0.65) translate(3%, 0%);\n}\n\n.svg-eraser {\n\ttransform: scale(0.85);\n}\n\n.svg-selection-tool {\n\ttransform: scale(0.9) translate(-1%, -1%);\n}\n\n.svg-select-all {\n\ttransform: scale(0.8);\n}\n\n.svg-delete-selection {\n\ttransform: scale(1);\n}\n\n.svg-copy-selection {\n\ttransform: scale(0.8);\n}\n\n.svg-paste-selection {\n\ttransform: scale(0.75);\n}\n\n.svg-flip-selection-horizontally {\n\ttransform: scale(0.8);\n}\n\n.svg-flip-selection-vertically {\n\ttransform: scale(0.8);\n}\n\n.svg-rotate-selection-left {\n\ttransform: scale(0.85);\n}\n\n.svg-rotate-selection-right {\n\ttransform: scale(-0.85, 0.85);\n}\n\n.svg-invert-selection-color {\n\ttransform: scale(0.8);\n}\n\n/* Specific section stylings... */\n\n.position-actions,\n.selection-actions {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(5, 1fr);\n}\n\n/* Buttons that can't receive the .active class (only 1 state): Give them an active state */\n.position-actions .instant:active,\n.selection-actions div:active {\n\tbackground-color: var(--background-theme-color);\n}\n\n.position-actions div,\n.editor-tools div,\n.selection-actions div,\n.editor-types .piece {\n\tcursor: pointer;\n}\n\n.editor-tools {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(4, 1fr);\n}\n\n.selection-actions div.disabled {\n\topacity: 0.4;\n\tpointer-events: none;\n}\n\n/* Palette */\n\n.color-select {\n\tborder: 0.2em solid black;\n\tborder-radius: 1.5em;\n\tmargin: 0.5em;\n\theight: 2.8em;\n\tbox-sizing: border-box;\n\tdisplay: flex;\n\tjustify-content: center;\n\talign-items: center;\n}\n\n.color-select:hover {\n\toutline: 0.25em solid black;\n\toutline-offset: -0.125em;\n\tcursor: pointer;\n}\n\n/* Text with this class takes the opposite color of the background beneath. */\n.opposite-color-text {\n\tcolor: white; /* base color to difference from */\n\tmix-blend-mode: difference;\n}\n\n/* The grid of piece types in the Palette section */\n.editor-types {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(4, 1fr);\n}\n\n.editor-types .piece,\n.editor-tools div,\n.position-actions div,\n.selection-actions div {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tborder-radius: 0.2em;\n}\n\n.editor-types .piece {\n\tmargin: 0.1em;\n}\n\n.editor-tools div,\n.position-actions div,\n.selection-actions div {\n\tmargin: 0.2em;\n}\n\n.editor-types .piece:hover,\n.editor-tools div:hover,\n.position-actions div:hover,\n.selection-actions div:hover {\n\toutline: 0.25em solid black;\n\toutline-offset: -0.125em;\n}\n\n.active {\n\toutline-offset: -0.125em;\n\toutline: 0.25em solid black;\n\tbackground-color: var(--background-theme-color);\n}\n\n.void {\n\tbackground-color: black;\n\tmargin: 5px;\n}\n\n.editor-types .void:hover {\n\toutline: 0.25em solid #757575;\n}\n\n.editor-types .void.active {\n\toutline: 0.25em solid #6d6d6d;\n}\n\n/* Entire board UI, including loading screen, canvas and overlay */\n\n#boardUI {\n\tposition: relative;\n\tflex-grow: 1;\n\tmin-width: 0;\n\theight: 100%;\n}\n\n/* Loading Page. A COUPLE OF THSEE CLASSES are also used for the game's loading animation page! */\n\n.animation-container {\n\ttransition: opacity 0.4s;\n\tz-index: 1;\n\tpointer-events: none;\n\tdisplay: flex;\n\tbackground-color: black;\n\tjustify-content: center; /* Center horizontally */\n\talign-items: center; /* Center vertically */\n\tposition: absolute;\n\ttop: 0;\n\tbottom: 0;\n\tleft: 0;\n\tright: 0;\n\toverflow: hidden;\n}\n\n.loading-glow {\n\tposition: absolute;\n\ttop: 0;\n\tbottom: 0;\n\tleft: 0;\n\tright: 0;\n\t--ring-color: rgb(60, 60, 60, 1);\n\tbackground: radial-gradient(circle, var(--ring-color) 0%, black 70%);\n\tcolor: red;\n\tz-index: -1; /* Render below checkers */\n\ttransition: 0.5s;\n}\n\n.loadingGlowAnimation {\n\tanimation: loadingGlow 1.2s alternate infinite cubic-bezier(0.42, 0, 0.58, 1);\n}\n\n@keyframes loadingGlow {\n\t0% {\n\t\ttransform: scale(1.2);\n\t\topacity: 70%;\n\t}\n\t100% {\n\t\ttransform: scale(2);\n\t}\n}\n\n.loading-glow.loading-glow-error {\n\t--ring-color-error: rgb(60, 45, 45);\n\tbackground: radial-gradient(circle, var(--ring-color-error) 0%, black 70%);\n}\n\n.loading-text {\n\tcolor: white;\n\tposition: absolute;\n\tfont-family: Verdana;\n\tfont-size: calc(30px + 1.2vw);\n\tletter-spacing: 0.05em;\n\tfont-weight: bold;\n\tanimation:\n\t\t0.6s infinite cubic-bezier(0.42, 0, 0.58, 1) alternate loadingPulsing,\n\t\t1.2s infinite cubic-bezier(0.42, 0, 0.58, 1) alternate loadingExpand;\n}\n\n@keyframes loadingPulsing {\n\tfrom {\n\t\topacity: 100%;\n\t}\n\tto {\n\t\topacity: 60%;\n\t}\n}\n\n@keyframes loadingExpand {\n\tfrom {\n\t}\n\tto {\n\t\ttransform: scale(1.04);\n\t}\n}\n\n.loading-error {\n\tcolor: red;\n\tposition: absolute;\n\tfont-family: Verdana;\n\ttext-align: center;\n}\n\n.loading-error h1 {\n\tfont-size: calc(30px + 1.2vw);\n\tletter-spacing: 0.05em;\n\tfont-weight: bold;\n\tmargin-bottom: 0.1em;\n}\n\n.loading-error p {\n\tfont-size: 16px;\n\tpadding: 0 1em;\n}\n\n.checkerboard {\n\twidth: 100vw;\n\theight: 100svh;\n\tbackground: repeating-conic-gradient(black 0% 25%, transparent 0% 50%) 50% / 20vmin 20vmin;\n}\n\n/* Canvas and the Overlay containing all html elements above the canvas */\n\ncanvas {\n\tposition: absolute;\n\twidth: 100%;\n\theight: 100%;\n}\n\n/* The game loading screen when loading svgs and generating spritesheet */\n\n.game-loading-screen {\n\tposition: absolute;\n\twidth: 100%;\n\theight: 100%;\n\tdisplay: flex;\n\tjustify-content: center;\n\talign-items: center;\n}\n\n.game-loading-screen.transparent {\n\t/* Adding this rule here instead of in the non-transparent loading screen\n    means that the opacity will only be transitioned one-way */\n\ttransition: opacity 0.3s;\n}\n\n.game-loading-screen .spinny-pawn {\n\t--width: 90px;\n\t--color: #e5e5e5;\n\twidth: var(--width);\n\theight: var(--width);\n\tstroke: var(--color);\n\tfill: var(--color);\n}\n\n/* The overlay that contains all UI elements overtop the canvas. */\n\n#overlay {\n\tposition: absolute;\n\twidth: 100%;\n\theight: 100%;\n\tcontainer-type: inline-size; /* Enables container queries on this element */\n}\n\n/* Discord & Game Credits external links on title screen and invite creation screen */\n\n.menu-external-links {\n\tposition: absolute;\n\tbottom: 0;\n\tright: 0;\n\tleft: 0;\n\tz-index: 1;\n}\n\n.menu-external-links .discord-icon {\n\tposition: absolute;\n\tleft: 0;\n\tbottom: 0;\n\twidth: calc(30px + var(--vw-capped) * 0.03);\n\tmargin: 8px 17px;\n\topacity: 0.4;\n}\n\n.menu-external-links .discord-icon:hover {\n\topacity: 0.55;\n}\n\n.menu-external-links .github-icon {\n\tposition: absolute;\n\tleft: 0;\n\tbottom: 0;\n\twidth: calc(30px + var(--vw-capped) * 0.03);\n\tmargin: 10px calc(40px + var(--vw-capped) * 0.054);\n\topacity: 0.4;\n}\n\n.menu-external-links .github-icon:hover {\n\topacity: 0.55;\n}\n\n.menu-external-links .credits {\n\topacity: 0.5;\n\tfont-weight: bold;\n\tposition: absolute;\n\tright: 0;\n\tbottom: 0;\n\tcolor: black;\n\ttext-decoration: none;\n\tmargin: 12px 17px;\n\tfont-size: calc(16px + var(--vw-capped) * 0.012);\n}\n\n.menu-external-links .credits:hover {\n\topacity: 0.7;\n}\n\n/* Title Screen: Play, guide, board editor */\n\n.title {\n\tposition: absolute;\n\ttop: 0;\n\tbottom: 0;\n\tleft: 0;\n\tright: 0;\n\tdisplay: grid;\n\tgrid-template:\n\t\tmin(16vw, 173px, calc(var(--vh-sub-nav) * 0.184)) repeat(\n\t\t\t4,\n\t\t\tmin(8vw, 86px, calc(var(--vh-sub-nav) * 0.092))\n\t\t)\n\t\t/ 1fr min(50vw, 542px, calc(var(--vh-sub-nav) * 0.575)) 1fr;\n\tgap: min(2vw, 22px, calc(var(--vh-sub-nav) * 0.023));\n\tpadding-bottom: min(10vw, 108px, calc(var(--vh-sub-nav) * 0.115));\n\tjustify-content: center;\n\talign-content: center;\n}\n\n.title h1 {\n\tfont-size: min(10vw, 108px, calc(var(--vh-sub-nav) * 0.115));\n\tfont-family: Georgia;\n\tcolor: rgb(0, 0, 0);\n\ttext-shadow: 1px 2px 3px rgb(255, 255, 255);\n\ttext-align: center;\n\toverflow: visible;\n\tgrid-column: 1 / 4;\n}\n\n/* All bubble buttons on title screen have similar design */\n.titlebubble {\n\tbox-shadow: 2px 4px 6px 0px rgb(0, 0, 0);\n\tborder: 2px solid rgb(139, 139, 139);\n\tborder-radius: min(1.3vw, 14px, calc(var(--vh-sub-nav) * 0.015));\n\tcolor: rgb(0, 0, 0);\n\tbackground-color: rgb(255, 255, 255);\n\tbackground: linear-gradient(to bottom, white, rgb(226, 226, 226), white);\n}\n\n.title button {\n\tfont-size: min(2.5vw, 27px, calc(var(--vh-sub-nav) * 0.029));\n\tgrid-column: 2 / 3;\n}\n\n.title button:hover {\n\t/* box-shadow: 0 0 15px 0 rgba(255, 255, 255, 0.51); */\n\tbackground: linear-gradient(to bottom, white, rgb(242, 242, 242), white);\n}\n\n.title button:active {\n\t/* background-color: rgb(255, 255, 255); */\n\tbackground: linear-gradient(to bottom, white, rgb(255, 255, 255), white);\n}\n\n/* Practice Page: Practice selection screen */\n\n.practice-selection {\n\tposition: absolute;\n\ttop: 0;\n\tbottom: 0;\n\tleft: 0;\n\tright: 0;\n\tdisplay: grid;\n\t/* prettier-ignore */\n\tgrid-template: min(8vw, 86px, calc(var(--vh-sub-nav) * 0.092)) min(58vw, 628px, calc(var(--vh-sub-nav) * 0.667)) min(8vw, 86px, calc(var(--vh-sub-nav) * 0.092)) / repeat(6, min(13vw, 141px, calc(var(--vh-sub-nav) * 0.15)));\n\tgap: min(1.5vw, 16px, calc(var(--vh-sub-nav) * 0.0173));\n\tjustify-content: center;\n\talign-content: center;\n\tmargin-bottom: 8vh;\n}\n\n.practice-selection button {\n\tfont-size: min(2.5vw, 27px, calc(var(--vh-sub-nav) * 0.029));\n}\n\n.practice-selection button:hover {\n\tbackground: linear-gradient(to bottom, white, rgb(242, 242, 242), white);\n}\n\n.practice-selection button:active {\n\tbackground: linear-gradient(to bottom, white, rgb(255, 255, 255), white);\n}\n\n.practice-selection .practice-name {\n\tgrid-column: 1 / 7;\n\talign-self: center;\n\tjustify-self: center;\n\tfont-size: min(2.5vw, 27px, calc(var(--vh-sub-nav) * 0.029));\n}\n\n.practice-selection .checkmate-practice {\n\tgrid-column: 1 / 4;\n}\n\n.practice-selection .tactics-practice {\n\tgrid-column: 4 / 7;\n}\n\n.practice-selection .practice-play {\n\tgrid-column: 1 / 4;\n\tbackground: linear-gradient(to bottom, white, rgb(226, 226, 226), white);\n}\n\n.practice-selection .practice-back {\n\tgrid-column: 4 / 7;\n}\n\n.practice-selection .selected {\n\tbox-shadow: none;\n}\n\n.practice-box {\n\tfont-size: min(2.5vw, 27px, calc(var(--vh-sub-nav) * 0.029));\n\tgrid-column: 1 / 7;\n\tdisplay: flex;\n\tflex-direction: column;\n}\n\n.practice-head {\n\tfont-family: Verdana;\n\tbackground: linear-gradient(to bottom, white, rgb(229, 229, 229), white);\n\tborder-bottom: 2px solid rgb(168, 168, 168);\n\tborder-radius: min(1.3vw, 30px, calc(var(--vh-sub-nav) * 0.015));\n\tdisplay: flex;\n\tjustify-content: space-between;\n\talign-items: center;\n\talign-content: center;\n\tpadding: 0.5em 2em;\n\theight: 4em;\n}\n\n.difficulty-title {\n\tfont-size: 0.8em;\n}\n\n.checkmate-list {\n\tfont-size: min(2.5vw, 27px, calc(var(--vh-sub-nav) * 0.029));\n\toverflow-y: scroll;\n\tdisplay: flex;\n\tflex-direction: column;\n\tflex-grow: 1;\n}\n\n.checkmate {\n\tdisplay: flex;\n\talign-items: center;\n\tfont-size: min(1.7vw, 18px, calc(var(--vh-sub-nav) * 0.02));\n\tjustify-content: center; /* OR: space-between */\n\tmargin: 0.3em;\n\tborder-radius: 0.3em;\n\tborder-width: 0em;\n\theight: 3em;\n}\n\n.checkmate {\n\tbackground-color: rgba(199, 199, 199, 1);\n}\n\n.checkmate.selected {\n\toutline-style: solid;\n\toutline-width: 0.25em;\n\toutline-offset: -0.15em;\n}\n\n.checkmate.beaten {\n\tbackground-color: rgba(0, 128, 0, 0.3);\n}\n\n.checkmate:hover {\n\tbackground-color: rgba(168, 168, 168, 0.8);\n\tcursor: pointer;\n}\n\n.checkmate:active {\n\tbackground-color: rgba(157, 156, 156, 0.8);\n}\n\n.checkmate.beaten:hover {\n\tbackground-color: rgba(0, 128, 0, 0.2);\n}\n\n.checkmate-child {\n\tpadding: 0 0.3em;\n\tmargin: 0.8em;\n}\n\n.completion-mark {\n\twidth: 10%;\n\theight: 100%;\n}\n\n/* Add the checkmark */\n.checkmate.beaten .completion-mark {\n\tbackground-image: url('/img/game/checkmatepractice/checkmark.svg');\n\tbackground-size: contain;\n\tbackground-repeat: no-repeat;\n}\n\n.piecelistW {\n\tdisplay: flex;\n\tjustify-content: center;\n\twidth: 40%;\n\theight: 100%;\n\tmargin-right: 5%;\n}\n\n.checkmate-child.versus {\n\twidth: 5%;\n}\n\n.piecelistB {\n\tdisplay: flex;\n\tjustify-content: center;\n\twidth: 10%;\n\theight: 100%;\n}\n\n.checkmate-difficulty {\n\tdisplay: flex;\n\tjustify-content: center;\n\talign-content: center;\n\twidth: 20%;\n}\n\n.checkmate-progress {\n\twidth: 15%;\n\tfont-size: 1em;\n}\n\n.checkmate-progress-bar {\n\tposition: relative;\n\twidth: 60%;\n\theight: 1.2em;\n\toutline-style: solid;\n\toutline-width: 0.1em;\n\tborder-radius: 0.25em;\n\tfont-size: 0.8em;\n}\n\n/* Badges */\n\n.badge {\n\tposition: absolute;\n\theight: 2.6em;\n\tuser-select: none;\n}\n\n.badge img {\n\theight: 100%;\n}\n\n.badge:hover img {\n\ttransition: transform 0.4s ease;\n\ttransform: scale(1.1);\n}\n\n.unearned {\n\tfilter: contrast(calc(1 / 3)) brightness(1.5);\n}\n\n#checkmate-badge-bronze {\n\tleft: 50%;\n\ttop: 50%;\n\ttransform: translate(-50%, -50%);\n}\n\n#checkmate-badge-silver {\n\tleft: 75%;\n\ttop: 50%;\n\ttransform: translate(-50%, -50%);\n}\n\n#checkmate-badge-gold {\n\tleft: 100%;\n\ttop: 50%;\n\ttransform: translate(-50%, -50%);\n}\n\n/* Badge shine properties are in header.css since they are shared with badges on play page */\n\n.checkmatepiececontainer {\n\talign-self: center;\n\theight: 100%;\n\tbackground-repeat: no-repeat;\n\tbackground-size: 0;\n\tpadding: 0.02em;\n\tmargin: 0.25em;\n\tborder-radius: 1em;\n}\n\n.checkmatepiececontainer.collated {\n\tmargin-left: -0.65em;\n}\n\n.checkmatepiececontainer.collated-strong {\n\tmargin-left: -1.75em;\n}\n\n.checkmatepiece {\n\twidth: 3em;\n\theight: 3em;\n\tbackground-image: inherit;\n\tbackground-repeat: no-repeat;\n\t/* NEEDS TO BE as many times greater than 100% as there are pieces in a row in the spritesheet! 8 pieces => 800% */\n\tbackground-size: 800%;\n}\n\n/* Play Page: Invite creation screen */\n\n.play-selection {\n\tposition: absolute;\n\ttop: 0;\n\tbottom: 0;\n\tleft: 0;\n\tright: 0;\n\tdisplay: grid;\n\t/* prettier-ignore */\n\tgrid-template: repeat(2, min(8vw, 86px, calc(var(--vh-sub-nav) * 0.092))) min(50vw, 542px, calc(var(--vh-sub-nav) * 0.575)) min(8vw, 86px, calc(var(--vh-sub-nav) * 0.092)) / repeat(6, min(13vw, 141px, calc(var(--vh-sub-nav) * 0.15)));\n\tgap: min(1.5vw, 16px, calc(var(--vh-sub-nav) * 0.0173));\n\tjustify-content: center;\n\talign-content: center;\n\tmargin-bottom: 8vh;\n}\n\n.play-selection button {\n\tfont-size: min(2.5vw, 27px, calc(var(--vh-sub-nav) * 0.029));\n}\n\n.play-selection button:hover {\n\tbackground: linear-gradient(to bottom, white, rgb(242, 242, 242), white);\n}\n\n.play-selection button:active {\n\tbackground: linear-gradient(to bottom, white, rgb(255, 255, 255), white);\n}\n\n.play-selection .play-name {\n\tgrid-column: 1 / 7;\n\talign-self: center;\n\tjustify-self: center;\n\tfont-size: min(2.5vw, 27px, calc(var(--vh-sub-nav) * 0.029));\n}\n\n.play-selection .online {\n\tgrid-column: 1 / 3;\n}\n\n.play-selection .local {\n\tgrid-column: 3 / 5;\n}\n\n.play-selection .computer {\n\tgrid-column: 5 / 7;\n}\n\n.play-selection .create-invite {\n\tgrid-column: 1 / 4;\n\tbackground: linear-gradient(to bottom, white, rgb(226, 226, 226), white);\n}\n\n.play-selection .play-back {\n\tgrid-column: 4 / 7;\n}\n\n.play-selection .selected {\n\tbox-shadow: none;\n}\n\n.play-selection .game-options {\n\tfont-size: min(2.5vw, 27px, calc(var(--vh-sub-nav) * 0.029));\n\tgrid-column: 1 / 7;\n\toverflow-y: auto;\n\tdisplay: flex;\n\tflex-direction: column;\n}\n\n/* Target the scrollbar */\n.game-options::-webkit-scrollbar {\n\twidth: 9px; /* Set the width of the scrollbar */\n}\n\n/* Set the background color of the scrollbar track */\n.game-options::-webkit-scrollbar-track {\n\tbackground-color: #f1f1f1;\n\tborder-radius: 5px; /* Set the border radius of the track */\n}\n\n/* Set the color and border radius of the scrollbar thumb */\n.game-options::-webkit-scrollbar-thumb {\n\tbackground-color: rgb(174, 174, 174);\n\tborder-radius: 5px; /* Set the border radius of the thumb */\n}\n\n.game-options .options {\n\tbackground: linear-gradient(to bottom, white, rgb(229, 229, 229), white);\n\tborder-bottom: 2px solid rgb(168, 168, 168);\n\t/* border-radius: min(1.3vw, 30px) min(1.3vw, 30px) 0 0; */\n\tborder-radius: min(1.3vw, 30px, calc(var(--vh-sub-nav) * 0.015));\n\tdisplay: flex;\n\tjustify-content: center;\n}\n\n.option-card {\n\tdisplay: flex;\n\tflex-flow: column;\n\talign-items: center;\n\tpadding: 0.35em 1.1em;\n}\n\n.game-options .option-card p {\n\tfont-size: min(1.5vw, 16px, calc(var(--vh-sub-nav) * 0.017));\n\ttext-align: center;\n\tpadding-bottom: 0.3em;\n}\n\n.game-options select {\n\tborder: 1.5px solid grey;\n\tborder-radius: 0.75em;\n\tpadding: 0.6em 0.9em;\n\tfont-size: min(1.5vw, 16px, calc(var(--vh-sub-nav) * 0.017));\n\tbox-sizing: content-box;\n\tmin-width: 3em;\n\tmax-width: 6em;\n\ttext-align: center;\n\n\t/* Remove arrow */\n\t-webkit-appearance: none;\n\t-moz-appearance: none;\n\tappearance: none;\n}\n\n#option-clock {\n\tmax-width: 5em;\n}\n\n.invite-list {\n\tflex-grow: 1;\n}\n\n.game-options .join-existing {\n\ttext-align: center;\n\tfont-size: min(1.7vw, 18px, calc(var(--vh-sub-nav) * 0.02));\n\tpadding: 0.5em;\n}\n\n.game-options .invite {\n\tbackground-color: rgba(0, 0, 255, 0.227);\n\theight: 3em;\n\tdisplay: flex;\n\talign-items: center;\n\tfont-size: min(1.7vw, 18px, calc(var(--vh-sub-nav) * 0.02));\n\tjustify-content: space-between;\n\tmargin: 0.4em;\n\tborder-radius: 0.3em;\n\tcursor: pointer;\n}\n\n.invite .invite-child {\n\tpadding: 0 0.6em;\n}\n\n.invite .invite-child.accept {\n\tmargin-right: 0.8em;\n\tpadding: 0.5em 0.8em;\n\tborder-radius: 0.5em;\n}\n\n.invite.hover {\n\tbackground-color: rgba(48, 145, 255, 0.442);\n}\n\n.invite.hover .accept {\n\tbackground-color: rgba(255, 255, 255, 0.299);\n}\n\n.invite.ours {\n\tbackground-color: rgba(156, 36, 255, 0.303);\n}\n\n.invite.ours.hover {\n\tbackground-color: rgba(255, 36, 178, 0.266);\n}\n\n.invite.private {\n\tbackground-color: rgba(0, 0, 0, 0.266);\n}\n\n.invite.private.hover {\n\tbackground-color: rgba(0, 0, 0, 0.22);\n}\n\n.join-private,\n.invite-code {\n\tdisplay: flex;\n\tjustify-content: center;\n\talign-items: center;\n\tfont-size: 0.9em;\n\tbackground: linear-gradient(to bottom, white, rgb(229, 229, 229), white);\n\tpadding: 0.5em 0;\n\tborder-top: 2px solid rgb(168, 168, 168);\n\tborder-radius: min(1.3vw, 30px, calc(var(--vh-sub-nav) * 0.015));\n}\n\n.textbox-private {\n\tfont-size: 0.8em;\n\tmargin: 0 1.8em 0 1em;\n\ttext-align: center;\n\tbackground-color: rgba(255, 255, 255, 0.291);\n\tborder: 0;\n\tborder-radius: 0.5em;\n\tpadding: 0.4em 0;\n\tbox-shadow: 0 0 0.4em rgba(0, 0, 0, 0.398);\n\twidth: 4.6em;\n}\n\n.textbox-private:hover {\n\tbox-shadow: 0 0 0.4em rgba(0, 0, 0, 0.631);\n}\n\n.textbox-private:focus {\n\toutline: solid 1px black;\n}\n\n.invite-code-code {\n\tfont-size: 1.1em;\n\tmargin: 0 1.1em 0 0.7em;\n\ttext-shadow: 0.05em 0.1em 0.15em rgba(0, 0, 0, 0.175);\n\tfont-weight: bold;\n}\n\nbutton.join-button,\nbutton.copy-button {\n\tfont-size: 0.8em;\n\tbackground-color: white;\n\tpadding: 0.45em 0.65em;\n\tborder-radius: 0.6em;\n\tbox-shadow: 0 0 0.4em rgba(0, 0, 0, 0.649);\n\tbackground: linear-gradient(to bottom, white, rgb(226, 226, 226), white);\n}\n\n/* Top Navigation: Zoom buttons, coordinates, rewind/forward game, pause */\n\n.navigation-bar {\n\tposition: absolute;\n\ttop: 0;\n\tleft: 0;\n\twidth: 100%;\n\tfont-size: 84px; /* Update with doc!! */\n\theight: 1em;\n\tdisplay: flex;\n\tjustify-content: space-between;\n\tbox-shadow: 0px 1px 7px 0px rgba(0, 0, 0, 0.659);\n\tbackground: linear-gradient(\n\t\tto top,\n\t\trgba(255, 255, 255, 0.104),\n\t\trgba(255, 255, 255, 0.552),\n\t\trgba(255, 255, 255, 0.216)\n\t);\n\t-webkit-backdrop-filter: blur(\n\t\t5px\n\t); /* Must be BEFORE the unprefixed rules, so Lightning CSS correclty parses! */\n\tbackdrop-filter: blur(8px); /* Apply a blur effect to the background */\n}\n\n.teleport,\n.coords,\n.right-nav {\n\tdisplay: flex;\n\talign-items: center;\n}\n\n.teleport {\n\tjustify-content: flex-start;\n\tpadding-left: 0.14em;\n}\n\n.coords {\n\tjustify-content: center;\n\tflex-grow: 1;\n}\n\n.right-nav {\n\tjustify-content: flex-end;\n\tpadding-right: 0.14em;\n}\n\n#position {\n\tbox-sizing: border-box;\n\tfont-size: 0.19em;\n\theight: 4em;\n\tmargin: 0.44em;\n\tborder-radius: 0.5em;\n\tbackground-color: rgb(255, 255, 255);\n\tbox-shadow: 0px 0px 7px 0px rgba(0, 0, 0, 0.878);\n\tdisplay: flex;\n\tflex-direction: column;\n\tjustify-content: center;\n}\n\n.x,\n.y {\n\theight: 50%;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n}\n\n.x {\n\tpadding: 0.13em 0 0 0.44em;\n\tborder-radius: 0.5em 0.5em 0 0;\n\tborder-bottom: 1px solid rgb(161, 161, 161);\n}\n\n.y {\n\tpadding: 0 0 0.13em 0.44em;\n\tborder-radius: 0 0 0.5em 0.5em;\n}\n\n#x,\n#y {\n\tmargin-right: 0.31em;\n\tpadding: 0.06em 0.19em;\n\tborder-radius: 0.19em;\n\twidth: 7.5em;\n\tfont-size: 1em;\n\tbackground-color: rgb(245, 245, 245);\n\tcolor: rgb(37, 37, 37);\n}\n\n/* The increment and decrement arrow heads spin buttons on the input element */\n#x::-webkit-inner-spin-button,\n#y::-webkit-inner-spin-button {\n\t-webkit-appearance: none;\n\tmargin: 0;\n}\n\n.navigation-bar .button {\n\tposition: relative;\n\twidth: 0.74em;\n\theight: 0.74em;\n\tmargin: 0.07em;\n\tborder-radius: 0.16em;\n\tbackground-color: rgb(255, 255, 255);\n\tbox-shadow: 0px 0px 7px 0px rgba(0, 0, 0, 0.878);\n\ttransition: transform 0.15s;\n\tcursor: pointer;\n\t-webkit-tap-highlight-color: transparent; /* No more blue highlight when tapping buttons on mobile */\n}\n\n.navigation-bar .button:hover {\n\ttransform: scale(1.07);\n}\n\n.navigation-bar .button:active {\n\ttransform: scale(1);\n}\n\n.navigation-bar svg {\n\tposition: absolute;\n}\n\nsvg.pencil {\n\ttransform: scale(0.7) translate(0.02em, 0);\n}\nsvg.erase {\n\ttransform: scale(0.75) translate(-0.015em, 0);\n}\nsvg.collapse {\n\ttransform: scale(0.75) translate(0, 0.02em);\n}\n\n/* Color annotations button bright blue when enabled */\n#annotations.enabled {\n\tbackground: radial-gradient(\n\t\trgb(255, 100, 0),\n\t\trgb(255, 100, 0),\n\t\trgb(255, 100, 100),\n\t\trgb(255, 170, 170),\n\t\trgb(255, 255, 255)\n\t);\n}\n\n/* Annotation buttons aren't visible on desktop */\n@media only screen and (pointer: fine) {\n\t/* Desktop */\n\t.buttoncontainer.annotations {\n\t\tdisplay: none;\n\t}\n\t.buttoncontainer.erase {\n\t\tdisplay: none;\n\t}\n\t.buttoncontainer.collapse {\n\t\tdisplay: none;\n\t}\n}\n\n/* Start shrinking top navigation bar */\n@container (max-width: 700px) {\n\t@media only screen and (pointer: fine) {\n\t\t/* Desktop */\n\t\t.navigation-bar {\n\t\t\tfont-size: 12cqw; /* Update with doc!! */\n\t\t}\n\t}\n}\n@container (max-width: 803px) {\n\t@media only screen and (pointer: coarse) {\n\t\t/* Mobile */\n\t\t.navigation-bar {\n\t\t\tfont-size: 10.5cqw; /* Update with doc!! */\n\t\t}\n\t}\n}\n\n/* Small screens. HIDE the coords and make the buttons size constant! */\n@container (max-width: 550px) {\n\t@media only screen and (pointer: fine) {\n\t\t/* Desktop */\n\t\t.navigation-bar {\n\t\t\tjustify-content: space-between;\n\t\t\tfont-size: 66px; /* Update with doc!! */\n\t\t}\n\t\t.coords {\n\t\t\tdisplay: none;\n\t\t}\n\t}\n}\n@container (max-width: 625px) {\n\t@media only screen and (pointer: coarse) {\n\t\t/* Mobile */\n\t\t.navigation-bar {\n\t\t\tfont-size: 66px; /* Update with doc!! */\n\t\t}\n\t\t.coords {\n\t\t\tdisplay: none;\n\t\t}\n\t}\n}\n\n/* Mobile screen, start shrinking the size again */\n@container (max-width: 368px) {\n\t@media only screen and (pointer: fine) {\n\t\t/* Desktop */\n\t\t.navigation-bar {\n\t\t\tfont-size: 17.9cqw; /* Update with doc!! */\n\t\t}\n\t}\n}\n@container (max-width: 483px) {\n\t@media only screen and (pointer: coarse) {\n\t\t/* Mobile */\n\t\t.navigation-bar {\n\t\t\tfont-size: 13.7cqw; /* Update with doc!! */\n\t\t}\n\t}\n}\n\n/* Bottom Navigation: Color to move, clocks, player names, draw offer UI */\n\n.game-info-bar {\n\tposition: absolute;\n\tbottom: 0;\n\twidth: 100%;\n\theight: 84px;\n\tbox-shadow: 0px -1px 7px 0px rgba(0, 0, 0, 0.659);\n\tdisplay: flex;\n\tbackground: linear-gradient(\n\t\tto bottom,\n\t\trgba(255, 255, 255, 0.307),\n\t\twhite,\n\t\trgba(255, 255, 255, 0.84)\n\t);\n\t-webkit-backdrop-filter: blur(\n\t\t5px\n\t); /* Must be BEFORE the unprefixed rules, so Lightning CSS correclty parses! */\n\tbackdrop-filter: blur(8px); /* Apply a blur effect to the background */\n}\n\n/* Stores their username container and timer */\n.player-container {\n\talign-content: center;\n\tpadding: 0 10px;\n\twidth: fit-content;\n\t/* Capping the width to a percentage of the gameinfo bar prevents them overflowing & black's clock pushed off the screen. */\n\tmax-width: 35%;\n}\n\n/* Stores the username containers */\n.playerwhite,\n.playerblack {\n\tdisplay: flex;\n}\n.playerwhite {\n\tjustify-content: left;\n}\n.playerblack {\n\t/* Don't need to justify here since the spacing is specially handled by guigameinfo.ts */\n}\n\n/* Stores the timer */\n.timer-container {\n\tdisplay: flex;\n\talign-items: center;\n\tpadding-top: 5px;\n}\n.timer-container.left {\n\tjustify-content: left;\n}\n.timer-container.right {\n\tjustify-content: right;\n}\n\n.timer {\n\tpadding: 6px 9px;\n\tfont-size: 18px;\n\tborder-radius: 4px;\n\tborder: 1px solid black;\n}\n.timer.white {\n\tbackground-color: rgb(255, 255, 255);\n\tcolor: rgb(0, 0, 0);\n}\n.timer.black {\n\tbackground-color: rgb(0, 0, 0);\n\tcolor: white;\n}\n\n.whosturn {\n\tflex-grow: 1;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\ttext-align: center;\n\tfont-size: 20px;\n\tfont-weight: bold;\n\tpadding: 0 8px;\n\tmin-width: 0px; /* Prevents the minimum width fitting the longest word */\n}\n\n/* Draw Offer UI (in the bottom nav bar) */\n\n.draw_offer_ui,\n.practice-engine-buttons {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 0.3em;\n\theight: 100%;\n}\n\n.draw_offer_ui .offer_title {\n\tfont-size: 0.9em;\n}\n\n.draw_offer_ui button,\n.practice-engine-buttons button {\n\tbackground-color: white;\n\tborder-radius: 1em;\n\twidth: 4em;\n\theight: 4em;\n\tdisplay: flex;\n\tjustify-content: center;\n\talign-items: center;\n\tborder: 2px solid grey;\n\tfont-size: 0.45em;\n}\n\n.draw_offer_ui svg {\n\theight: 90%;\n}\n\n.draw_offer_ui button:hover,\n.practice-engine-buttons button:hover {\n\tbackground: linear-gradient(to bottom, white, rgb(230, 230, 230), white);\n}\n\n.draw_offer_ui button:active,\n.practice-engine-buttons button:active {\n\tbackground: linear-gradient(to bottom, white, rgb(230, 230, 230), white);\n}\n\n/* Game Control Buttons (in the bottom nav bar) */\n\n.practice-engine-buttons {\n\tfont-size: 30px;\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 0.3em;\n\theight: 100%;\n\tmargin: 0 0.5em;\n\t-webkit-tap-highlight-color: transparent; /* No more blue highlight when tapping buttons on mobile */\n}\n\n.practice-engine-buttons .svg-undo {\n\twidth: 72%;\n\ttransform-origin: 53% 55%;\n}\n\n.practice-engine-buttons .svg-restart {\n\taspect-ratio: 1;\n\twidth: 84%;\n\ttransform: translate(0.5px, 0.5px);\n}\n\n/* Promotion UI */\n\n#promote {\n\tmin-width: 280px;\n\tmax-width: 400px;\n\tpadding: 10px;\n\tborder-radius: 10px;\n\tposition: absolute;\n\tleft: 50%;\n\ttop: 50%;\n\ttransform: translate(-50%, -50%);\n\tbackground-color: rgba(255, 255, 255, 0.949);\n\tbox-shadow: 0px 0px 12px 0px rgba(0, 0, 0, 0.918);\n}\n\n.promotecolor {\n\tdisplay: flex;\n\tjustify-content: space-evenly;\n\tflex-wrap: wrap;\n}\n\n.promotepiece {\n\twidth: 80px;\n\theight: 80px;\n\tpadding: 3px;\n\tmargin: 3px;\n\tborder-radius: 10px;\n}\n\n.promotepiece:hover {\n\tbackground-color: rgba(0, 0, 0, 0.099);\n}\n\n.promotepiece:active {\n\tbackground-color: rgba(0, 0, 0, 0.158);\n}\n\n/* Shared floating Window UI */\n.floating-window {\n\tdisplay: flex;\n\tflex-direction: column;\n\tmax-height: min(80vh, var(--dvh-sub-nav));\n\tbox-sizing: border-box;\n\tpadding: 0 18px 16px 18px;\n\tborder-radius: 12px;\n\tposition: absolute;\n\tleft: 1%;\n\ttop: 11%;\n\tbackground-color: rgba(255, 255, 255, 0.96);\n\tbox-shadow: 0 0 12px rgba(0, 0, 0, 0.75);\n\tfont-family: 'Segoe UI', Roboto, sans-serif;\n\tfont-size: 14px;\n\tcolor: #222;\n\t--editor-floating-window-btn-primary: #1d8ee4;\n\t--editor-floating-window-btn-primary-hover: #0d7ed4;\n\t--editor-floating-window-btn-secondary: #e0e0e0;\n\t--editor-floating-window-btn-secondary-hover: #d5d5d5;\n}\n\n.floating-window.scrolling {\n\toverflow: auto;\n}\n\n.floating-window.nonscrolling {\n\toverflow: hidden;\n}\n\n.floating-window.narrow {\n\twidth: 100%;\n\tbox-sizing: border-box;\n\tmax-width: min(220px, 60vw);\n}\n\n.floating-window.wide {\n\twidth: 100%;\n\tmax-width: calc(2 * min(230px, 60vw));\n}\n\n/* Header (draggable area + close button) */\n.window-header {\n\tcursor: move;\n\tuser-select: none;\n\tdisplay: flex;\n\tjustify-content: space-between;\n\talign-items: center;\n\tpadding: 8px 0 4px 0;\n\tfont-size: 18px;\n\tfont-weight: 600;\n}\n\n.close-floating-window {\n\tbackground: none;\n\tborder: none;\n\tcolor: #616161;\n\tfont-size: 20px;\n\tcursor: pointer;\n\tfont-weight: bold;\n}\n\n.close-floating-window:hover {\n\tcolor: #000000;\n\ttransform: scale(1.1);\n}\n\n.divider {\n\tborder: none;\n\tborder-bottom: 1px solid #ccc;\n\tmargin-bottom: 12px;\n}\n\n/* Floating window UI confirmation button styling */\n.confirmation-buttons {\n\tdisplay: flex;\n\tjustify-content: center;\n\tgap: 12px;\n\tmargin-top: 18px;\n}\n\n.confirmation-buttons .btn {\n\tmin-width: 80px;\n\tpadding: 10px 18px;\n\tborder-radius: 8px;\n\tfont-size: 15px;\n\tfont-weight: 600;\n\tcursor: pointer;\n\tborder: 1px solid transparent;\n}\n\n.btn-primary {\n\tbackground-color: var(--editor-floating-window-btn-primary);\n\tcolor: #ffffff;\n}\n\n.btn-primary:hover {\n\tbackground-color: var(--editor-floating-window-btn-primary-hover);\n}\n\n.btn-secondary {\n\tbackground-color: var(--editor-floating-window-btn-secondary);\n\tcolor: #222;\n}\n\n.btn-secondary:hover {\n\tbackground-color: var(--editor-floating-window-btn-secondary-hover);\n}\n\n/* Floating window UI input styling */\n.flwindow-section {\n\tmargin-bottom: 10px;\n\tdisplay: flex;\n\tflex-direction: column;\n}\n\n.flwindow-section label {\n\tfont-weight: 500;\n\tmargin-bottom: 4px;\n\tword-wrap: break-word;\n}\n\n.floating-window .invalid-input {\n\t/* Higher specificity to override default border-color */\n\tbackground-color: #f8d7da; /* light red */\n\tborder-color: #f5c2c7; /* optional border */\n}\n\n.input-pair {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 6px;\n\twidth: 100%; /* make the container take full width */\n}\n\n.input-pair input {\n\tflex: 1; /* allow inputs to expand equally */\n\tmin-width: 0; /* ensures proper shrinking in flex layouts */\n\tpadding: 4px 8px; /* adjust padding as needed */\n\ttext-align: center; /* keep numbers/letters centered */\n\tborder: 1px solid #ccc;\n\tborder-radius: 6px;\n\ttransition: border 0.2s;\n}\n\n.flwindow-section input[type='text'] {\n\tborder: 1px solid #ccc;\n\tborder-radius: 6px;\n\tpadding: 5px 8px;\n\ttransition: border 0.2s;\n}\n\n.flwindow-section input:focus {\n\tborder-color: #4285f4;\n\toutline: none;\n}\n\n.toggle-group {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tgap: 10px;\n}\n\n.toggle-group label {\n\tcursor: pointer;\n}\n\n.flwindow-list {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: 6px; /* space between items */\n}\n\n.checkbox-item {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 8px; /* space between checkbox and label */\n\tline-height: 1.2;\n}\n\n.checkbox-item input[type='checkbox'] {\n\twidth: 16px;\n\theight: 16px;\n\tcursor: pointer;\n\taccent-color: #4285f4; /* optional for modern browsers */\n\tvertical-align: middle;\n\tposition: relative;\n\ttop: -2px; /* fine-tune to align checkbox center with label text */\n}\n\n/* Tri-state (indeterminate) style enhancement */\n.checkbox-item input[type='checkbox']:indeterminate {\n\tbackground-color: #ddd;\n\tborder: 1px solid #888;\n\tappearance: none;\n\tdisplay: inline-block;\n\tposition: relative;\n}\n\n.checkbox-item input[type='checkbox']:indeterminate::after {\n\tcontent: '';\n\tposition: absolute;\n\ttop: 50%;\n\tleft: 50%;\n\twidth: 10px;\n\theight: 2px;\n\tbackground-color: #333;\n\ttransform: translate(-50%, -50%);\n\tborder-radius: 1px;\n}\n\n.floating-window input::placeholder {\n\tcolor: #999;\n}\n\n/* Board editor load & save positions UI */\n\n.saved-positions {\n\t/* Fixed column widths */\n\t--w-count: 70px;\n\t--w-date: 95px;\n\t--w-header-actions: 52px;\n\t/* Shared column structure (everything left of the action buttons) */\n\t--grid-layout: auto var(--w-count) var(--w-date);\n\n\t/* Allows flex items to shrink below their content size,\n\tallowing the positions list to be scrollable instead of clipping */\n\tmin-height: 0;\n}\n\n/* Expand header actions width when cloud button is present (user logged in) */\n.saved-positions.with-cloud {\n\t--w-header-actions: 78px;\n}\n\n.saved-position-header {\n\tdisplay: grid;\n\tgrid-template-columns: var(--grid-layout) var(--w-header-actions);\n\talign-items: center;\n\tpadding: 0 0.5em;\n\tmargin-bottom: 0.4em;\n\tposition: relative;\n}\n\n.saved-position-header > div {\n\tpadding-right: 0.5em;\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n}\n\n/* The last column of the saved-position-header holds the loading animation */\n.saved-position-header > div:last-child {\n\tdisplay: flex;\n\tjustify-content: flex-end;\n\tpadding-right: 0;\n}\n\n/* Spinny pawn in the saved positions header — fixed size for grid context */\n.saved-position-header .svg-pawn {\n\theight: 28px;\n\tbottom: -1px;\n\tposition: absolute;\n}\n\n.saved-position-list {\n\tmin-height: 80px;\n\toverflow-y: auto;\n}\n\n.saved-position-list-empty {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\theight: 80px;\n\tcolor: #888;\n\tfont-style: italic;\n}\n\n.saved-position {\n\theight: 2.5em;\n\tborder-bottom: 1px solid rgba(0, 0, 0, 0.12);\n\tdisplay: grid;\n\talign-items: center;\n\tgrid-template-columns: var(--grid-layout) max-content max-content max-content;\n\tpadding: 0 0.5em;\n}\n\n.saved-position:nth-child(even) {\n\tbackground-color: #f7f7f7;\n}\n\n.saved-position.active-position {\n\t--mid: var(--background-theme-color);\n\t--end: color-mix(in srgb, var(--mid), white 50%);\n\tbackground: linear-gradient(var(--end), var(--mid), var(--end));\n}\n\n.saved-position > div {\n\tpadding-right: 0.5em;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\twhite-space: nowrap;\n}\n\n/* Hide piece count column on semi-small screens */\n@container (max-width: 390px) {\n\t.saved-positions .piece-count {\n\t\tdisplay: none;\n\t}\n\t.saved-positions {\n\t\t--grid-layout: auto var(--w-date);\n\t}\n}\n/* Also hide date column on very small screens */\n@container (max-width: 320px) {\n\t.saved-positions .date {\n\t\tdisplay: none;\n\t}\n\t.saved-positions {\n\t\t--grid-layout: auto;\n\t}\n}\n\n.position-name {\n\tdisplay: flex;\n\tflex-direction: column;\n\tmargin-bottom: 12px;\n}\n\n.position-name-row {\n\tdisplay: flex;\n\tgap: 8px;\n}\n\n.position-name-row input {\n\tflex-grow: 1;\n\tmin-width: 0;\n}\n\n.position-name-btnsave {\n\tflex-shrink: 0;\n\twidth: 75px;\n\tborder-radius: 8px;\n\tfont-size: 15px;\n\tfont-weight: 600;\n\tbackground-color: var(--editor-floating-window-btn-primary);\n\tcolor: #ffffff;\n}\n\n.position-name-btnsave:hover {\n\tbackground-color: var(--editor-floating-window-btn-primary-hover);\n}\n\n.saved-position-btn {\n\tbackground-color: transparent;\n\tdisplay: inline-flex;\n\talign-items: center;\n\tjustify-content: center;\n\twidth: 26px;\n}\n\n.saved-position-btn:hover svg {\n\ttransform: scale(1.1);\n}\n\n.saved-position-btn svg {\n\twidth: 22px;\n\taspect-ratio: 1;\n}\n\n/* Cloud save button: greyed-out when position is local (not saved on cloud) */\n.cloud-save.local svg {\n\topacity: 0.35;\n}\n\n/* The overlay covers ONLY the load-position-UI */\n.load-position-modal-overlay {\n\tposition: absolute;\n\tinset: 0;\n\tz-index: 3;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tbackground: rgba(0, 0, 0, 0.25);\n\tpointer-events: auto;\n}\n\n.load-position-modal {\n\twidth: 90%;\n\tborder-radius: 0.75em;\n\tbackground-color: rgba(255, 255, 255, 0.96);\n\tbox-shadow: 0 14px 40px rgba(0, 0, 0, 0.35);\n\tpadding: 0em 1em 0.5em 1em;\n}\n\n.load-position-modal-header {\n\tcursor: unset;\n}\n\n/* Pause UI */\n\n.pauseUI {\n\tposition: absolute;\n\ttop: 0;\n\tbottom: 0;\n\tleft: 0;\n\tright: 0;\n\tz-index: 5; /* Must be above .editor-menu-toggle */\n\tbackground-color: rgba(0, 0, 0, 0.849);\n\tpadding-bottom: 15vh;\n\tdisplay: grid;\n\tgrid-template: repeat(6, min(8vw, 86px, 15dvh)) / repeat(2, min(30vw, 320px));\n\tgap: min(3vw, 32px, calc(4.2dvh - var(--nav-bar-height) / 6));\n\tjustify-content: center;\n\talign-content: center;\n}\n\n.pauseUI p.paused,\n.pauseUI button {\n\tfont-size: min(2.5vw, 27px);\n}\n\n.pauseUI p.paused {\n\tcolor: white;\n\ttext-align: center;\n\talign-self: center;\n}\n\n.pauseUI button {\n\tbackground-color: rgb(228, 228, 228);\n\tbox-shadow: 0 0 10px 0 rgba(255, 255, 255, 0.27);\n\tborder-radius: min(0.9vw, 10px);\n\tcolor: rgb(0, 0, 0);\n\tbackground: linear-gradient(to bottom, white, rgb(199, 199, 199), white);\n}\n\n.pauseUI button:hover {\n\t/* background-color: rgb(255, 255, 255); */\n\tbackground: linear-gradient(to bottom, white, rgb(219, 219, 219), white);\n}\n\n.pauseUI button:active {\n\t/* box-shadow: 0 0 15px 0 rgba(255, 255, 255, 0.51); */\n\tbackground: linear-gradient(to bottom, white, rgb(230, 230, 230), white);\n}\n\n.pauseUI p.paused,\nbutton.paused,\nbutton.resume,\nbutton.mainmenu,\nbutton.offerdraw,\nbutton.practicemenu {\n\tgrid-column: 1 / 3;\n}\n\n/* Status text showing alerts and errors */\n\n.toastmessage {\n\tposition: absolute;\n\tbottom: 84px;\n\tleft: 0;\n\tright: 0;\n\tpadding: 1em 8%;\n\tz-index: 1;\n\tpointer-events: none;\n}\n\n.toastmessage .toast {\n\tmargin: 0 auto;\n\tpadding: 0.4em 3em;\n\twidth: fit-content;\n\tfont-size: 18px;\n\ttext-align: center;\n\topacity: 0;\n\twhite-space: pre-wrap;\n\tline-height: 1.5;\n}\n\n.toast.ok {\n\topacity: 1;\n\tcolor: black;\n\t--color: white;\n\tbackground: linear-gradient(\n\t\tto right,\n\t\ttransparent,\n\t\tvar(--color),\n\t\tvar(--color),\n\t\tvar(--color),\n\t\tvar(--color),\n\t\ttransparent\n\t);\n}\n\n.toast.error {\n\topacity: 1;\n\tcolor: white;\n\t--color: rgb(255, 0, 0);\n\tbackground: linear-gradient(\n\t\tto right,\n\t\ttransparent,\n\t\tvar(--color),\n\t\tvar(--color),\n\t\tvar(--color),\n\t\tvar(--color),\n\t\ttransparent\n\t);\n}\n\n/* Status messages along the top-right showing detailed information (move count, fps meter) */\n\n#stats {\n\tposition: absolute;\n\ttop: 0;\n\twidth: 100%;\n\tfont-size: 22px;\n\t/* Allows clicks to pass through to the elements underneath.\n     FIXES A BUG that doesn't let you click arrows along the top of the screen while the move count is visible!! */\n\tpointer-events: none;\n}\n\n.status {\n\ttext-align: right;\n\tmargin: 0.4em 0.6em;\n\tword-break: break-all;\n}\n\n/* General classes with basic properties */\n\n.center {\n\ttext-align: center;\n}\n\na {\n\t-webkit-tap-highlight-color: rgba(0, 0, 0, 0.099);\n}\n\n.hidden {\n\tdisplay: none;\n}\n\n.opacity-0_5 {\n\topacity: 0.5;\n}\n\n.opacity-0_25 {\n\topacity: 0.25;\n}\n\n.rotate-180 {\n\ttransform: rotate(180deg);\n}\n\n/* Animations */\n\n@keyframes fade-in {\n\tfrom {\n\t\topacity: 0%;\n\t}\n\tto {\n\t\topacity: 100%;\n\t}\n}\n\n@keyframes fade-out {\n\t0% {\n\t\topacity: 1;\n\t}\n\t100% {\n\t\topacity: 0;\n\t}\n}\n\n.fade-in-1s {\n\tanimation: fade-in 1s;\n}\n\n.fade-out-1s {\n\tanimation: fade-out 1s; /* UPDATE 1s within the document in the toast module! */\n}\n"
  },
  {
    "path": "src/client/css/termsofservice.css",
    "content": "* {\n\tmargin: 0;\n\tpadding: 0;\n\tfont-family: Verdana; /* Helvetica */\n\tborder: 0;\n\t/* Enable temporarily during dev to see the borders of all elements */\n\t/* outline: 1px solid rgba(0, 0, 0, 0.191); */\n}\n\nhtml {\n\theight: 100%;\n\tbackground-color: rgb(33, 33, 33);\n}\n\nmain {\n\tbackground-color: #fff;\n\t/* Using PNG because it was the smallest after compression */\n\tbackground-image: url('/img/blank_board.png');\n\tbackground-position: center;\n\tbackground-repeat: no-repeat;\n\tbackground-size: cover;\n\t-webkit-background-size: cover;\n\t-moz-background-size: cover;\n\t-o-background-size: cover;\n\tbackground-attachment: fixed;\n\n\tmargin-top: 40px;\n\tmin-height: 400px;\n}\n\n#content {\n\tbackground-color: rgba(255, 255, 255, 0.805);\n\tmin-height: 450px;\n\tmargin: auto;\n\tbox-shadow: 0 0 10px rgba(0, 0, 0, 0.522);\n\tpadding: 30px 20px;\n}\n\n#content h1 {\n\tfont-size: 40px;\n\tfont-family: georgia;\n\tmargin-bottom: 40px;\n}\n\nh2 {\n\ttext-align: center;\n\tfont-size: 25px;\n\tmargin-top: 1.5em;\n}\n\nh3 {\n\tfont-size: 18px;\n\ttext-align: center;\n\tfont-weight: normal;\n}\n\n#content p {\n\tline-height: 1.5;\n\tfont-size: 16px;\n\tmargin: 20px 0px;\n}\n\na {\n\tcolor: black;\n\t-webkit-tap-highlight-color: rgba(0, 0, 0, 0.099);\n}\n\n.grey {\n\tcolor: rgba(0, 0, 0, 0.345);\n}\n\n.center {\n\ttext-align: center;\n}\n\n/* Start increasing header links width */\n@media only screen and (min-width: 450px) {\n\t#content h1 {\n\t\tfont-size: calc(40px + 0.028 * (100vw - 450px));\n\t}\n}\n\n/* Cap content width size, revealing image on the sides */\n@media only screen and (min-width: 810px) {\n\t#content {\n\t\tmax-width: calc(810px - 60px); /* 60px less than 810 to account for padding */\n\t\tpadding: 40px 30px;\n\t\tmin-height: 800px;\n\t}\n\n\t#content h1 {\n\t\tfont-size: 50px;\n\t\tmargin-bottom: 50px;\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/cjs/game/htmlscript.ts",
    "content": "// src/client/scripts/cjs/game/htmlscript.ts\n\n/* global main */\n\n/**\n * The server injects this script directly into the html document\n * before serving that.\n * This is so we can execute code that needs to be executed preferrably\n * before the document fully loads (for example, the loading screen,\n * or pre-loading the sound spritesheet)\n *\n * This is also what calls our main() function when the page fully loads.\n */\nglobalThis.htmlscript = (function () {\n\t// Listen for the first user gesture...\n\n\t// If there's an error in loading, stop the loading animation\n\t// ...\n\n\tlet loadingErrorOcurred = false;\n\tlet lostNetwork = false;\n\n\t/** Called on failure to load a page asset. */\n\tfunction callback_LoadingError(): void {\n\t\t// const type = event.type; // Event type: \"error\"/\"abort\"\n\t\t// const target = event.target; // Element that triggered the event\n\t\t// const elementType = target?.tagName.toLowerCase();\n\t\t// const sourceURL = target?.src || target?.href; // URL of the resource that failed to load\n\t\t// console.error(`Event ${type} ocurred loading ${elementType} at ${sourceURL}.`);\n\n\t\tif (loadingErrorOcurred) return; // We only need to show the error text once\n\t\tloadingErrorOcurred = true;\n\n\t\t// Hide the \"LOADING\" text\n\t\tconst element_loadingText = document.getElementById('loading-text')!;\n\t\telement_loadingText.classList.add('hidden'); // This applies a 'display: none' rule\n\n\t\t// Show the ERROR text\n\t\tconst element_loadingError = document.getElementById('loading-error');\n\t\tconst element_loadingErrorText = document.getElementById('loading-error-text');\n\t\tconst element_loadingGlow = document.getElementById('loading-glow');\n\t\tif (!element_loadingError || !element_loadingErrorText || !element_loadingGlow) {\n\t\t\tconsole.error('Loading error elements not found in document.');\n\t\t\treturn;\n\t\t}\n\n\t\telement_loadingError.classList.remove('hidden');\n\t\telement_loadingErrorText.textContent = lostNetwork\n\t\t\t? translations.lost_network\n\t\t\t: translations.failed_to_load;\n\n\t\t// Remove the glowing in the background animation\n\t\telement_loadingGlow.classList.remove('loadingGlowAnimation');\n\t\telement_loadingGlow.classList.add('loading-glow-error');\n\t}\n\n\t/** Removes this specific html element's listener for a loading error. It must be the \"this\" object. */\n\tfunction removeOnerror(this: HTMLElement): void {\n\t\tthis.removeAttribute('onerror');\n\t\tthis.removeAttribute('onload');\n\t}\n\n\t// Add event listeners for when connection is dropped when loading\n\n\t(function initLoadingScreenListeners(): void {\n\t\twindow.addEventListener('offline', callback_Offline);\n\t\twindow.addEventListener('online', callback_Online);\n\t})();\n\tfunction closeLoadingScreenListeners(): void {\n\t\twindow.removeEventListener('offline', callback_Offline);\n\t\twindow.removeEventListener('online', callback_Online);\n\t}\n\n\tfunction callback_Offline(): void {\n\t\tconsole.log('Network connection lost');\n\t\tlostNetwork = true;\n\t\tcallback_LoadingError();\n\t}\n\tfunction callback_Online(): void {\n\t\tconsole.log('Network connection regained');\n\t\tlostNetwork = false;\n\t\tif (loadingErrorOcurred) window.location.reload(); // Refresh the page\n\t}\n\n\t// When the document is loaded, start the game!\n\n\twindow.addEventListener('load', function () {\n\t\tif (loadingErrorOcurred) return; // Page never finished loading, don't start the game.\n\t\tcloseLoadingScreenListeners(); // Remove document event listeners for the loading screen\n\t\tmain.start(); // Start the game!\n\t});\n\n\treturn Object.freeze({\n\t\tcallback_LoadingError,\n\t\tremoveOnerror,\n\t});\n})();\n"
  },
  {
    "path": "src/client/scripts/esm/audio/AudioEffects.ts",
    "content": "// src/client/scripts/esm/audio/AudioEffects.ts\n\n/**\n * This module is responsible for creating and managing audio effects using the Web Audio API.\n */\n\n// Types ---------------------------------------------------------------------------------------------\n\n/** A wrapper containing the input and output nodes of an effect graph. */\nexport interface NodeChain {\n\tinput: AudioNode;\n\toutput: AudioNode;\n}\n\n/** The base configuration for any effect. */\ninterface EffectConfigBase {\n\t/**\n\t * The volume of the \"wet\" (processed) signal. Default: 1.\n\t */\n\twetLevel?: number;\n\t/**\n\t * The volume of the \"dry\" (original) signal. Default: 0.\n\t * Increase to allow some of the original signal to pass through unaffected.\n\t */\n\tdryLevel?: number;\n}\n\n/** The configuration for a single effect in the effects chain. */\nexport type EffectConfig = EffectConfigBase & { type: 'reverb'; durationSecs: number };\n// Future effects will be added here, e.g.:\n// | { type: 'filter', filterType: BiquadFilterType, frequency: number }\n\n// Effect Creation ---------------------------------------------------------------------------------\n\n/**\n * Creates a complete, wrapped effect node graph based on the provided configuration.\n * @param audioContext - The global audio context.\n * @param config - The configuration object for the effect.\n * @returns An EffectWrapper containing the input and output nodes of the effect graph.\n */\nexport function createEffectNode(audioContext: AudioContext, config: EffectConfig): NodeChain {\n\t// 1. Create the core effect node based on its type.\n\tlet coreEffectNode: AudioNode;\n\n\tswitch (config.type) {\n\t\tcase 'reverb': {\n\t\t\tcoreEffectNode = generateConvolverNode(audioContext, config.durationSecs);\n\t\t\tbreak;\n\t\t}\n\t\t// When you add a filter:\n\t\t// case 'filter': {\n\t\t// \tcoreEffectNode = audioContext.createBiquadFilter();\n\t\t// \tcoreEffectNode.type = config.filterType;\n\t\t// \tcoreEffectNode.frequency.value = config.frequency;\n\t\t// \tbreak;\n\t\t// }\n\t\tdefault:\n\t\t\tthrow new Error(`Unknown effect type specified in config.`);\n\t}\n\n\t// 2. Create the input and output nodes for parallel dry and wet signal paths.\n\tconst input = new GainNode(audioContext);\n\tconst output = new GainNode(audioContext);\n\n\t// Determine the dry level. Default to 0 (0% passthrough) if not specified.\n\tconst dryLevel = config.dryLevel === undefined ? 0 : Math.max(0, config.dryLevel);\n\tif (dryLevel > 0) {\n\t\tconst dryGain = new GainNode(audioContext, { gain: dryLevel });\n\t\tinput.connect(dryGain).connect(output);\n\t}\n\n\t// Determine the wet level. Default to 1 (100% effect) if not specified.\n\tconst wetLevel = config.wetLevel === undefined ? 1 : Math.max(0, config.wetLevel);\n\tif (wetLevel > 0) {\n\t\tconst wetGain = new GainNode(audioContext, { gain: wetLevel });\n\t\tinput.connect(coreEffectNode).connect(wetGain).connect(output);\n\t}\n\n\t// 3. Return the wrapped effect node.\n\treturn { input, output };\n}\n\n// Internal Helpers --------------------------------------------------------------------------------\n\n/** Generates a reverb effect node. */\nfunction generateConvolverNode(audioContext: AudioContext, durationSecs: number): ConvolverNode {\n\tconst impulse = impulseResponse(audioContext, durationSecs);\n\treturn new ConvolverNode(audioContext, { buffer: impulse });\n}\n\n/** The mathematical function used by the convolver (reverb) node used to calculate the reverb effect! */\nfunction impulseResponse(audioContext: AudioContext, duration: number): AudioBuffer {\n\t// Duration in seconds, decay\n\tconst decay = 2;\n\tconst sampleRate = audioContext.sampleRate;\n\tconst length = sampleRate * duration;\n\tconst impulse = audioContext.createBuffer(1, length, sampleRate);\n\tconst IR = impulse.getChannelData(0);\n\tfor (let i = 0; i < length; i++)\n\t\tIR[i] = (2 * Math.random() - 1) * Math.pow(1 - i / length, decay);\n\treturn impulse;\n}\n"
  },
  {
    "path": "src/client/scripts/esm/audio/AudioManager.ts",
    "content": "// src/client/scripts/esm/audio/AudioManager.ts\n\n/**\n * This module is responsible for creating and playing sounds using the Web Audio API.\n */\n\nimport AudioUtils from './AudioUtils';\nimport preferences from '../components/header/preferences';\nimport { DownsamplerNode } from './processors/downsampler/DownsamplerNode';\nimport { createEffectNode, EffectConfig, NodeChain } from './AudioEffects';\n\n// Types ---------------------------------------------------------------------------------------------\n\ntype AudioBufferWithGainNode = AudioBufferSourceNode & { gainNode: GainNode };\n\ninterface SoundObject {\n\t/** The source of the audio, with its attached `gainNode`. */\n\tsource: AudioBufferWithGainNode;\n\t/** Whether to loop the sound indefinitely. */\n\treadonly looping: boolean;\n\t/**\n\t * Stops the sound from playing.\n\t * If this creates static pops, use fadeOut() instead.\n\t */\n\tstop: () => void;\n\t/**\n\t * Fades out the sound.\n\t * [Looping sounds] Fades to silent and continues playing.\n\t * [Non-looping sounds] Fades to silent and then stops the sound entirely.\n\t * @param durationMillis - The duration of the fade out in milliseconds.\n\t */\n\tfadeOut: (_durationMillis: number) => void;\n\t/**\n\t * Fades in the sound from its current volume to a target volume.\n\t * If you wish to fade-in a non-looping sound, initate the sound object with 0 volume initially.\n\t * @param targetVolume - The final volume level (0-1).\n\t * @param durationMillis - The duration of the fade-in effect in milliseconds.\n\t */\n\tfadeIn: (_targetVolume: number, _durationMillis: number) => void;\n}\n\n/** Config options for playing a sound. */\ninterface PlaySoundOptions {\n\t/** The time of the audio buffer to start playing, if not from the beginning. */\n\tstartTime?: number;\n\t/** The duration to play the audio buffer for, if not for the whole duration. */\n\tduration?: number;\n\t/** Volume of the sound. Default: 1. Typical range: 0-1. Capped at {@link VOLUME_DANGER_THRESHOLD} for safety. */\n\tvolume?: number;\n\t/** Delay before the sound starts playing in seconds. Default: 0 */\n\tdelay?: number;\n\t/** An array of effects to apply to the sound. */\n\teffects?: EffectConfig[];\n\t/**\n\t * Playback rate of the sound. Default: 1. 1 = normal speed & pitch\n\t * Lower = slower & lower pitch. Higher = faster & higher pitch.\n\t */\n\tplaybackRate?: number;\n\t/** Whether the sound should loop indefinitely. Default: false */\n\tloop?: boolean;\n\t/** If true, the sound will bypass the global downsampler effect. Default: false */\n\tbypassDownsampler?: boolean;\n}\n\n// Constants ----------------------------------------------------------------------------------------------\n\n/** Any volume above this is probably a mistake, so we reset it to 1 and log an error in the console. */\nconst VOLUME_DANGER_THRESHOLD = 4;\n\n// State ----------------------------------------------------------------------------------------------\n\n/** This context plays all our sounds. */\nconst audioContext: AudioContext = new AudioContext();\n\n/** An input bus for all sound chains before they reach the master gain. Allows for global effects. */\nconst effectsBus = audioContext.createGain();\n/** The global downsampler effect node. Null until the worklet is loaded. */\nlet globalDownsampler: DownsamplerNode | null = null;\n/** The gain node for the \"dry\" (unprocessed) signal path around the downsampler. */\nconst downsamplerDryGain = audioContext.createGain();\ndownsamplerDryGain.gain.value = 1; // Default to 100% dry signal\n/** The gain node for the \"wet\" (processed) signal path through the downsampler. */\nconst downsamplerWetGain = audioContext.createGain();\ndownsamplerWetGain.gain.value = 0; // Default to 0% wet signal\n\n/** A master gain node to control the overall volume of all sounds. */\nconst masterGain = audioContext.createGain();\nmasterGain.gain.value = preferences.getMasterVolume(); // Initialize to saved preference\n// Listen for changes to the master volume preference\ndocument.addEventListener('master-volume-change', (event) => {\n\tconst newVolume = event.detail;\n\tmasterGain.gain.setValueAtTime(newVolume, audioContext.currentTime);\n});\n\n/** A final safety compressor to prevent clipping from very high gain. */\nconst limiter = new DynamicsCompressorNode(audioContext, {\n\tthreshold: -0.1, // Start compressing just before the signal hits 0dB\n\tknee: 0, // Hard knee for a strict ceiling\n\tratio: 20, // A 20:1 ratio is considered \"limiting\"\n\tattack: 0.001, // Very fast attack to catch transients\n\trelease: 0.1, // Quick release\n});\n\n// Connect the audio graph: Effects Bus -> Master Gain -> Limiter -> Destination (speakers)\n// Initially, connect the effectsBus directly to masterGain as a bypass until the downsampler loads.\neffectsBus.connect(masterGain);\nmasterGain.connect(limiter);\nlimiter.connect(audioContext.destination);\n\n// Asynchronously load and initialize the Downsampler worklet.\n(async () => {\n\ttry {\n\t\tconst downsamplerNode = await DownsamplerNode.create(audioContext);\n\t\tglobalDownsampler = downsamplerNode;\n\n\t\t// Set the static parameters for the downsampler effect\n\t\tglobalDownsampler.downsampling!.value = 20; // Default: 20\n\n\t\t// Re-wire the audio graph to include the dry/wet downsampler paths\n\t\teffectsBus.disconnect(masterGain); // Disconnect the bypass\n\n\t\t// Dry path\n\t\teffectsBus.connect(downsamplerDryGain);\n\t\tdownsamplerDryGain.connect(masterGain);\n\n\t\t// Wet path\n\t\teffectsBus.connect(globalDownsampler);\n\t\tglobalDownsampler.connect(downsamplerWetGain);\n\t\tdownsamplerWetGain.connect(masterGain);\n\n\t\t// console.log('Global downsampler effect initialized successfully.');\n\t} catch (error) {\n\t\tconsole.error(\n\t\t\t'Failed to initialize global downsampler effect. Audio will remain clean.',\n\t\t\terror,\n\t\t);\n\t\t// If it fails, the initial bypass connection from effectsBus to masterGain remains active.\n\t}\n})();\n\n// Getters ----------------------------------------------------------------------------------------------\n\n/** Returns the global audio context. */\nfunction getContext(): AudioContext {\n\treturn audioContext;\n}\n\n/**\n * Returns the master gain node. All sounds MUST route through the\n * master gain node in order for the master volume control to work!\n * This should be used for sounds that need to BYPASS the global effects bus (such as ambiences).\n */\nfunction getDestination(): AudioNode {\n\treturn masterGain;\n}\n\n// Public API -------------------------------------------------------------------------------------------\n\n/** Fades in the global downsampler effect over a given duration. */\nfunction fadeInDownsampler(durationMillis: number): void {\n\tif (!globalDownsampler) {\n\t\tconsole.warn('Downsampler not loaded yet, cannot fade in.');\n\t\treturn;\n\t}\n\tAudioUtils.applyPerceptualFade(audioContext, downsamplerDryGain.gain, 0, durationMillis);\n\tAudioUtils.applyPerceptualFade(audioContext, downsamplerWetGain.gain, 1, durationMillis);\n}\n\n/** Fades out the global downsampler effect over a given duration. */\nfunction fadeOutDownsampler(durationMillis: number): void {\n\tif (!globalDownsampler) {\n\t\tconsole.warn('Downsampler not loaded yet, cannot fade out.');\n\t\treturn;\n\t}\n\tAudioUtils.applyPerceptualFade(audioContext, downsamplerDryGain.gain, 1, durationMillis);\n\tAudioUtils.applyPerceptualFade(audioContext, downsamplerWetGain.gain, 0, durationMillis);\n}\n\n// Sound Playing ------------------------------------------------------------------------------------------\n\n/** Plays the specified audio buffer with the specified options. */\nfunction playAudio(\n\tbuffer: AudioBuffer | undefined,\n\tplayOptions: PlaySoundOptions,\n): SoundObject | undefined {\n\t// Attempt to resume if it was suspended (e.g., due to browser autoplay policy)\n\tif (audioContext.state === 'suspended') audioContext.resume();\n\tif (!audioContext) {\n\t\tconsole.warn(`Can't play sound when audioContext isn't initialized yet. (Still loading)`);\n\t\treturn;\n\t}\n\tif (!buffer) {\n\t\tconsole.warn(`Can't play sound when buffer isn't loaded yet. (Still loading)`);\n\t\treturn;\n\t}\n\n\tconst {\n\t\tstartTime,\n\t\tduration,\n\t\tvolume = 1,\n\t\tdelay = 0,\n\t\tplaybackRate = 1,\n\t\tloop = false,\n\t\teffects = [],\n\t\tbypassDownsampler = false,\n\t} = playOptions;\n\n\t// Calculate the desired start time by adding the delay\n\tconst startAt = audioContext.currentTime + delay;\n\n\t// We need an audio \"source\" to play our main sound effect. Several of these can exist at once for one audio context.\n\n\t// 1. Create the fundamental source and its master gain node.\n\tconst mainSource = createBufferSource(buffer, volume, playbackRate);\n\tmainSource.loop = loop; // Set the loop property on the audio source itself.\n\n\t// 2. Build the effects chain by asking the factory to create the nodes.\n\tconst effectNodes = effects.map((effectConfig) => createEffectNode(audioContext, effectConfig));\n\n\t// 3. Connect the nodes in order: Source -> Gain -> Effect1 -> Effect2 -> Effects Bus -> Master Gain -> Limiter -> Destination\n\tconnectNodeChain(mainSource.gainNode, effectNodes, bypassDownsampler);\n\n\t// The SoundObject is now much simpler!\n\tconst soundObject: SoundObject = {\n\t\tsource: mainSource,\n\t\tlooping: loop,\n\n\t\tstop: (): void => {\n\t\t\tsoundObject.source.stop();\n\t\t},\n\t\tfadeOut: (durationMillis): void => {\n\t\t\tconst fadeOutDurationSecs = durationMillis / 1000;\n\t\t\tconst fadeOutEndTime = audioContext.currentTime + fadeOutDurationSecs;\n\t\t\t// Fade the source to silent\n\t\t\tfadeOut(soundObject.source, fadeOutEndTime);\n\t\t\t// For non-looping sounds, schedule them to stop completely after the fade.\n\t\t\tif (!soundObject.looping) setTimeout(() => soundObject.stop(), durationMillis);\n\t\t},\n\t\tfadeIn: (targetVolume, durationMillis): void => {\n\t\t\tconst fadeInDurationSecs = durationMillis / 1000;\n\t\t\tconst fadeInEndTime = audioContext.currentTime + fadeInDurationSecs;\n\t\t\t// Fade the main source to the target volume\n\t\t\tfadeIn(soundObject.source, targetVolume, fadeInEndTime);\n\t\t},\n\t};\n\n\t// Start the playback\n\tsoundObject.source.start(startAt, startTime, duration);\n\n\tscheduleDisconnection(mainSource, buffer, loop, delay, effects, duration, startTime);\n\n\treturn soundObject;\n}\n\n/**\n * Schedules disconnection of the audio nodes after the sound and its effects have finished playing.\n *\n * Patches a bug on chrome, where when audio sources are played\n * that have a reverb (or any other tail) effect, the audio nodes\n * are garbage collected too early, cutting off the tail effect.\n */\nfunction scheduleDisconnection(\n\tsource: AudioBufferSourceNode,\n\tbuffer: AudioBuffer,\n\tloop: boolean,\n\tdelay: number,\n\teffects: EffectConfig[],\n\tduration?: number,\n\tstartTime?: number,\n): void {\n\tif (loop) return;\n\n\tconst sourceDurationSecs = duration ?? buffer.duration - (startTime ?? 0);\n\n\t// Find the longest tail duration among all applied effects.\n\tconst maxTailSecs = effects.reduce((max, effect) => {\n\t\tif (effect.type === 'reverb') return Math.max(max, effect.durationSecs);\n\t\t// Future effects with tails (e.g., delay) could be accounted for here.\n\t\telse\n\t\t\tthrow Error(\n\t\t\t\t`Sound effect type \"${effect.type}\" not accounted for in tail duration calculation.`,\n\t\t\t);\n\t}, 0);\n\n\tconst totalLifetimeMillis = (sourceDurationSecs + maxTailSecs + delay) * 1000;\n\n\t// Keep a reference to the source for the entire lifetime of the sound + effects.\n\tsetTimeout(() => source.disconnect(), totalLifetimeMillis);\n}\n\n// Audio Nodes ------------------------------------------------------------------------------------------\n\n/**\n * Creates a new buffer source and its master gain node.\n * It does NOT connect it to the destination, allowing an effects chain to be inserted later.\n * @param buffer - The audio buffer to play.\n * @param volume - The initial volume of the sound (0-1).\n * @param playbackRate - The playback rate of the sound. 1 = normal speed & pitch.\n * @returns The created AudioBufferSourceNode with its attached GainNode as `gainNode` property.\n */\nfunction createBufferSource(\n\tbuffer: AudioBuffer,\n\tvolume: number,\n\tplaybackRate: number = 1,\n): AudioBufferWithGainNode {\n\tconst source = audioContext.createBufferSource();\n\tsource.buffer = buffer;\n\tsource.playbackRate.value = playbackRate;\n\n\tconst gainNode = generateGainNode(audioContext, volume);\n\tsource.connect(gainNode); // Connect source to its own master gain node\n\n\t// @ts-ignore\n\tsource.gainNode = gainNode; // Attach for fading controls\n\n\treturn source as AudioBufferWithGainNode;\n}\n\n/** Generates a gain node for affecting the volume of sounds. */\nfunction generateGainNode(audioContext: AudioContext, volume: number): GainNode {\n\tif (volume > VOLUME_DANGER_THRESHOLD) {\n\t\tconsole.error(`Gain was DANGEROUSLY set to ${volume}!!!! Resetting to 1.`);\n\t\tvolume = 1;\n\t}\n\tconst gainNode = audioContext.createGain();\n\tgainNode.gain.value = volume; // Set the volume level (0 to 1)\n\treturn gainNode;\n}\n\n/**\n * Connects a starting node through a list of effect wrappers, ending at\n * either the global effects bus or directly at the master gain.\n * @param startNode - The first node in the chain (usually a source's gain node).\n * @param wrapperList - The list of effects to connect in series.\n * @param bypassDownsampler - If true, the chain will connect to masterGain, otherwise effectsBus.\n */\nfunction connectNodeChain(\n\tstartNode: AudioNode,\n\twrapperList: NodeChain[],\n\tbypassDownsampler: boolean,\n): void {\n\tlet currentNode: AudioNode = startNode;\n\n\tfor (const effectWrapper of wrapperList) {\n\t\tcurrentNode.connect(effectWrapper.input);\n\t\tcurrentNode = effectWrapper.output; // The output of this effect is the input to the next one.\n\t}\n\n\t// Connect the very last node in the chain to either the effects bus or directly to master gain.\n\tconst destinationNode = bypassDownsampler ? masterGain : effectsBus;\n\tcurrentNode.connect(destinationNode);\n}\n\n/**\n * Initiates a fade-in for an audio source's gain node. This is interruptible.\n * @param source - The audio source node to fade in, WITH ITS `gainNode` property attached.\n * @param targetVolume - The final volume level.\n * @param endTime - The audioContext time at which the fade should complete.\n */\nfunction fadeIn(source: AudioBufferWithGainNode, targetVolume: number, endTime: number): void {\n\tconst now = audioContext.currentTime;\n\t// First, cancel any pending volume changes to make this interruptible.\n\tsource.gainNode.gain.cancelScheduledValues(now);\n\t// Set the starting point for the ramp at the current volume.\n\tsource.gainNode.gain.setValueAtTime(source.gainNode.gain.value, now);\n\t// Schedule the linear ramp to the target volume.\n\tsource.gainNode.gain.linearRampToValueAtTime(targetVolume, endTime);\n}\n\n/**\n * Initiates a fade-out for an audio source's gain node. This is interruptible.\n * @param source - The audio source node to fade out, WITH ITS `gainNode` property attached.\n * @param endTime - The audioContext time at which the fade should complete.\n */\nfunction fadeOut(source: AudioBufferWithGainNode, endTime: number): void {\n\tconst now = audioContext.currentTime;\n\t// First, cancel any pending volume changes to make this interruptible.\n\tsource.gainNode.gain.cancelScheduledValues(now);\n\t// Set the starting point for the ramp at the current volume.\n\tsource.gainNode.gain.setValueAtTime(source.gainNode.gain.value, now);\n\t// Schedule the linear ramp down to zero.\n\tsource.gainNode.gain.linearRampToValueAtTime(0, endTime);\n}\n\n// Utility ----------------------------------------------------------------------------------\n\n/** Decodes audio data from an ArrayBuffer from a fetch request into an AudioBuffer. */\nfunction decodeAudioData(buffer: ArrayBuffer): Promise<AudioBuffer> {\n\treturn new Promise((resolve, reject) => {\n\t\tif (!audioContext) {\n\t\t\treject('Audio context not initialized.');\n\t\t\treturn;\n\t\t}\n\t\taudioContext.decodeAudioData(\n\t\t\tbuffer,\n\t\t\t(decodedData) => resolve(decodedData),\n\t\t\t(error) => reject(error),\n\t\t);\n\t});\n}\n\n// Exports ----------------------------------------------------------------------\n\nexport type { SoundObject };\n\nexport default {\n\t// Getters\n\tgetContext,\n\tgetDestination,\n\t// Public API\n\tfadeInDownsampler,\n\tfadeOutDownsampler,\n\t// Sound Playing\n\tplayAudio,\n\t// Utility\n\tdecodeAudioData,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/audio/AudioUtils.ts",
    "content": "// src/client/scripts/esm/audio/AudioUtils.ts\n\n/**\n * This module provides generic, reusable utility functions for working with the Web Audio API.\n */\n\n// Constants --------------------------------------------------------------------------------\n\n/** The number of points to use when generating fade curves. Higher = smoother, but more CPU. */\nconst FADE_CURVE_RESOLUTION = 100;\n\n/**\n * Higher = Exponential ramp gets more weight at beginning, Linear ramp gets more weight at end.\n * Range:\n * \t   0.0 (perfect 50% blend of linear and exponential throughout time t)\n *     to 0.5 (100% exponential ramp at start, 100% linear ramp at end)\n */\nconst FADE_RAMP_CURVATURE = 0.4;\n\n// Utility -----------------------------------------------------------------------------------\n\n/**\n * Applies a perceptually-blended fade with a dynamic blending curve to any AudioParam.\n * This can be tuned between linear and exponential ramps, providing a more natural-sounding fade.\n * @param audioContext The active AudioContext.\n * @param gainParam The gain AudioParam to be modified.\n * @param targetVolume The final volume (amplitude) for the fade.\n * @param durationMillis The duration of the fade in milliseconds.\n */\nfunction applyPerceptualFade(\n\taudioContext: AudioContext,\n\tgainParam: AudioParam,\n\ttargetVolume: number,\n\tdurationMillis: number,\n): void {\n\tconst now: number = audioContext.currentTime;\n\tconst durationSeconds = durationMillis / 1000;\n\tconst startVolume: number = gainParam.value;\n\n\t// In Firefox, this DOESN'T CANCEL value curves currently active! Use linear ramps instead!\n\tgainParam.cancelScheduledValues(now);\n\n\t// Anchor the start point to prevent popping\n\tgainParam.setValueAtTime(startVolume, now);\n\n\tconst MIN_GAIN = 0.00001;\n\tconst effectiveStart = Math.max(startVolume, MIN_GAIN);\n\tconst effectiveTarget = Math.max(targetVolume, MIN_GAIN);\n\n\tconst easeFunction = (t: number): number =>\n\t\tFADE_RAMP_CURVATURE * Math.sin(Math.PI * t + 0.5 * Math.PI) + 0.5;\n\n\t// Generate segments and schedule them as linear ramps\n\t// We start from i=1 because i=0 is our starting anchor set above at 'now'\n\tfor (let i = 1; i <= FADE_CURVE_RESOLUTION; i++) {\n\t\tconst progress = i / FADE_CURVE_RESOLUTION; // 0.0 to 1.0\n\n\t\t// Calculate the specific time for this segment\n\t\tconst timeOffset = progress * durationSeconds;\n\t\tconst scheduledTime = now + timeOffset;\n\n\t\t// Calculate the volume value for this segment\n\t\tconst isFadeOut = targetVolume < startVolume;\n\t\tconst blendProgress = isFadeOut ? 1 - progress : progress;\n\t\tconst currentRatio = easeFunction(blendProgress);\n\n\t\tconst linearPoint = startVolume + (targetVolume - startVolume) * progress;\n\t\tconst exponentialPoint =\n\t\t\teffectiveStart * Math.pow(effectiveTarget / effectiveStart, progress);\n\n\t\tconst value = linearPoint * (1 - currentRatio) + exponentialPoint * currentRatio;\n\n\t\t// 6. Schedule the ramp segment\n\t\tgainParam.linearRampToValueAtTime(value, scheduledTime);\n\t}\n}\n\n// Exports ----------------------------------------------------------------------------------\n\nexport default {\n\tapplyPerceptualFade,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/audio/LFOFactory.ts",
    "content": "// src/client/scripts/esm/audio/LFOFactory.ts\n\n/**\n * A factory for creating Low-Frequency Oscillator (LFO) units for modulating audio parameters.\n */\n\nimport PerlinNoise from '../util/PerlinNoise';\n\n/** Configuration for a low-frequency oscillator (LFO) modulating a parameter. */\nexport interface LFOConfig {\n\twave: 'sine' | 'square' | 'sawtooth' | 'triangle' | 'perlin';\n\trate: number;\n\tdepth: number;\n}\n\n/** A container for an LFO's audio nodes. */\ninterface LFOUnit {\n\tsource: AudioNode;\n\tgain: GainNode;\n}\n\n/** A shared AudioBuffer for Perlin noise LFOs to use. */\nlet perlinNoiseBuffer: AudioBuffer | null = null;\n\n/**\n * A factory for creating LFO (Low-Frequency Oscillator) units.\n * @param context The global AudioContext.\n * @param config The configuration for the LFO.\n * @returns An LFOUnit containing the necessary source and gain nodes.\n */\nexport function createLFO(context: AudioContext, config: LFOConfig): LFOUnit {\n\tconst lfoGain = context.createGain();\n\tlfoGain.gain.value = config.depth;\n\n\tlet lfoSource: AudioNode;\n\tif (config.wave === 'perlin') {\n\t\tlfoSource = createPerlinLFO(context, config.rate);\n\t} else {\n\t\tconst osc = context.createOscillator();\n\t\tosc.type = config.wave;\n\t\tosc.frequency.value = config.rate;\n\t\tlfoSource = osc;\n\t}\n\n\treturn { source: lfoSource, gain: lfoGain };\n}\n\n/** Creates a looping AudioBufferSourceNode that outputs Perlin noise. */\nfunction createPerlinLFO(context: AudioContext, rate: number): AudioBufferSourceNode {\n\tif (!perlinNoiseBuffer) {\n\t\t// Create the perlin noise buffer only once\n\t\tconst duration = 30; // 30 seconds long buffer\n\t\tconst sampleCount = context.sampleRate * duration;\n\n\t\t// The \"zoom\" level for the noise. Higher values = smoother/slower noise.\n\t\tconst noiseZoom = 50000;\n\t\tconst noisePeriod = Math.ceil(sampleCount / noiseZoom);\n\t\t// console.log(\"noisePeriod: \", noisePeriod); // We get about 1 second of looping per 1 noise period at 1.0 rate.\n\t\tconst noiseGenerator = PerlinNoise.create1DNoiseGenerator(noisePeriod);\n\n\t\tperlinNoiseBuffer = context.createBuffer(1, sampleCount, context.sampleRate);\n\t\tconst data = perlinNoiseBuffer.getChannelData(0);\n\t\tfor (let i = 0; i < sampleCount; i++) {\n\t\t\tdata[i] = noiseGenerator(i / noiseZoom);\n\t\t}\n\t}\n\tconst lfoSource = context.createBufferSource();\n\tlfoSource.buffer = perlinNoiseBuffer;\n\tlfoSource.loop = true;\n\tlfoSource.playbackRate.value = rate;\n\treturn lfoSource;\n}\n"
  },
  {
    "path": "src/client/scripts/esm/audio/SoundLayer.ts",
    "content": "// src/client/scripts/esm/audio/SoundLayer.ts\n\n/**\n * This module implements the audio graph for individual sound layers within a soundscape.\n *\n * A sound layer could either be:\n * - A noise source (e.g. white noise) with filters applied.\n * - An oscillator source (e.g. sine wave) with filters applied.\n *\n * Each layer can have its own volume control, and each parameter can be modulated by an LFO.\n */\n\nimport { createLFO, LFOConfig } from './LFOFactory';\n\n// Types -----------------------------------------------------------------------------\n\n/** A single sound layer within a soundscape. */\nexport interface LayerConfig {\n\tvolume: ModulatedParamConfig;\n\tsource: SourceConfig;\n\tfilters: FilterConfig[];\n}\n\n/** The configuration for the audio source of a layer. */\ntype SourceConfig = NoiseSourceConfig | OscillatorSourceConfig;\n\n/** Configuration for a noise source. */\ninterface NoiseSourceConfig {\n\ttype: 'noise';\n}\n\n/** Configuration for an oscillator source with optional LFO modulation. */\ninterface OscillatorSourceConfig {\n\ttype: 'oscillator';\n\twave: 'sine' | 'square' | 'sawtooth' | 'triangle';\n\tfreq: ModulatedParamConfig;\n\tdetune: ModulatedParamConfig;\n}\n\n/** Configuration for a BiquadFilterNode with optional LFO modulation. */\ninterface FilterConfig {\n\t/** The type of BiquadFilter to create. */\n\ttype: BiquadFilterType;\n\t/** Where on the frequency spectrum the filter should work. */\n\tfrequency: ModulatedParamConfig;\n\t/**\n\t * The Q factor (resonance) of the filter. Optional.\n\t * Range: 0.0001 to 1000. Default: 1.\n\t */\n\tQ: ModulatedParamConfig;\n\t/**\n\t * The gain of the filter, in dB. Optional.\n\t * Only used for certain filter types: peaking, lowshelf, highshelf.\n\t */\n\tgain: ModulatedParamConfig;\n}\n\n/** Configuration for a parameter that can be modulated by an LFO. */\ninterface ModulatedParamConfig {\n\tbase: number;\n\tlfo?: LFOConfig;\n}\n\n// SoundLayer Class ----------------------------------------------------------------\n\n/**\n * Represents the complete audio graph for a single layer in a soundscape.\n */\nexport class SoundLayer {\n\tprivate readonly outputGain: GainNode;\n\t/** All unique oscillators and LFOs that need to be started and stopped for this layer. */\n\tprivate readonly allNodesToStart: (AudioBufferSourceNode | OscillatorNode)[] = [];\n\n\tconstructor(\n\t\tcontext: AudioContext,\n\t\tconfig: LayerConfig,\n\t\tsharedNoiseSource: AudioBufferSourceNode,\n\t) {\n\t\tthis.outputGain = context.createGain();\n\t\tthis.outputGain.gain.value = config.volume.base;\n\n\t\tif (config.volume.lfo) {\n\t\t\t// The volume for this layer is modulated by an LFO\n\t\t\tconst lfo = createLFO(context, config.volume.lfo);\n\t\t\tlfo.source.connect(lfo.gain).connect(this.outputGain.gain);\n\t\t\tthis.allNodesToStart.push(lfo.source as OscillatorNode | AudioBufferSourceNode);\n\t\t}\n\n\t\tlet currentNode: AudioNode;\n\n\t\tif (config.source.type === 'noise') {\n\t\t\tcurrentNode = sharedNoiseSource;\n\t\t\t// The shared noise source is managed by the player, so we don't add it to our start/stop list.\n\t\t} else {\n\t\t\t// type === 'oscillator'\n\t\t\tconst oscConfig: OscillatorSourceConfig = config.source;\n\t\t\tconst osc = context.createOscillator();\n\t\t\tosc.type = oscConfig.wave;\n\t\t\tosc.frequency.value = oscConfig.freq.base;\n\t\t\tosc.detune.value = oscConfig.detune.base;\n\n\t\t\tif (oscConfig.freq.lfo) {\n\t\t\t\tconst lfo = createLFO(context, oscConfig.freq.lfo);\n\t\t\t\tlfo.source.connect(lfo.gain).connect(osc.frequency);\n\t\t\t\tthis.allNodesToStart.push(lfo.source as OscillatorNode | AudioBufferSourceNode);\n\t\t\t}\n\t\t\tif (oscConfig.detune.lfo) {\n\t\t\t\tconst lfo = createLFO(context, oscConfig.detune.lfo);\n\t\t\t\tlfo.source.connect(lfo.gain).connect(osc.detune);\n\t\t\t\tthis.allNodesToStart.push(lfo.source as OscillatorNode | AudioBufferSourceNode);\n\t\t\t}\n\n\t\t\tcurrentNode = osc;\n\t\t\tthis.allNodesToStart.push(osc);\n\t\t}\n\n\t\tconfig.filters.forEach((filterConfig) => {\n\t\t\tconst filterNode = context.createBiquadFilter();\n\t\t\tfilterNode.type = filterConfig.type;\n\t\t\tfilterNode.frequency.value = filterConfig.frequency.base;\n\t\t\tfilterNode.Q.value = filterConfig.Q.base;\n\t\t\tfilterNode.gain.value = filterConfig.gain.base;\n\n\t\t\tif (filterConfig.frequency.lfo) {\n\t\t\t\tconst lfo = createLFO(context, filterConfig.frequency.lfo);\n\t\t\t\tlfo.source.connect(lfo.gain).connect(filterNode.frequency);\n\t\t\t\tthis.allNodesToStart.push(lfo.source as OscillatorNode | AudioBufferSourceNode);\n\t\t\t}\n\t\t\tif (filterConfig.Q.lfo) {\n\t\t\t\tconst lfo = createLFO(context, filterConfig.Q.lfo);\n\t\t\t\tlfo.source.connect(lfo.gain).connect(filterNode.Q);\n\t\t\t\tthis.allNodesToStart.push(lfo.source as OscillatorNode | AudioBufferSourceNode);\n\t\t\t}\n\t\t\tif (filterConfig.gain.lfo) {\n\t\t\t\tconst lfo = createLFO(context, filterConfig.gain.lfo);\n\t\t\t\tlfo.source.connect(lfo.gain).connect(filterNode.gain);\n\t\t\t\tthis.allNodesToStart.push(lfo.source as OscillatorNode | AudioBufferSourceNode);\n\t\t\t}\n\n\t\t\tcurrentNode.connect(filterNode);\n\t\t\tcurrentNode = filterNode;\n\t\t});\n\n\t\tcurrentNode.connect(this.outputGain);\n\t}\n\n\t/** Connects this layer's output to a destination node. */\n\tpublic connect(destination: AudioNode): void {\n\t\tthis.outputGain.connect(destination);\n\t}\n\n\t/** Starts all unique oscillators and LFOs for this layer. */\n\tpublic start(): void {\n\t\t// FUTURE: Potentially upgrade to start perlin noise buffers at random\n\t\t// offsets so they don't sound identical every refresh.\n\t\tthis.allNodesToStart.forEach((node) => node.start(0));\n\t}\n\n\t/** Stops all unique oscillators and LFOs for this layer. */\n\tpublic stop(): void {\n\t\tthis.allNodesToStart.forEach((node) => node.stop(0));\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/audio/SoundscapePlayer.ts",
    "content": "// src/client/scripts/esm/audio/SoundscapePlayer.ts\n\n/**\n * This module implements a soundscape player that can play complex, layered ambient sounds.\n *\n * For creating of soundscape configs, use the Interactive Soundscape Generator tool:\n * dev-utils/sounds/SoundscapeGenerator.html\n */\n\nimport AudioUtils from './AudioUtils';\nimport AudioManager from './AudioManager';\nimport { LayerConfig, SoundLayer } from './SoundLayer';\n\n// Types -----------------------------------------------------------------------------\n\n/** The complete configuration for a soundscape. */\nexport interface SoundscapeConfig {\n\tmasterVolume: number;\n\tlayers: LayerConfig[];\n}\n\n// Constants  --------------------------------------------------------------------------------\n\n/**\n * The length of the shared noise buffer for this soundscape's layers, in seconds.\n * Longer = less repetition, but more memory use and cpu initialization time.\n */\nconst NOISE_DURATION_SECS = 10;\n\n// SoundscapePlayer Class --------------------------------------------------------------------------------\n\n/** The control interface for a soundscape player. */\nexport class SoundscapePlayer {\n\tprivate readonly config: SoundscapeConfig;\n\n\tprivate audioContext: AudioContext;\n\t/** The master gain node controlling overall volume of the soundscape. */\n\tprivate masterGain: GainNode;\n\t/** All the individual sound layers in this soundscape. */\n\tprivate layers: SoundLayer[] = [];\n\n\t/** A shared noise source for all layers to use. Reduces CPU and memory usage. */\n\tprivate sharedNoiseSource: AudioBufferSourceNode | null = null;\n\n\t/**\n\t * Whether the player has been initialized and is ready to play.\n\t * We only initialize when playing is actually needed, as it's expensive.\n\t */\n\tprivate playerReady: boolean = false;\n\n\tconstructor(config: SoundscapeConfig) {\n\t\tthis.config = config;\n\t\tthis.audioContext = AudioManager.getContext();\n\t\tthis.masterGain = this.audioContext.createGain();\n\t}\n\n\t/**\n\t * Initializes the audio graph, creates all nodes, and starts sources.\n\t * This is called only once. This is the expensive part of the process.\n\t */\n\tprivate initializeAndPlay(): void {\n\t\tthis.masterGain.gain.value = 0.0; // Always start silent\n\t\tthis.masterGain.connect(AudioManager.getDestination()); // Connect to the global master gain\n\n\t\t// Create the shared raw noise buffer data source\n\t\tconst bufferSize = NOISE_DURATION_SECS * this.audioContext.sampleRate;\n\t\tconst sharedNoiseBuffer = this.audioContext.createBuffer(\n\t\t\t2,\n\t\t\tbufferSize,\n\t\t\tthis.audioContext.sampleRate,\n\t\t); // 2 channels for stereo sound (unique noise in each ear)\n\t\tfor (let c = 0; c < 2; c++) {\n\t\t\tconst channelData = sharedNoiseBuffer.getChannelData(c);\n\t\t\tfor (let i = 0; i < bufferSize; i++) {\n\t\t\t\tchannelData[i] = Math.random() * 2 - 1;\n\t\t\t}\n\t\t}\n\t\tthis.sharedNoiseSource = this.audioContext.createBufferSource();\n\t\tthis.sharedNoiseSource.buffer = sharedNoiseBuffer;\n\t\tthis.sharedNoiseSource.loop = true;\n\n\t\t// Build each layer\n\t\tthis.config.layers.forEach((layerConfig) => {\n\t\t\tconst layer = new SoundLayer(this.audioContext!, layerConfig, this.sharedNoiseSource!);\n\t\t\tlayer.connect(this.masterGain!);\n\t\t\tthis.layers.push(layer);\n\t\t});\n\n\t\t// Start all sources (at volume 0)\n\t\tthis.sharedNoiseSource.start(0);\n\t\tthis.layers.forEach((layer) => layer.start());\n\n\t\tthis.playerReady = true;\n\t}\n\n\t/**\n\t * Immediately stops all audio, disconnects nodes, and resets the player to a clean state.\n\t * The player can be started again with fadeIn().\n\t */\n\tpublic stop(): void {\n\t\tif (!this.playerReady) return; // Not even initialized, nothing to do.\n\n\t\tthis.sharedNoiseSource!.stop(0);\n\t\tthis.layers.forEach((layer) => layer.stop());\n\n\t\t// Disconnect everything to be garbage collected\n\t\tthis.masterGain.disconnect();\n\t\tthis.sharedNoiseSource?.disconnect();\n\n\t\t// Reset state\n\t\tthis.playerReady = false; // Allow re-initialization on next fadeIn\n\t\tthis.layers = [];\n\t}\n\n\t/** Fades in the soundscape to a specified target volume, initializing it if necessary. */\n\tpublic fadeIn(durationMillis: number): void {\n\t\t// Initialize now if not already done.\n\t\t// Saves compute until the soundscape is actually NEEDED,\n\t\t// as the initialization is expensive.\n\t\tif (!this.playerReady) this.initializeAndPlay();\n\n\t\tAudioUtils.applyPerceptualFade(\n\t\t\tthis.audioContext,\n\t\t\tthis.masterGain.gain,\n\t\t\tthis.config.masterVolume,\n\t\t\tdurationMillis,\n\t\t);\n\t}\n\n\t/** Fades out the ambience to silence. The player remains active at zero volume. */\n\tpublic fadeOut(durationMillis: number): void {\n\t\tif (!this.playerReady) return; // Hasn't initialized, nothing to fade out.\n\n\t\tAudioUtils.applyPerceptualFade(\n\t\t\tthis.audioContext,\n\t\t\tthis.masterGain.gain,\n\t\t\t0.0,\n\t\t\tdurationMillis,\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/audio/processors/downsampler/DownsamplerNode.ts",
    "content": "// src/client/scripts/esm/audio/processors/downsampler/DownsamplerNode.ts\n\nexport class DownsamplerNode extends AudioWorkletNode {\n\tconstructor(context: AudioContext) {\n\t\tsuper(context, 'downsampler-processor');\n\t}\n\n\t/**\n\t * Factory method to asynchronously create and initialize a DownsamplerNode.\n\t * @param context The AudioContext to create the node in.\n\t * @returns A promise that resolves with a fully initialized DownsamplerNode instance.\n\t */\n\tpublic static async create(context: AudioContext): Promise<DownsamplerNode> {\n\t\ttry {\n\t\t\t// Load the worklet processor from the specified URL\n\t\t\tawait context.audioWorklet.addModule(\n\t\t\t\t'scripts/esm/audio/processors/downsampler/DownsamplerProcessor.js',\n\t\t\t);\n\t\t\t// Once loaded, create an instance of the node\n\t\t\treturn new DownsamplerNode(context);\n\t\t} catch (e) {\n\t\t\tconsole.error('Failed to load downsampler audio worklet', e);\n\t\t\tthrow e;\n\t\t}\n\t}\n\n\t/**\n\t * The factor by which to reduce the sample rate.\n\t * A value of 1 means no downsampling.\n\t * Range: 1 to 40.\n\t */\n\tget downsampling(): AudioParam | undefined {\n\t\treturn this.parameters.get('downsampling');\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/audio/processors/downsampler/DownsamplerProcessor.ts",
    "content": "// src/client/scripts/esm/audio/processors/downsampler/DownsamplerProcessor.ts\n\nimport type { AudioParamDescriptor } from '../worklet-types';\n\n/*\n * These need to be declared in every audio worklet processor file,\n * because apparently our typescript setup doesn't have the\n * AudioWorkletGlobalScope, and nothing I do will add it.\n */\n\ndeclare abstract class AudioWorkletProcessor {\n\tstatic get parameterDescriptors(): AudioParamDescriptor[];\n\tconstructor(options?: any);\n\tabstract process(\n\t\tinputs: Float32Array[][],\n\t\toutputs: Float32Array[][],\n\t\tparameters: Record<string, Float32Array>,\n\t): boolean;\n}\n\ndeclare function registerProcessor(name: string, processorCtor: typeof AudioWorkletProcessor): void;\n\n/** Parameters for the DownsamplerProcessor. */\ninterface DownsamplerParameters extends Record<string, Float32Array> {\n\tdownsampling: Float32Array;\n}\n\n/** An AudioWorkletProcessor that applies a downsampling (sample-and-hold) effect to audio. */\nclass DownsamplerProcessor extends AudioWorkletProcessor {\n\tstatic override get parameterDescriptors(): AudioParamDescriptor[] {\n\t\treturn [\n\t\t\t{\n\t\t\t\tname: 'downsampling',\n\t\t\t\tdefaultValue: 1,\n\t\t\t\tminValue: 1,\n\t\t\t\tmaxValue: 40,\n\t\t\t\tautomationRate: 'k-rate',\n\t\t\t},\n\t\t];\n\t}\n\n\tprivate phase = 0;\n\tprivate lastSampleValue = 0;\n\n\tprocess(\n\t\tinputs: Float32Array[][],\n\t\toutputs: Float32Array[][],\n\t\tparameters: DownsamplerParameters,\n\t): boolean {\n\t\tconst input = inputs[0];\n\t\tconst output = outputs[0];\n\t\tif (!input || !output) return true; // Nothing to process\n\n\t\tconst downsampling = parameters['downsampling'];\n\n\t\tfor (let channel = 0; channel < input.length; ++channel) {\n\t\t\tconst inputChannel = input[channel];\n\t\t\tconst outputChannel = output[channel];\n\t\t\tif (!inputChannel || !outputChannel) continue;\n\n\t\t\tfor (let i = 0; i < inputChannel.length; ++i) {\n\t\t\t\tconst downsamplingValue =\n\t\t\t\t\tdownsampling.length > 1 ? downsampling[i]! : downsampling[0]!;\n\n\t\t\t\t// Downsampling: Hold the last sample value for 'downsamplingValue' samples.\n\t\t\t\tif (this.phase % downsamplingValue < 1) this.lastSampleValue = inputChannel[i]!;\n\n\t\t\t\t// Output the held sample.\n\t\t\t\toutputChannel[i] = this.lastSampleValue;\n\n\t\t\t\tthis.phase++;\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t}\n}\n\nregisterProcessor('downsampler-processor', DownsamplerProcessor);\n"
  },
  {
    "path": "src/client/scripts/esm/audio/processors/worklet-types.ts",
    "content": "// src/client/scripts/esm/audio/processors/worklet-types.ts\n\n/**\n * Stores missing audio worklet typescript types that apparently\n * aren't present in the @types/audioworklet package.\n */\n\n/** Describes a parameter for an AudioWorkletProcessor. */\nexport interface AudioParamDescriptor {\n\tname: string;\n\tdefaultValue?: number;\n\tminValue?: number;\n\tmaxValue?: number;\n\tautomationRate?: 'a-rate' | 'k-rate';\n}\n"
  },
  {
    "path": "src/client/scripts/esm/chess/rendering/checkerboardgenerator.ts",
    "content": "// src/client/scripts/esm/chess/rendering/checkerboardgenerator.ts\n\n/**\n * This script can create a 2x2 checkerboard texture of any color for\n * light and dark tiles, and of any width.\n */\n\n/**\n * Creates a checkerboard pattern image of a given size with custom colors.\n * @param lightColor - The color for the light squares (CSS color format).\n * @param darkColor - The color for the dark squares (CSS color format).\n * @param imageSize - The size of the image (width and height). The final image will be imageSize x imageSize, split into 4 squares.\n * @returns A promise that resolves to the checkerboard image.\n */\nfunction createCheckerboardIMG(\n\tlightColor: string,\n\tdarkColor: string,\n\timageSize: number = 2,\n): Promise<HTMLImageElement> {\n\tconst canvas = document.createElement('canvas');\n\tcanvas.width = imageSize;\n\tcanvas.height = imageSize;\n\tconst ctx = canvas.getContext('2d')!;\n\n\t// Define the size of each square\n\tconst squareSize: number = imageSize / 2;\n\n\t// Top-left (light square)\n\tctx.fillStyle = lightColor;\n\tctx.fillRect(0, 0, squareSize, squareSize);\n\n\t// Top-right (dark square)\n\tctx.fillStyle = darkColor;\n\tctx.fillRect(squareSize, 0, squareSize, squareSize);\n\n\t// Bottom-left (dark square)\n\tctx.fillStyle = darkColor;\n\tctx.fillRect(0, squareSize, squareSize, squareSize);\n\n\t// Bottom-right (light square)\n\tctx.fillStyle = lightColor;\n\tctx.fillRect(squareSize, squareSize, squareSize, squareSize);\n\n\t// Convert to an image element\n\tconst img = new Image();\n\timg.src = canvas.toDataURL();\n\n\t// Return a promise that resolves when the image is loaded\n\treturn new Promise<HTMLImageElement>((resolve, reject): void => {\n\t\timg.onload = (): void => resolve(img);\n\t\timg.onerror = (): void => {\n\t\t\tconst errorMessage = 'Error loading the checkerboard texture';\n\t\t\tconsole.error(errorMessage, img);\n\t\t\treject(new Error(errorMessage));\n\t\t};\n\t});\n}\n\nexport default {\n\tcreateCheckerboardIMG,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/chess/rendering/imagecache.ts",
    "content": "// src/client/scripts/esm/chess/rendering/imagecache.ts\n\n/**\n * This script caches the HTMLImageElement objects for the pieces\n * required by the currently loaded game.\n *\n * It assumes that `initImagesForGame` is called before any\n * attempt to retrieve an image using `getPieceImage`.\n *\n * If no game is loaded, the cache should be empty.\n */\n\nimport type { Board } from '../../../../../shared/chess/logic/gamefile.js';\nimport type { TypeGroup } from '../../../../../shared/chess/util/typeutil.js';\n\nimport typeutil from '../../../../../shared/chess/util/typeutil.js';\n\nimport svgcache from '../../chess/rendering/svgcache.js';\nimport { GameBus } from '../../game/GameBus.js';\nimport svgtoimageconverter from '../../util/svgtoimageconverter.js';\n\n// Variables ---------------------------------------------------------------------------\n\n/**\n * The cache storing HTMLImageElement objects for each piece type\n * required by the current game. Keys are the numeric piece types.\n */\nlet cachedImages: TypeGroup<HTMLImageElement> = {};\n\n// Events ---------------------------------------------------------------------------\n\nGameBus.addEventListener('game-unloaded', () => {\n\tdeleteImageCache();\n});\n\n// Functions ---------------------------------------------------------------------------\n\n/**\n * Initializes the image cache for the provided gamefile.\n * Fetches necessary SVGs (using svgcache), converts them to images,\n * normalizes them, and stores them in the cache.\n */\nasync function initImagesForGame(boardsim: Board): Promise<void> {\n\tif (Object.keys(cachedImages).length > 0)\n\t\tthrow Error(\n\t\t\t'Image cache already initialized. Call deleteImageCache() when unloading games.',\n\t\t);\n\t// console.log(\"Initializing image cache for game...\");\n\n\t// 1. Determine required piece types (excluding SVG-less ones)\n\tconst types = boardsim.existingTypes.filter(\n\t\t(t: number) => !typeutil.SVGLESS_TYPES.has(typeutil.getRawType(t)),\n\t);\n\tif (types.length === 0)\n\t\treturn console.log(\n\t\t\t'No piece types with SVGs found for this game. Image cache remains empty.',\n\t\t);\n\n\t// console.log(\"Required piece types for image cache:\", types);\n\n\ttry {\n\t\t// 2. Get SVG elements using the existing svgcache\n\t\t// No width/height needed here as normalization will handle sizing later\n\t\tconst svgElements = await svgcache.getSVGElements(types);\n\t\t// console.log(`Retrieved ${svgElements.length} SVG elements.`);\n\n\t\t// 3. Convert SVGs to initial Image elements\n\t\tconst initialImages = await svgtoimageconverter.convertSVGsToImages(svgElements);\n\t\t// console.log(`Converted ${initialImages.length} SVGs to initial images.`);\n\n\t\t// 4. Normalize images and populate the cache\n\t\t// Patches firefox bug that darkens the image (when it is partially transparent) caused by double-multiplying the RGB channels by the alpha channel\n\t\tconst newCache: { [type: string]: HTMLImageElement } = {}; // 'pawn-white' => HTMLImageElement\n\t\tconst normalizationPromises: Promise<void>[] = [];\n\n\t\tfor (const img of initialImages) {\n\t\t\t// Ensure the image has an ID which corresponds to the piece type\n\t\t\tif (!img.id) throw Error('Image is missing ID after conversion from SVG.');\n\n\t\t\t// Start normalization process for each image\n\t\t\tconst promise = svgtoimageconverter\n\t\t\t\t.normalizeImagePixelData(img)\n\t\t\t\t.then((normalizedImg) => {\n\t\t\t\t\tnewCache[img.id] = normalizedImg;\n\t\t\t\t\t// Optional: Log successful caching of a specific type\n\t\t\t\t\t// console.log(`Cached normalized image for type ${typeutil.debugType(Number(img.id))}`);\n\t\t\t\t})\n\t\t\t\t.catch((error) => {\n\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t`Failed to normalize or cache image for type ${typeutil.debugType(Number(img.id))}:`,\n\t\t\t\t\t\terror,\n\t\t\t\t\t);\n\t\t\t\t\t// Decide how to handle normalization failures - potentially throw?\n\t\t\t\t});\n\t\t\tnormalizationPromises.push(promise);\n\t\t}\n\n\t\t// Wait for all normalizations to complete\n\t\tawait Promise.all(normalizationPromises);\n\n\t\t// Replace the old cache with the newly populated one\n\t\tcachedImages = newCache;\n\n\t\t// console.log(`Image cache initialization complete. Cached ${Object.keys(cachedImages).length} images.`);\n\t} catch (error) {\n\t\tconsole.error('Error during image cache initialization:', error);\n\t\t// Clear cache on failure to avoid partial state\n\t\tcachedImages = {};\n\t\t// Re-throw the error so the caller knows initialization failed\n\t\tthrow error;\n\t}\n}\n\n/**\n * Retrieves a cached HTMLImageElement for the given piece type.\n * Throws an error if the image for the type is not found in the cache.\n * Assumes `initImagesForGame` has been successfully called beforehand.\n */\nfunction getPieceImage(type: number): HTMLImageElement {\n\tconst image = cachedImages[type];\n\tif (!image)\n\t\tthrow new Error(\n\t\t\t`Image for piece type ${typeutil.debugType(type)} not found in cache. Was initImagesForGame() called?`,\n\t\t);\n\t// Optional: Return a clone to prevent external modification of the cached element?\n\t// For simple display, returning the direct reference is usually fine and more performant.\n\t// If you plan to modify the image attributes (like style) elsewhere, cloning might be safer:\n\t// return image.cloneNode(true) as HTMLImageElement;\n\treturn image;\n}\n\n/**\n * Clears the image cache. Call this when the game unloads.\n */\nfunction deleteImageCache(): void {\n\t// console.log(\"Deleting image cache.\");\n\tcachedImages = {};\n}\n\n// Exports -------------------------------------------------------------------\n\nexport default {\n\tinitImagesForGame,\n\tgetPieceImage,\n\tdeleteImageCache,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/chess/rendering/svgcache.ts",
    "content": "// src/client/scripts/esm/chess/rendering/svgcache.ts\n\n/**\n * This module handles fetching and caching of chess piece SVGs.\n * It won't request the same SVG twice.\n */\n\nimport type { Color } from '../../../../../shared/util/math/math.js';\nimport type { RawType, Player } from '../../../../../shared/chess/util/typeutil.js';\n\nimport typeutil from '../../../../../shared/chess/util/typeutil.js';\nimport pieceThemes from '../../../../../shared/components/header/pieceThemes.js';\n\nimport preferences from '../../components/header/preferences.js';\n\n// Variables -----------------------------------------------------------------\n\n/** Stores fetched SVG elements, keyed by their unique svg id (e.g., 'pawn-white'). These ids are on the svg elements themselves. */\nconst cachedPieceSVGs: { [pieceType: string]: SVGElement } = {};\n\n/** Tracks promises for ongoing SVG file fetch requests, using the file URL as the key, to prevent duplicates. */\nconst processingCache: { [key: string]: Promise<void> } = {};\n\n// Initialization: Cache classical pieces on load. EVERY SINGLE GAME USES THESE.\nfetchLocation('classical');\n\n// Core functionality --------------------------------------------------------\n\n/**\n * Fetches required SVG files if not cached, then returns the SVG elements for the requested piece types.\n * This is the main public function for retrieving piece SVGs.\n */\nasync function getSVGElements(\n\tids: number[],\n\twidth?: number,\n\theight?: number,\n): Promise<SVGElement[]> {\n\tconst locations = getNeededSVGLocations(ids);\n\tif (locations.size > 0) await fetchMissingTypes(locations);\n\t// At this point, all needed SVGs should be in the cache!\n\treturn getSVGIDs(ids, width, height);\n}\n\n/**\n * Initiates fetch requests for all specified SVG file locations concurrently, preventing duplicate requests.\n * @param locations - A set of unique SVG location names (e.g., \"classical\", \"fairy/rose\") to fetch.\n */\nasync function fetchMissingTypes(locations: Set<string>): Promise<void> {\n\tawait Promise.all([...locations].map(async (location) => fetchLocation(location)));\n}\n/**\n * Fetches an SVG file from a specific location, parses it, and caches the individual SVG elements found within.\n * It prevents duplicate fetch requests for the same URL while a request is already in progress.\n * @param location - The SVG file location on the server (e.g., \"classical\", \"fairy/rose\") relative to `svg/pieces/`.\n * @returns A promise that resolves when the fetch and caching are complete.\n */\nasync function fetchLocation(location: string): Promise<void> {\n\tconst url = `svg/pieces/${location}.svg`;\n\n\tif (!processingCache[url]) {\n\t\tprocessingCache[url] = (async (): Promise<void> => {\n\t\t\ttry {\n\t\t\t\tconst response = await fetch(url);\n\t\t\t\tif (!response.ok)\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`HTTP error when fetching piece svgs from location \"${location}\"! status: ${response.status}`,\n\t\t\t\t\t);\n\t\t\t\tconst svgText = await response.text();\n\t\t\t\tconst doc = new DOMParser().parseFromString(svgText, 'image/svg+xml');\n\n\t\t\t\tArray.from(doc.getElementsByTagName('svg')).forEach((svg) => {\n\t\t\t\t\tcachedPieceSVGs[svg.id] = svg;\n\t\t\t\t\t// console.log(`Fetched piece svg at location ${location}`);\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\t// Remove the failed promise from the cache to allow retrying\n\t\t\t\tdelete processingCache[url];\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t})();\n\t} else {\n\t\t// console.log(`Already fetching piece svg at location ${location}. Not sending duplicate request. Waiting..`);\n\t}\n\n\tawait processingCache[url];\n}\n\n/**\n * Tints an SVG element by applying a multiplication filter using the specified color.\n * The tint is applied by multiplying the original colors with the provided [r, g, b, a] values.\n * For example, white (1,1,1) becomes the tint color and black (0,0,0) remains black.\n * @param svgElement\n * @param color\n */\nfunction tintSVG(svgElement: SVGElement, color: Color): SVGElement {\n\t// Ensure a <defs> element exists in the SVG\n\tconst defs =\n\t\tsvgElement.querySelector('defs') ??\n\t\tsvgElement.insertBefore(\n\t\t\tdocument.createElementNS('http://www.w3.org/2000/svg', 'defs'),\n\t\t\tsvgElement.firstChild,\n\t\t);\n\n\t// Create a unique filter\n\tconst filterId = `tint-${crypto.randomUUID()}`;\n\tconst filter = document.createElementNS('http://www.w3.org/2000/svg', 'filter');\n\tfilter.id = filterId;\n\n\t// Create feColorMatrix with the tinting effect to multiply color channels.\n\tconst feColorMatrix = document.createElementNS('http://www.w3.org/2000/svg', 'feColorMatrix');\n\tfeColorMatrix.setAttribute('type', 'matrix');\n\t// Construct the matrix values string, and multiply each color channel by them.\n\t// prettier-ignore\n\tconst matrixValues = [\n\t\tcolor[0], 0, 0, 0, 0,\n\t\t0, color[1], 0, 0, 0,\n\t\t0, 0, color[2], 0, 0,\n\t\t0, 0, 0, color[3], 0\n\t].join(' ');\n\tfeColorMatrix.setAttribute('values', matrixValues);\n\n\t// Append filter and apply it to the SVG\n\tfilter.appendChild(feColorMatrix);\n\tdefs.appendChild(filter);\n\n\t// Apply the filter to the SVG element.\n\t// svgElement.setAttribute('filter', `url(#${filterId})`);\n\t{\n\t\t// FIREFOX PATCH. Without this block, in firefox when converting the svg to an image, the filter is not applied.\n\t\t// Create a <g> element to wrap all children (except <defs>)\n\t\tconst group = document.createElementNS('http://www.w3.org/2000/svg', 'g');\n\t\tgroup.setAttribute('filter', `url(#${filterId})`);\n\n\t\t// Move all children (except <defs>) into the <g> element\n\t\tconst children = Array.from(svgElement.childNodes);\n\t\tfor (const child of children) {\n\t\t\tif (child !== defs) {\n\t\t\t\tgroup.appendChild(child);\n\t\t\t}\n\t\t}\n\n\t\t// Append the <g> element to the SVG\n\t\tsvgElement.appendChild(group);\n\t}\n\n\treturn svgElement;\n}\n\n// Helper functions ---------------------------------------------------------\n\n/**\n * Determines the priority of what player color gets what color of svg, depending on what's available.\n * For example, if player neutral needs a pawn svg, it will first look for a neutral svg,\n * but when it doesn't exist it will fallback to the white svg.\n * @param color - The player color code (0, 1, or 2).\n * @returns An array of SVG color variant suffixes, ordered by lookup priority.\n */\nfunction getSVGColorPriority(color: Player): string[] {\n\tswitch (color) {\n\t\tcase 0: // Neutral: prioritize neutral svg over white\n\t\t\treturn ['-neutral', '-white'];\n\t\tcase 1: // White: prioritize white svg over black\n\t\t\treturn ['-white', '-neutral'];\n\t\tcase 2: // Black: prioritize black svg over neutral\n\t\t\treturn ['-black', '-neutral'];\n\t\t// All higher player numbers are treated as tinted white pieces...\n\t\tcase 3: // Red: prioritize white svg over neutral\n\t\t\treturn ['-white', '-neutral'];\n\t\tcase 4: // Blue: prioritize white svg over neutral\n\t\t\treturn ['-white', '-neutral'];\n\t\tcase 5: // Yellow: prioritize white svg over neutral\n\t\t\treturn ['-white', '-neutral'];\n\t\tcase 6: // Green: prioritize white svg over neutral\n\t\t\treturn ['-white', '-neutral'];\n\t\tdefault:\n\t\t\tthrow new Error(`Invalid color code: ${color}`);\n\t}\n}\n\n/**\n * Identifies the unique SVG file locations (e.g., \"classical\", \"fairy/rose\") that need to be fetched.\n * It checks the cache first and only returns locations for types whose SVG variants are not yet cached.\n * @param types - An array of piece type numbers (combining raw type and color).\n * @returns A set of unique SVG file location names required for the given types.\n */\nfunction getNeededSVGLocations(types: number[]): Set<string> {\n\tconst locations: Set<RawType> = new Set();\n\ttypeloop: for (const type of types) {\n\t\tconst [raw, c] = typeutil.splitType(type);\n\t\tconst baseId = `${typeutil.getRawTypeStr(raw)}`;\n\t\tconst checks: string[] = getSVGColorPriority(c);\n\t\tfor (const c of checks) {\n\t\t\tconst id = baseId + c;\n\t\t\tif (id in cachedPieceSVGs) continue typeloop;\n\t\t}\n\t\tlocations.add(raw);\n\t}\n\n\treturn pieceThemes.getLocationsForTypes(locations);\n}\n\n/**\n * Retrieves and prepares cloned SVG elements for the specified piece types from the cache.\n * It automatically applies our theme's tint as well.\n * @param types - An array of piece type numbers to get SVGs for.\n * @param [width] - Optional width to set on the SVG elements.\n * @param [height] - Optional height to set on the SVG elements.\n * @returns An array of cloned and prepared SVG elements.\n */\nfunction getSVGIDs(types: number[], width?: number, height?: number): SVGElement[] {\n\tlet failed: boolean = false;\n\tconst svgs: SVGElement[] = [];\n\tl: for (const type of types) {\n\t\tconst tint = preferences.getTintColorOfType(type);\n\t\tconst [raw, c] = typeutil.splitType(type);\n\t\tconst baseId = `${typeutil.getRawTypeStr(raw)}`;\n\t\tconst checks: string[] = getSVGColorPriority(c);\n\t\tfor (const c of checks) {\n\t\t\tconst id = baseId + c;\n\t\t\tif (!(id in cachedPieceSVGs)) continue;\n\t\t\t// Clone the SVG element\n\t\t\tconst cloned = cachedPieceSVGs[id]!.cloneNode(true) as SVGElement;\n\n\t\t\tcloned.id = String(type);\n\n\t\t\t// Set width and height if specified\n\t\t\tif (width !== undefined) cloned.setAttribute('width', width.toString());\n\t\t\tif (height !== undefined) cloned.setAttribute('height', height.toString());\n\n\t\t\t// Tint if non-white\n\t\t\tif (tint.some((channel) => channel !== 1)) tintSVG(cloned, tint);\n\n\t\t\tsvgs.push(cloned);\n\t\t\tcontinue l;\n\t\t}\n\t\tconsole.error(\n\t\t\t`SVG at path \"${pieceThemes.getLocationForType(raw)}\" does not contain an svg with extensions ${checks} for ${baseId}`,\n\t\t);\n\t\tfailed = true;\n\t}\n\tif (failed) throw Error('SVG theme is missing ids for pieces');\n\treturn svgs;\n}\n\n/**\n * Appends all cached SVG elements directly to the document body for debugging purposes.\n * This allows visual inspection of the SVGs currently held in the cache.\n */\nfunction showCache(): void {\n\tfor (const svg of Object.values(cachedPieceSVGs)) {\n\t\tdocument.body.appendChild(svg);\n\t}\n}\n\n// Exports -------------------------------------------------------------------\n\nexport default {\n\tgetSVGElements,\n\tshowCache,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/chess/rendering/texturecache.ts",
    "content": "// src/client/scripts/esm/chess/rendering/texturecache.ts\n\n/**\n * This module handles the caching of WebGL textures of the pieces in our game.\n * It prevents redundant texture creation and data uploads to the GPU by caching\n * textures based on their source type. All textures are created with mipmaps enabled.\n */\n\nimport type { Board } from '../../../../../shared/chess/logic/gamefile.js';\nimport type { TypeGroup } from '../../../../../shared/chess/util/typeutil.js';\n\nimport typeutil from '../../../../../shared/chess/util/typeutil.js';\n\nimport imagecache from './imagecache.js';\nimport TextureLoader from '../../webgl/TextureLoader.js';\n\n// Texture Cache Implementation ----------------------------------------------------------\n\n/** Internal cache storing WebGLTexture objects, keyed by piece type. */\nconst textureCache: TypeGroup<WebGLTexture> = {};\n\n/**\n * Initializes the texture cache for the provided gamefile.\n * Retrieves necessary images from `imagecache`, creates WebGL textures\n * (with mipmaps enabled) for each, and stores them in the cache.\n * MUST be called after {@link imagecache.initImagesForGame}` has successfully completed.\n * @param gl - The WebGL2 rendering context.\n * @param boardsim - The board containing the list of piece types used.\n */\nasync function initTexturesForGame(gl: WebGL2RenderingContext, boardsim: Board): Promise<void> {\n\t// Clear existing cache before initializing for a new game\n\t// if (Object.keys(textureCache).length > 0) throw Error(\"TextureCache: Cache already initialized. Call deleteTextureCache() when unloading games.\");\n\t// console.log(\"Initializing texture cache for game...\");\n\n\t// 1. Determine required piece types (mirroring imagecache logic, filter SVG-less)\n\tconst types = boardsim.existingTypes.filter(\n\t\t(t: number) => !typeutil.SVGLESS_TYPES.has(typeutil.getRawType(t)),\n\t);\n\n\tif (types.length === 0)\n\t\treturn console.log(\n\t\t\t'TextureCache: No piece types with SVGs found for this game. Texture cache remains empty.',\n\t\t);\n\n\t// console.log(\"Required piece types for texture cache:\", types);\n\n\t// 2. Iterate and create textures\n\tfor (const type of types) {\n\t\t// Retrieve the pre-cached loaded image\n\t\tconst img = imagecache.getPieceImage(type);\n\t\ttextureCache[type] = TextureLoader.loadTexture(gl, img, { mipmaps: true });\n\t\t// console.log(`TextureCache: Cached texture for type ${typeutil.debugType(type)}`);\n\t}\n\t// console.log(`TextureCache: Initialization complete. Cached ${Object.keys(textureCache).length} textures.`);\n}\n\n/**\n * Retrieves a WebGLTexture from the cache.\n * ASSUMES `initTexturesForGame` has been called successfully for the current game.\n * @param type - The piece type.\n * @returns The cached WebGLTexture.\n */\nfunction getTexture(type: number): WebGLTexture {\n\t// 1. Check cache using type directly as the key\n\tconst cachedTexture = textureCache[type];\n\tif (cachedTexture) return cachedTexture;\n\t// If not found, it implies initTexturesForGame wasn't called or failed for this type.\n\telse\n\t\tthrow new Error(\n\t\t\t`TextureCache: Texture for type ${typeutil.debugType(type)} not found in cache. Was initTexturesForGame() called?`,\n\t\t);\n}\n\n// /**\n//  * Deletes all textures currently stored in the cache from the GPU memory\n//  * and clears the internal cache object.\n//  *\n//  * **Important:** This requires the same WebGL context that was used to create the textures.\n//  * Call this when the WebGL context is being destroyed or the cached textures are no longer needed\n//  * to prevent GPU memory leaks.\n//  */\n// function deleteTextureCache(gl: WebGL2RenderingContext): void {\n// \tconsole.log(\"TextureCache: Deleting all cached textures...\");\n// \tfor (const key in textureCache) gl.deleteTexture(textureCache[key]!);\n// \ttextureCache = {}; // Clear the cache object\n// \tconsole.log(`TextureCache: Deleted textures from GPU and cleared cache.`);\n// }\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\tinitTexturesForGame, // Add the init function to exports\n\tgetTexture,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/components/header/currpage-greyer.ts",
    "content": "// src/client/scripts/esm/components/header/currpage-greyer.ts\n\n// Greys the background color of the header navigation link of the page we are currently on\n\nimport docutil from '../../util/docutil.js';\nimport validatorama from '../../util/validatorama.js';\n\nconst loginLink = document.getElementById('login-link')!;\n\n(function init() {\n\tgreyBackgroundOfCurrPage();\n\tinitListeners();\n})();\n\n/** Greys the background color of the header navigation link of the page we are currently on */\nfunction greyBackgroundOfCurrPage(): void {\n\tdocument.querySelectorAll('nav a').forEach((link) => {\n\t\tconst hrefPathname = docutil.getPathnameFromHref(link.getAttribute('href')!);\n\t\tif (hrefPathname === window.location.pathname) {\n\t\t\t// e.g. \"/news\"\n\t\t\tlink.classList.add('currPage');\n\t\t} else {\n\t\t\tlink.classList.remove('currPage');\n\t\t}\n\t});\n\tupdateColorOfProfileButton();\n}\n\n// Greys the background color of the profile button if it is ours\nfunction updateColorOfProfileButton(): void {\n\tif (!window.location.pathname.startsWith('/member')) return; // Not on a members profile\n\n\tloginLink.classList.remove('currPage'); // Reset\n\n\tconst username = validatorama.getOurUsername();\n\tif (!username) return; // Not signed in, this isn't our profile\n\n\tif (docutil.getLastSegmentOfURL() === username.toLowerCase())\n\t\tloginLink.classList.add('currPage');\n}\n\nfunction initListeners(): void {\n\tdocument.addEventListener('login', updateColorOfProfileButton); // Custom-event listener. Fired when the validator script receives a response from the server with either our access token or new browser-id cookie.\n\twindow.addEventListener('pageshow', greyBackgroundOfCurrPage); // Fired on initial page load AND when hitting the back button to return.\n}\n\nexport default {};\n"
  },
  {
    "path": "src/client/scripts/esm/components/header/dropdowns/appearancedropdown.ts",
    "content": "// src/client/scripts/esm/components/header/dropdowns/appearancedropdown.ts\n\nimport themes from '../../../../../../shared/components/header/themes.js';\n\nimport style from '../../../game/gui/style.js';\nimport preferences from '../preferences.js';\nimport checkerboardgenerator from '../../../chess/rendering/checkerboardgenerator.js';\n\n// Document Elements -------------------------------------------------------------------------\n\nconst appearanceDropdownTitle = document.querySelector('.appearance-dropdown .dropdown-title')!;\nconst appearanceDropdown = document.querySelector('.appearance-dropdown')!;\nconst themeList = document.querySelector('.theme-list')!; // Get the theme list div\n\nconst coordinatesCheckbox = document.querySelector<HTMLInputElement>(\n\t'.boolean-option.coordinates input',\n)!;\nconst starfieldCheckbox = document.querySelector<HTMLInputElement>(\n\t'.boolean-option.starfield input',\n)!;\nconst advancedEffectsCheckbox = document.querySelector<HTMLInputElement>(\n\t'.boolean-option.advanced-effects input',\n)!;\n\n// Functions ---------------------------------------------------------------------------------\n\n(function init() {\n\tshowCheckmarkOnSelectedOptions();\n\taddThemesToThemesDropdown();\n})();\n\nfunction showCheckmarkOnSelectedOptions(): void {\n\tcoordinatesCheckbox.checked = preferences.getCoordinatesEnabled();\n\tstarfieldCheckbox.checked = preferences.getStarfieldMode();\n\tadvancedEffectsCheckbox.checked = preferences.getAdvancedEffectsMode();\n}\n\nasync function addThemesToThemesDropdown(): Promise<void> {\n\tconst themeDictionary = themes.themes;\n\n\t// Loop through each theme in the dictionary\n\tfor (const themeName in themeDictionary) {\n\t\tconst theme = themeDictionary[themeName]!;\n\t\tconst lightTiles = theme.lightTiles;\n\t\tconst darkTiles = theme.darkTiles;\n\n\t\t// Create the checkerboard image for the theme\n\t\tconst checkerboardImage = await checkerboardgenerator.createCheckerboardIMG(\n\t\t\tstyle.arrayToCssColor(lightTiles), // Convert to CSS color format\n\t\t\tstyle.arrayToCssColor(darkTiles), // Convert to CSS color format\n\t\t\t2, // Width\n\t\t);\n\t\tcheckerboardImage.setAttribute('theme', themeName);\n\t\tcheckerboardImage.setAttribute('draggable', 'false');\n\n\t\t// Append the image to the theme list div\n\t\tthemeList.appendChild(checkerboardImage);\n\t}\n\n\tupdateThemeSelectedStyling();\n}\n\nfunction open(): void {\n\tappearanceDropdown.classList.remove('visibility-hidden'); // The stylesheet adds a short delay animation to when it becomes hidden\n\tinitListeners();\n}\nfunction close(): void {\n\tappearanceDropdown.classList.add('visibility-hidden'); // The stylesheet adds a short delay animation to when it becomes hidden\n\tcloseListeners();\n}\n\nfunction initListeners(): void {\n\tappearanceDropdownTitle.addEventListener('click', close);\n\tinitThemeChangeListeners();\n\t// Coordinates toggle\n\tcoordinatesCheckbox.addEventListener('click', toggleCoordinates);\n\t// Starfield toggle\n\tstarfieldCheckbox.addEventListener('click', toggleStarfield);\n\t// Advanced Effects toggle\n\tadvancedEffectsCheckbox.addEventListener('click', toggleAdvancedEffects);\n}\nfunction closeListeners(): void {\n\tappearanceDropdownTitle.removeEventListener('click', close);\n\tcloseThemeChangeListeners();\n\t// Coordinates toggle\n\tcoordinatesCheckbox.removeEventListener('click', toggleCoordinates);\n\t// Starfield toggle\n\tstarfieldCheckbox.removeEventListener('click', toggleStarfield);\n\t// Advanced Effects toggle\n\tadvancedEffectsCheckbox.removeEventListener('click', toggleAdvancedEffects);\n}\nfunction initThemeChangeListeners(): void {\n\tfor (let i = 0; i < themeList.children.length; i++) {\n\t\tconst theme = themeList.children[i]!;\n\t\ttheme.addEventListener('click', selectTheme);\n\t}\n}\nfunction closeThemeChangeListeners(): void {\n\tfor (let i = 0; i < themeList.children.length; i++) {\n\t\tconst theme = themeList.children[i]!;\n\t\ttheme.removeEventListener('click', selectTheme);\n\t}\n}\n\nfunction selectTheme(event: Event): void {\n\tconst selectedTheme = (event.currentTarget as HTMLElement).getAttribute('theme')!;\n\n\t// Saves it to browser storage\n\tpreferences.setTheme(selectedTheme);\n\n\tupdateThemeSelectedStyling();\n\n\t// Dispatch a custom event for theme change so that any game code present can pick it up.\n\tdocument.dispatchEvent(new Event('theme-change'));\n}\n/** Outlines in black the current theme selection */\nfunction updateThemeSelectedStyling(): void {\n\tconst selectedTheme = preferences.getTheme();\n\tfor (let i = 0; i < themeList.children.length; i++) {\n\t\tconst theme = themeList.children[i]!;\n\t\tif (theme.getAttribute('theme') === selectedTheme) theme.classList.add('selected');\n\t\telse theme.classList.remove('selected');\n\t}\n}\n\nfunction toggleCoordinates(): void {\n\tpreferences.setCoordinatesEnabled(coordinatesCheckbox.checked);\n}\n\nfunction toggleStarfield(): void {\n\tpreferences.setStarfieldMode(starfieldCheckbox.checked);\n}\n\nfunction toggleAdvancedEffects(): void {\n\tpreferences.setAdvancedEffectsMode(advancedEffectsCheckbox.checked);\n}\n\nexport default {\n\topen,\n\tclose,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/components/header/dropdowns/gameplaydropdown.ts",
    "content": "// src/client/scripts/esm/components/header/dropdowns/gameplaydropdown.ts\n\n// This script allows us to enable or disable premoves and dragging pieces\n\nimport preferences from '../preferences.js';\n\n// Document Elements -------------------------------------------------------------------------\n\nconst settingsDropdown = document.querySelector('.settings-dropdown')!;\n\nconst gameplayDropdown = document.querySelector('.gameplay-dropdown')!;\nconst gameplayDropdownTitle = document.querySelector('.gameplay-dropdown .dropdown-title')!;\n\nconst dragCheckbox = document.querySelector('.boolean-option.drag input') as HTMLInputElement;\nconst premoveCheckbox = document.querySelector('.boolean-option.premove input') as HTMLInputElement;\nconst animationsCheckbox = document.querySelector(\n\t'.boolean-option.animations input',\n) as HTMLInputElement;\nconst fastTransitionsCheckbox = document.querySelector(\n\t'.boolean-option.fast-transitions input',\n) as HTMLInputElement;\nconst lingeringAnnotationsCheckbox = document.querySelector(\n\t'.boolean-option.lingering-annotations input',\n) as HTMLInputElement;\n\n// Functions ---------------------------------------------------------------------------------\n\n(function init() {\n\tshowCheckmarkOnSelectedOptions();\n})();\n\nfunction showCheckmarkOnSelectedOptions(): void {\n\tdragCheckbox.checked = preferences.getDragEnabled();\n\tpremoveCheckbox.checked = preferences.getPremoveEnabled();\n\tanimationsCheckbox.checked = preferences.getAnimationsMode();\n\tfastTransitionsCheckbox.checked = preferences.getFastTransitionsMode();\n\tlingeringAnnotationsCheckbox.checked = preferences.getLingeringAnnotationsMode();\n}\n\nfunction open(): void {\n\tgameplayDropdown.classList.remove('visibility-hidden');\n\tinitListeners();\n\tsettingsDropdown.classList.add('transparent');\n}\nfunction close(): void {\n\tgameplayDropdown.classList.add('visibility-hidden');\n\tcloseListeners();\n\tsettingsDropdown.classList.remove('transparent');\n}\n\nfunction initListeners(): void {\n\tgameplayDropdownTitle.addEventListener('click', close);\n\tdragCheckbox.addEventListener('click', toggleDrag);\n\tpremoveCheckbox.addEventListener('click', togglePremove);\n\tanimationsCheckbox.addEventListener('click', toggleAnimations);\n\tfastTransitionsCheckbox.addEventListener('click', toggleFastTransitions);\n\tlingeringAnnotationsCheckbox.addEventListener('click', toggleLingeringAnnotations);\n}\nfunction closeListeners(): void {\n\tgameplayDropdownTitle.removeEventListener('click', close);\n\tdragCheckbox.removeEventListener('click', toggleDrag);\n\tpremoveCheckbox.removeEventListener('click', togglePremove);\n\tanimationsCheckbox.removeEventListener('click', toggleAnimations);\n\tfastTransitionsCheckbox.removeEventListener('click', toggleFastTransitions);\n\tlingeringAnnotationsCheckbox.removeEventListener('click', toggleLingeringAnnotations);\n}\n\nfunction toggleDrag(): void {\n\tpreferences.setDragEnabled(dragCheckbox.checked);\n}\nfunction togglePremove(): void {\n\tpreferences.setPremoveMode(premoveCheckbox.checked);\n}\nfunction toggleAnimations(): void {\n\tpreferences.setAnimationsMode(animationsCheckbox.checked);\n}\nfunction toggleFastTransitions(): void {\n\tpreferences.setFastTransitionsMode(fastTransitionsCheckbox.checked);\n}\nfunction toggleLingeringAnnotations(): void {\n\tpreferences.setLingeringAnnotationsMode(lingeringAnnotationsCheckbox.checked);\n}\n\nexport default {\n\tinitListeners,\n\tcloseListeners,\n\tclose,\n\topen,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/components/header/dropdowns/languagedropdown.ts",
    "content": "// src/client/scripts/esm/components/header/dropdowns/languagedropdown.ts\n\n// This script selects new languages when we click a language in the language dropdown.\n// It also appends the lng query param to all header navigation links.\n// And it removes the lng query param from the url after loading.\n\nimport docutil from '../../../util/docutil.js';\n\n// Document Elements -------------------------------------------------------------------------\n\nconst settingsDropdown = document.querySelector('.settings-dropdown')!;\n\nconst languageDropdown = document.querySelector('.language-dropdown')!;\nconst dropdownItems = document.querySelectorAll('.language-dropdown-item');\nconst languageDropdownTitle = document.querySelector('.language-dropdown .dropdown-title')!;\n\n// Functions ---------------------------------------------------------------------------------\n\n(function init() {\n\t// Request cookie if it doesn't exist\n\tif (!docutil.getCookieValue('i18next')) {\n\t\tfetch('/setlanguage', {\n\t\t\tmethod: 'POST',\n\t\t\tcredentials: 'same-origin',\n\t\t\theaders: {\n\t\t\t\t'is-fetch-request': 'true', // Custom header\n\t\t\t},\n\t\t});\n\t}\n\tremoveLngQueryParam();\n})();\n\n/**\n * Modifies the provided URL to include the \"lng\" query parameter based on the i18next cookie.\n * @param href - The original URL.\n * @returns The modified URL with the \"lng\" query parameter.\n */\nfunction addLngQueryParamToLink(href: string): string {\n\t// Get the value of the i18next cookie\n\tconst lng = docutil.getCookieValue('i18next');\n\tif (!lng) return href;\n\n\t// Create a URL object from the given href\n\tconst url = new URL(href, window.location.origin);\n\n\t// Add or update the \"lng\" query parameter\n\turl.searchParams.set('lng', lng);\n\n\t// Return the modified URL as a string\n\treturn url.toString();\n}\n\n/** This block auto-removes the \"lng\" query parameter from the url, visually, without refreshing */\nfunction removeLngQueryParam(): void {\n\t// Create a URL object from the current window location\n\tconst url = new URL(window.location.href);\n\n\t// Remove the \"lng\" query parameter\n\turl.searchParams.delete('lng');\n\n\t// Update the browser's URL without refreshing the page\n\twindow.history.replaceState({}, '', url);\n}\n\nfunction open(): void {\n\tlanguageDropdown.classList.remove('visibility-hidden'); // The stylesheet adds a short delay animation to when it becomes hidden\n\tinitListeners();\n\tsettingsDropdown.classList.add('transparent');\n}\nfunction close(): void {\n\tlanguageDropdown.classList.add('visibility-hidden'); // The stylesheet adds a short delay animation to when it becomes hidden\n\tcloseListeners();\n\tsettingsDropdown.classList.remove('transparent');\n}\n\nfunction initListeners(): void {\n\tlanguageDropdownTitle.addEventListener('click', close);\n\tdropdownItems.forEach((item) => {\n\t\titem.addEventListener('click', onLanguageClicked);\n\t});\n}\nfunction closeListeners(): void {\n\tlanguageDropdownTitle.removeEventListener('click', close);\n\tdropdownItems.forEach((item) => {\n\t\titem.removeEventListener('click', onLanguageClicked);\n\t});\n}\n\nfunction onLanguageClicked(event: Event): void {\n\tconst item = event.currentTarget as HTMLElement;\n\tconst selectedLanguage = item.getAttribute('value')!; // Get the selected language code\n\tdocutil.updateCookie('i18next', selectedLanguage, 365);\n\n\t// Modify the URL to include the \"lng\" query parameter\n\tconst url = new URL(window.location.href);\n\turl.searchParams.set('lng', selectedLanguage);\n\n\t// Update the browser's URL without reloading the page\n\twindow.history.replaceState({}, '', url);\n\n\t// Reload the page\n\tlocation.reload();\n}\n\nexport default {\n\tinitListeners,\n\tcloseListeners,\n\taddLngQueryParamToLink,\n\topen,\n\tclose,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/components/header/dropdowns/legalmovedropdown.ts",
    "content": "// src/client/scripts/esm/components/header/dropdowns/legalmovedropdown.ts\n\n// This script selects new languages when we click a language in the language dropdown.\n// It also appends the lng query param to all header navigation links.\n// And it removes the lng query param from the url after loading.\n\nimport preferences from '../preferences.js';\n\n// Document Elements -------------------------------------------------------------------------\n\nconst settingsDropdown = document.querySelector('.settings-dropdown')!;\n\nconst legalmoveDropdown = document.querySelector('.legalmove-dropdown')!;\n// const dropdownItems = document.querySelectorAll(\".legalmove-option\");\nconst legalmoveDropdownTitle = document.querySelector('.legalmove-dropdown .dropdown-title')!;\n\nconst squaresOption = document.querySelector('.legalmove-option.squares')!;\nconst dotsOption = document.querySelector('.legalmove-option.dots')!;\n\n// Functions ---------------------------------------------------------------------------------\n\n(function init() {\n\tshowCheckmarkOnSelectedOption();\n})();\n\nfunction showCheckmarkOnSelectedOption(): void {\n\tconst selectedLegalMovesOption = preferences.getLegalMovesShape(); // squares/dots\n\tconst targetCheckmark = document.querySelector<HTMLElement>(\n\t\t`.legalmove-option.${selectedLegalMovesOption} .checkmark`,\n\t)!;\n\ttargetCheckmark.classList.remove('visibility-hidden');\n}\n\nfunction open(): void {\n\tlegalmoveDropdown.classList.remove('visibility-hidden'); // The stylesheet adds a short delay animation to when it becomes hidden\n\tinitListeners();\n\tsettingsDropdown.classList.add('transparent');\n}\nfunction close(): void {\n\tlegalmoveDropdown.classList.add('visibility-hidden'); // The stylesheet adds a short delay animation to when it becomes hidden\n\tcloseListeners();\n\tsettingsDropdown.classList.remove('transparent');\n}\n\nfunction initListeners(): void {\n\tlegalmoveDropdownTitle.addEventListener('click', close);\n\tsquaresOption.addEventListener('click', toggleSquares);\n\tdotsOption.addEventListener('click', toggleDots);\n}\nfunction closeListeners(): void {\n\tlegalmoveDropdownTitle.removeEventListener('click', close);\n\tsquaresOption.removeEventListener('click', toggleSquares);\n\tdotsOption.removeEventListener('click', toggleDots);\n}\n\nfunction toggleSquares(): void {\n\t// console.log(\"Clicked squares\");\n\tpreferences.setLegalMovesShape('squares');\n\thideAllCheckmarks();\n\tconst checkmark = document.querySelector<HTMLElement>('.legalmove-option.squares .checkmark')!;\n\tcheckmark.classList.remove('visibility-hidden');\n\tdispatchLegalMoveChangeEvent();\n}\n\nfunction toggleDots(): void {\n\t// console.log(\"Clicked dots\");\n\tpreferences.setLegalMovesShape('dots');\n\thideAllCheckmarks();\n\tconst checkmark = document.querySelector<HTMLElement>('.legalmove-option.dots .checkmark')!;\n\tcheckmark.classList.remove('visibility-hidden');\n\tdispatchLegalMoveChangeEvent();\n}\n\nfunction hideAllCheckmarks(): void {\n\tdocument.querySelectorAll('.legalmove-option .checkmark').forEach((checkmark) => {\n\t\tcheckmark.classList.add('visibility-hidden');\n\t});\n}\n\nfunction dispatchLegalMoveChangeEvent(): void {\n\t// Dispatch a custom event for theme change so that any game code present can pick it up.\n\tconst themeChangeEvent = new CustomEvent('legalmove-shape-change');\n\tdocument.dispatchEvent(themeChangeEvent);\n}\n\nexport default {\n\tinitListeners,\n\tcloseListeners,\n\tclose,\n\topen,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/components/header/dropdowns/perspectivedropdown.ts",
    "content": "// src/client/scripts/esm/components/header/dropdowns/perspectivedropdown.ts\n\n// This script allows us to adjust the mouse sensitivity in perspective mode\n\nimport docutil from '../../../util/docutil.js';\nimport preferences from '../preferences.js';\n\n// Document Elements -------------------------------------------------------------------------\n\nconst settingsDropdown = document.querySelector('.settings-dropdown')!;\n\n// The option in the main settings menu\nconst perspectiveSettingsDropdownItem = document.getElementById(\n\t'perspective-settings-dropdown-item',\n)!;\n\nconst perspectiveDropdown = document.querySelector('.perspective-dropdown')!;\nconst perspectiveDropdownTitle = document.querySelector('.perspective-dropdown .dropdown-title')!;\n\nconst mouseSensitivitySlider = document.querySelector<HTMLInputElement>(\n\t'.perspective-options .mouse-sensitivity .slider',\n)!;\n/** The text that displays the value */\nconst mouseSensitivityOutput = document.querySelector(\n\t'.perspective-options .mouse-sensitivity .value',\n)!;\n\nconst fovSlider = document.querySelector<HTMLInputElement>('.perspective-options .fov .slider')!;\n/** The text that displays the value */\nconst fovOutput = document.querySelector('.perspective-dropdown .fov .value')!;\nconst fovResetDefaultContainer = document.querySelector(\n\t'.perspective-dropdown .fov .reset-default-container',\n)!;\nconst fovResetDefault = document.querySelector('.perspective-dropdown .fov .reset-default')!;\n\n// Functions ---------------------------------------------------------------------------------\n\n(function init() {\n\tif (docutil.isMouseSupported())\n\t\tperspectiveSettingsDropdownItem.classList.remove('hidden'); // Enable (perspective mode can't be used on mobile)\n\telse return;\n\n\tsetInitialValues();\n})();\n\n/** Update the sliders according to the already existing preferences */\nfunction setInitialValues(): void {\n\tmouseSensitivitySlider.value = String(preferences.getPerspectiveSensitivity());\n\tupdateMouseSensitivityOutput();\n\n\tfovSlider.value = String(preferences.getPerspectiveFOV());\n\tupdateFOVOutput();\n}\n\nfunction open(): void {\n\tperspectiveDropdown.classList.remove('visibility-hidden');\n\tinitListeners();\n\tsettingsDropdown.classList.add('transparent');\n}\nfunction close(): void {\n\tperspectiveDropdown.classList.add('visibility-hidden');\n\tcloseListeners();\n\tsettingsDropdown.classList.remove('transparent');\n}\n\nfunction initListeners(): void {\n\tperspectiveDropdownTitle.addEventListener('click', close);\n\tmouseSensitivitySlider.addEventListener('input', onMouseSensitivityChange);\n\tfovSlider.addEventListener('input', onFOVChange);\n\tfovResetDefault.addEventListener('click', resetFOVDefault);\n}\nfunction closeListeners(): void {\n\tperspectiveDropdownTitle.removeEventListener('click', close);\n\tmouseSensitivitySlider.removeEventListener('input', onMouseSensitivityChange);\n\tfovSlider.removeEventListener('input', onFOVChange);\n\tfovResetDefault.removeEventListener('click', resetFOVDefault);\n}\n\nfunction onMouseSensitivityChange(event: Event): void {\n\tconst value = Number((event.currentTarget as HTMLInputElement).value);\n\t// console.log(`Mouse sensitivity changed: ${value}`);\n\tsetMouseSensitivity(value);\n}\nfunction onFOVChange(event: Event): void {\n\tconst value = Number((event.currentTarget as HTMLInputElement).value);\n\t// console.log(`FOV changed: ${value}`);\n\tsetFOV(value);\n}\n\nfunction setMouseSensitivity(value: number): void {\n\tpreferences.setPerspectiveSensitivity(value);\n\tupdateMouseSensitivityOutput();\n}\nfunction setFOV(value: number): void {\n\tpreferences.setPerspectiveFOV(value);\n\tupdateFOVOutput();\n}\n\nfunction updateMouseSensitivityOutput(): void {\n\tconst value = Number(mouseSensitivitySlider.value);\n\tmouseSensitivityOutput.textContent = value + '%';\n}\nfunction updateFOVOutput(): void {\n\tconst value = Number(fovSlider.value);\n\tfovOutput.textContent = String(value);\n\tupdateFOVResetDefaultButton(value);\n}\nfunction updateFOVResetDefaultButton(value: number): void {\n\tif (value === preferences.getDefaultPerspectiveFOV())\n\t\tfovResetDefaultContainer.classList.add('hidden');\n\telse fovResetDefaultContainer.classList.remove('hidden');\n}\nfunction resetFOVDefault(): void {\n\tconst defaultFOV = preferences.getDefaultPerspectiveFOV();\n\tfovSlider.value = String(defaultFOV);\n\tsetFOV(defaultFOV);\n}\n\nexport default {\n\tinitListeners,\n\tcloseListeners,\n\tclose,\n\topen,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/components/header/dropdowns/sounddropdown.ts",
    "content": "// src/client/scripts/esm/components/header/dropdowns/sounddropdown.ts\n\n// This script manages the sound settings dropdown\n\nimport preferences from '../preferences.js';\n\n// Document Elements -------------------------------------------------------------------------\n\nconst settingsDropdown = document.querySelector('.settings-dropdown') as HTMLElement;\n\nconst soundDropdown = document.querySelector('.sound-dropdown') as HTMLElement;\nconst soundDropdownTitle = document.querySelector('.sound-dropdown .dropdown-title') as HTMLElement;\n\nconst masterVolumeSlider = document.querySelector(\n\t'.sound-options .master-volume .slider',\n) as HTMLInputElement;\n/** The text that displays the value */\nconst masterVolumeOutput = document.querySelector(\n\t'.sound-options .master-volume .value',\n) as HTMLElement;\n\nconst ambienceCheckbox = document.querySelector(\n\t'.boolean-option.ambience input',\n) as HTMLInputElement;\n\n// Functions ---------------------------------------------------------------------------------\n\n(function init(): void {\n\tsetInitialValues();\n})();\n\n/** Update the sliders and checkboxes according to the already existing preferences */\nfunction setInitialValues(): void {\n\tmasterVolumeSlider.value = String(preferences.getMasterVolume() * 100); // Preferences stores a value from 0 to 1\n\tupdateMasterVolumeOutput();\n\n\tambienceCheckbox.checked = preferences.getAmbienceEnabled();\n}\n\nfunction open(): void {\n\tsoundDropdown.classList.remove('visibility-hidden');\n\tinitListeners();\n\tsettingsDropdown.classList.add('transparent');\n}\nfunction close(): void {\n\tsoundDropdown.classList.add('visibility-hidden');\n\tcloseListeners();\n\tsettingsDropdown.classList.remove('transparent');\n}\n\nfunction initListeners(): void {\n\tsoundDropdownTitle.addEventListener('click', close);\n\tmasterVolumeSlider.addEventListener('input', onMasterVolumeChange);\n\tambienceCheckbox.addEventListener('click', toggleAmbience);\n}\nfunction closeListeners(): void {\n\tsoundDropdownTitle.removeEventListener('click', close);\n\tmasterVolumeSlider.removeEventListener('input', onMasterVolumeChange);\n\tambienceCheckbox.removeEventListener('click', toggleAmbience);\n}\n\nfunction onMasterVolumeChange(event: Event): void {\n\tconst value = Number((event.currentTarget as HTMLInputElement).value);\n\tpreferences.setMasterVolume(value / 100); // Preferences expects a value from 0 to 1\n\tupdateMasterVolumeOutput();\n}\n\nfunction toggleAmbience(): void {\n\tpreferences.setAmbienceEnabled(ambienceCheckbox.checked);\n}\n\nfunction updateMasterVolumeOutput(): void {\n\tconst value = Number(masterVolumeSlider.value);\n\tmasterVolumeOutput.textContent = value + '%';\n}\n\nexport default {\n\tinitListeners,\n\tcloseListeners,\n\tclose,\n\topen,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/components/header/faviconselector.ts",
    "content": "// src/client/scripts/esm/components/header/faviconselector.ts\n\n// This script auto detects device theme and adjusts the browser icon accordingly\n\nconst element_favicon = document.getElementById('favicon') as HTMLLinkElement;\n\n/** Switches the browser icon to match the given theme. */\nfunction switchFavicon(theme: 'dark' | 'light'): void {\n\tif (theme === 'dark') element_favicon.href = '/img/favicon/favicon-dark.png';\n\telse element_favicon.href = '/img/favicon/favicon-light.png';\n}\n\n// Don't create a theme-change event listener if matchMedia isn't supported.\nif (window.matchMedia) {\n\t// Initial theme detection\n\tconst prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)').matches;\n\tswitchFavicon(prefersDarkScheme ? 'dark' : 'light');\n\n\t// Listen for theme changes\n\twindow.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (event) => {\n\t\tconst newTheme = event.matches ? 'dark' : 'light';\n\t\tconsole.log(`Toggled ${newTheme} icon`);\n\t\tswitchFavicon(newTheme);\n\t});\n}\n\nexport default {};\n"
  },
  {
    "path": "src/client/scripts/esm/components/header/header.ts",
    "content": "// src/client/scripts/esm/components/header/header.ts\n\n// This script contains the code related to the\n// header that runs on every single page\n\nimport validatorama from '../../util/validatorama.js';\nimport languagedropdown from './dropdowns/languagedropdown.js';\n\nimport './spacing.js';\nimport './settings.js';\nimport './faviconselector.js';\nimport './currpage-greyer.js';\nimport './news-notification.js'; // Handles unread news badge\nimport '../../util/tooltips.js'; // This should be imported on EVERY page!\n\n// --------------------------------------------------------------------------------------\n\nconst loginLink = document.getElementById('login-link') as HTMLAnchorElement;\nconst loginText = document.getElementById('login')!;\nconst loginSVG = document.getElementById('svg-login')!;\nconst profileText = document.getElementById('profile')!;\nconst profileSVG = document.getElementById('svg-profile')!;\nconst createaccountLink = document.getElementById('createaccount-link') as HTMLAnchorElement;\nconst createaccountText = document.getElementById('createaccount')!;\nconst createaccountSVG = document.getElementById('svg-createaccount')!;\nconst logoutText = document.getElementById('logout')!;\nconst logoutSVG = document.getElementById('svg-logout')!;\n\n(function init() {\n\tinitListeners();\n\tupdateNavigationLinks(); // Do this once initially\n})();\n\nfunction initListeners(): void {\n\twindow.addEventListener('pageshow', updateNavigationLinks); // Fired on initial page load AND when hitting the back button to return.\n\tdocument.addEventListener('login', updateNavigationLinks); // Custom-event listener. Fired when the validator script receives a response from the server with either our access token or new browser-id cookie.\n\tdocument.addEventListener('logout', updateNavigationLinks); // Custom-event listener. Often fired when a web socket connection closes due to us logging out.\n}\n\n/**\n * Changes the navigation links, depending on if we're logged in, to\n * go to our Profile or the Log Out route, or the Log In / Create Account pages.\n */\nfunction updateNavigationLinks(): void {\n\tconst username = validatorama.getOurUsername();\n\tif (username) {\n\t\t// Logged in\n\t\tloginText.classList.add('hidden');\n\t\tloginSVG.classList.add('hidden');\n\t\tcreateaccountText.classList.add('hidden');\n\t\tcreateaccountSVG.classList.add('hidden');\n\t\tprofileText.classList.remove('hidden');\n\t\tprofileSVG.classList.remove('hidden');\n\t\tlogoutText.classList.remove('hidden');\n\t\tlogoutSVG.classList.remove('hidden');\n\n\t\tloginLink.href = languagedropdown.addLngQueryParamToLink(\n\t\t\t`/member/${username.toLowerCase()}`,\n\t\t);\n\t\tcreateaccountLink.href = languagedropdown.addLngQueryParamToLink('/logout');\n\t} else {\n\t\t// Not logged in\n\t\tprofileText.classList.add('hidden');\n\t\tprofileSVG.classList.add('hidden');\n\t\tlogoutSVG.classList.add('hidden');\n\t\tlogoutText.classList.add('hidden');\n\t\tloginText.classList.remove('hidden');\n\t\tloginSVG.classList.remove('hidden');\n\t\tcreateaccountText.classList.remove('hidden');\n\t\tcreateaccountSVG.classList.remove('hidden');\n\n\t\tloginLink.href = languagedropdown.addLngQueryParamToLink('/login');\n\t\tcreateaccountLink.href = languagedropdown.addLngQueryParamToLink('/createaccount');\n\t}\n\n\t// Manually dispatch a window resize event so that our javascript knows to\n\t// recalc the spacing/compactness of the header, as the items have changed their content.\n\tdocument.dispatchEvent(new CustomEvent('resize'));\n}\n\n// For every '.badge img' in the document, prevent long-press context menu\n// Specify <HTMLImageElement> so TS knows these are HTMLElements (which have 'contextmenu')\ndocument.querySelectorAll<HTMLImageElement>('.badge img').forEach((img) => {\n\t// The native definition of contextmenu is MouseEvent.\n\timg.addEventListener('contextmenu', (event: MouseEvent) => {\n\t\tif (!(event instanceof PointerEvent)) return;\n\t\t// Only prevent default if the context menu is triggered by touch or pen\n\t\tif (event.pointerType !== 'touch' && event.pointerType !== 'pen') return;\n\t\tconsole.log('Preventing context menu for badge image.');\n\t\tevent.preventDefault();\n\t});\n});\n\n// OVERRIDE the viewport height variable in header.css based on how\n// much screen space the home button bar takes up on mobile devices!\n// Just using 100vh is incorrect as the home button bar doesn't affect that.\nupdateViewportHeight();\nwindow.addEventListener('resize', () => updateViewportHeight());\nfunction updateViewportHeight(): void {\n\tdocument.documentElement.style.setProperty('--vh', `${window.innerHeight}px`);\n}\n"
  },
  {
    "path": "src/client/scripts/esm/components/header/news-notification.ts",
    "content": "// src/client/scripts/esm/components/header/news-notification.ts\n\n/**\n * This script handles the unread news notification badge in the header.\n * It fetches the count of unread news posts and displays a red circle badge\n * next to the News link when there are unread posts.\n */\n\nimport validatorama from '../../util/validatorama.js';\n\nconst newsLink = document.querySelector<HTMLAnchorElement>('a[href*=\"/news\"]');\nlet notificationBadge: HTMLSpanElement | null = null;\n\n/**\n * Creates and returns the notification badge element\n * @param count - The number of unread news posts\n */\nfunction createNotificationBadge(count: number): HTMLSpanElement {\n\tconst badge = document.createElement('span');\n\tbadge.className = 'news-notification-badge';\n\n\t// Display count as \"9+\" for 10 or more, otherwise show the number\n\tconst displayText = count >= 10 ? '9+' : count.toString();\n\tbadge.textContent = displayText;\n\n\tbadge.style.cssText = `\n\t\tposition: absolute;\n\t\ttop: 2px;\n\t\tright: 4px;\n\t\tbackground-color: #ff4444;\n\t\tcolor: white;\n\t\tborder-radius: 50%;\n\t\twidth: 16px;\n\t\theight: 16px;\n\t\tpadding: 0;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tfont-size: 10px;\n\t\tfont-weight: bold;\n\t\tline-height: 1;\n\t\tbox-shadow: 0 2px 4px rgba(0,0,0,0.3);\n\t\tpointer-events: none;\n\t`;\n\treturn badge;\n}\n\n/**\n * Fetches the unread news count from the server\n */\nasync function fetchUnreadNewsCount(): Promise<number> {\n\ttry {\n\t\tconst response = await fetch('/api/news/unread-count', {\n\t\t\theaders: {\n\t\t\t\t'is-fetch-request': 'true',\n\t\t\t},\n\t\t});\n\n\t\tif (!response.ok) {\n\t\t\tconsole.error('Failed to fetch unread news count');\n\t\t\treturn 0;\n\t\t}\n\n\t\tconst data = (await response.json()) as { count: number };\n\t\treturn data.count || 0;\n\t} catch (error) {\n\t\tconsole.error('Error fetching unread news count:', error);\n\t\treturn 0;\n\t}\n}\n\n/**\n * Updates the notification badge display\n */\nasync function updateNotificationBadge(): Promise<void> {\n\t// Only show badge if user is logged in\n\tconst username = validatorama.getOurUsername();\n\tif (!username) {\n\t\tremoveNotificationBadge();\n\t\treturn;\n\t}\n\n\tconst count = await fetchUnreadNewsCount();\n\n\tif (count > 0) {\n\t\tshowNotificationBadge(count);\n\t} else {\n\t\tremoveNotificationBadge();\n\t}\n}\n\n/**\n * Shows the notification badge with the given count\n * @param count - The number of unread news posts\n */\nfunction showNotificationBadge(count: number): void {\n\tif (!newsLink) {\n\t\treturn;\n\t}\n\n\tif (!notificationBadge) {\n\t\tnotificationBadge = createNotificationBadge(count);\n\t\tnewsLink.appendChild(notificationBadge);\n\t} else {\n\t\t// Update existing badge text\n\t\tconst displayText = count >= 10 ? '9+' : count.toString();\n\t\tnotificationBadge.textContent = displayText;\n\t}\n}\n\n/**\n * Removes the notification badge\n */\nfunction removeNotificationBadge(): void {\n\tif (notificationBadge && notificationBadge.parentNode) {\n\t\tnotificationBadge.remove();\n\t\tnotificationBadge = null;\n\t}\n}\n\n/**\n * Initializes the news notification feature\n */\nfunction init(): void {\n\tif (!newsLink) {\n\t\tconsole.warn('News link not found in header');\n\t\treturn;\n\t}\n\n\t// Update on page load\n\tupdateNotificationBadge();\n\n\t// Update when login state changes\n\tdocument.addEventListener('login', updateNotificationBadge);\n\tdocument.addEventListener('logout', () => removeNotificationBadge());\n\n\t// Listen for custom event when news is marked as read\n\tdocument.addEventListener('news-marked-read', () => {\n\t\tupdateNotificationBadge();\n\t});\n}\n\ninit();\n\nexport default {\n\tupdateNotificationBadge,\n\tremoveNotificationBadge,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/components/header/pingmeter.ts",
    "content": "// src/client/scripts/esm/components/header/pingmeter.ts\n\n/**\n * This script manages the display and updates of the ping meter.\n */\n\n// Document Elements -------------------------------------------------------------------------\n\nconst pingMeter = document.querySelector('.ping-meter')!;\nconst pingBars = document.querySelector('.ping-bars')!;\nconst pingValue = document.querySelector('.ping-value')!;\nconst loadingAnim = document.querySelector('.ping-meter .svg-pawn')!; // Spinning-pawn loading animation\n\n// Variables ---------------------------------------------------------------------------------\n\n// Functions ---------------------------------------------------------------------------------\n\n(function init() {\n\tinitEventListeners();\n})();\n\nfunction initEventListeners(): void {\n\tdocument.addEventListener('ping', updatePing); // Custom event. When we receive this event, we know we are connected\n\tdocument.addEventListener('socket-opening', openMeterAndDisplayLoading); // Custom event that is dispatched whenever we start trying to open a new socket upgrade connection request.\n\tdocument.addEventListener('connection-lost', openMeterAndDisplayLoading); // Custom event\n\tdocument.addEventListener('socket-closed', socketClosed); // Custom event\n}\n\nfunction updatePing(event: CustomEvent): void {\n\tshowPing_hideLoadingAnim();\n\tconst newPing = event.detail;\n\t// console.log(`New ping! ${newPing}`);\n\tpingValue.textContent = newPing;\n\tupdateBarCount(newPing);\n}\n\nfunction updateBarCount(ping: number): void {\n\tremoveAllColor();\n\tconst newBarCount = getBarCount(ping);\n\tconst color = newBarCount >= 3 ? 'green' : newBarCount === 2 ? 'yellow' : 'red';\n\tfor (let i = 1; i <= newBarCount; i++) {\n\t\tconst thisPingBar = pingBars.children[i - 1]!;\n\t\tthisPingBar.classList.add(color);\n\t}\n}\n\nfunction removeAllColor(): void {\n\tfor (let i = 1; i <= pingBars.children.length; i++) {\n\t\tconst thisPingBar = pingBars.children[i - 1]!;\n\t\tthisPingBar.classList.remove('green');\n\t\tthisPingBar.classList.remove('yellow');\n\t\tthisPingBar.classList.remove('red');\n\t}\n}\n\n/**\n * Returns the number of Bars that should be lit up according to the given ping.\n * This can be customized.\n */\nfunction getBarCount(ping: number): number {\n\tif (ping <= 150) return 4;\n\telse if (ping <= 300) return 3;\n\telse if (ping <= 550) return 2;\n\telse return 1;\n}\n\nfunction showPing_hideLoadingAnim(): void {\n\tpingMeter.classList.remove('hidden');\n\tpingBars.classList.remove('hidden');\n\tloadingAnim.classList.add('hidden');\n}\n\n/** Open meter. Hide the green bars, show the spinning-pawn loading animation, set the ping to ω */\nfunction openMeterAndDisplayLoading(): void {\n\tpingMeter.classList.remove('hidden'); // Reveals ping meter\n\tloadingAnim.classList.remove('hidden');\n\tpingBars.classList.add('hidden');\n\tpingValue.textContent = 'ω';\n}\n\n/**\n * A callback function that is executed when we receive the custom socket closed event.\n * 1. If the soccer was close by choice, we close the ping meter.\n * 2. If the socket was closed by bad network, we display the loading animation\n */\nfunction socketClosed(event: CustomEvent): void {\n\tconst notByChoise = event.detail; // This will be true if the user didn't intend to close the connection, they could have bad network.\n\n\tif (notByChoise)\n\t\topenMeterAndDisplayLoading(); // Hide the green bars, show the spinning-pawn loading animation\n\telse closeMeter(); // By choice. Just close the ping meter, we are no longer connected\n}\n\n/** Hides the ping meter from the settings dropdown document element */\nfunction closeMeter(): void {\n\tpingMeter.classList.add('hidden');\n\tloadingAnim.classList.remove('hidden');\n\tpingValue.textContent = '-';\n}\n\nexport default {};\n"
  },
  {
    "path": "src/client/scripts/esm/components/header/preferences.ts",
    "content": "// src/client/scripts/esm/components/header/preferences.ts\n\nimport type { Color } from '../../../../../shared/util/math/math.js';\n\nimport themes from '../../../../../shared/components/header/themes.js';\nimport jsutil from '../../../../../shared/util/jsutil.js';\nimport typeutil from '../../../../../shared/chess/util/typeutil.js';\nimport timeutil from '../../../../../shared/util/timeutil.js';\nimport pieceThemes, {\n\tPieceColorGroup,\n} from '../../../../../shared/components/header/pieceThemes.js';\n\nimport docutil from '../../util/docutil.js';\nimport LocalStorage from '../../util/LocalStorage.js';\nimport validatorama from '../../util/validatorama.js';\n\n/** Prefs that do NOT get saved on the server side */\nconst clientSidePrefs: string[] = [\n\t'perspective_sensitivity',\n\t'perspective_fov',\n\t'drag_enabled',\n\t'premove_enabled',\n\t'fast_transitions_enabled',\n\t'coordinates_enabled',\n\t'starfield_enabled',\n\t'advanced_effects_enabled',\n\t'master_volume',\n\t'ambience_enabled',\n];\ninterface ClientSidePreferences {\n\tperspective_sensitivity: number;\n\tperspective_fov: number;\n\tdrag_enabled: boolean;\n\tpremove_enabled: boolean;\n\tfast_transitions_enabled: boolean;\n\tcoordinates_enabled: boolean;\n\tstarfield_enabled: boolean;\n\tadvanced_effects_enabled: boolean;\n\t/** Master volume level from 0 (silent) to 1 (full volume) */\n\tmaster_volume: number;\n\tambience_enabled: boolean;\n\t[key: string]: any;\n}\n\ninterface ServerSidePreferences {\n\ttheme: string;\n\tlegal_moves: 'dots' | 'squares';\n\tanimations: boolean;\n\tlingering_annotations: boolean;\n}\n\n/** Both client and server side preferences */\ntype Preferences = ServerSidePreferences & ClientSidePreferences;\n\n// Variables ------------------------------------------------------------\n\n/** All our preferences. */\nlet preferences: Preferences;\n\n// The legal moves shape preference\nconst default_legal_moves: 'dots' | 'squares' = 'squares'; // dots/squares\nconst default_drag_enabled: boolean = true;\nconst default_premove_enabled: boolean = true;\nconst default_fast_transitions_enabled: boolean = false;\n/** When false, animations are instant, only playing the sound. (same as dropping dragged pieces) */\nconst default_animations: boolean = true;\nconst default_perspective_sensitivity: number = 100;\nconst default_perspective_fov: number = 90;\nconst default_lingering_annotations: boolean = false;\nconst default_coordinates_enabled: boolean = false;\nconst default_starfield_enabled: boolean = true;\nconst default_advanced_effects_enabled: boolean = true;\nconst default_master_volume: number = 1;\nconst default_ambience_enabled: boolean = true;\n\n/**\n * Whether a change was made to the preferences since the last time we sent them over to the server.\n * We only change this to true if we change a preference that isn't only client side.\n */\nlet changeWasMade: boolean = false;\n\n// Functions -----------------------------------------------------------------------\n\n(function init(): void {\n\tloadPreferences();\n})();\n\nfunction loadPreferences(): void {\n\tconst browserStoragePrefs: Preferences = LocalStorage.loadItem('preferences') || {\n\t\ttheme: themes.defaultTheme,\n\t\tlegal_moves: default_legal_moves,\n\t\tperspective_sensitivity: default_perspective_sensitivity,\n\t\tperspective_fov: default_perspective_fov,\n\t\tdrag_enabled: default_drag_enabled,\n\t\tpremove_enabled: default_premove_enabled,\n\t\tfast_transitions_enabled: default_fast_transitions_enabled,\n\t\tanimations: default_animations,\n\t\tlingering_annotations: default_lingering_annotations,\n\t\tcoordinates_enabled: default_coordinates_enabled,\n\t\tstarfield_enabled: default_starfield_enabled,\n\t\tadvanced_effects_enabled: default_advanced_effects_enabled,\n\t\tmaster_volume: default_master_volume,\n\t\tambience_enabled: default_ambience_enabled,\n\t};\n\n\tpreferences = browserStoragePrefs;\n\n\tconst cookiePrefs: string | undefined = docutil.getCookieValue('preferences');\n\tif (cookiePrefs) {\n\t\t// console.log(\"Preferences cookie was present!\");\n\t\tpreferences = JSON.parse(decodeURIComponent(cookiePrefs));\n\t\t// console.log(jsutil.deepCopyObject(preferences));\n\t\tclientSidePrefs.forEach((pref) => (preferences![pref] = browserStoragePrefs[pref]));\n\t}\n}\n\nfunction savePreferences(): void {\n\tconst oneYearInMillis: number = timeutil.getTotalMilliseconds({ years: 1 });\n\tLocalStorage.saveItem('preferences', preferences, oneYearInMillis);\n\n\t// After a delay, also send a post request to the server to update our preferences.\n\t// Auto send it if the window is closing\n}\n\nfunction onChangeMade(): void {\n\tchangeWasMade = true;\n\tvalidatorama.getAccessToken(); // Preload the access token so that we are ready to quickly save our preferences on the server if the page is unloaded\n}\n\nasync function sendPrefsToServer(): Promise<void> {\n\tif (!validatorama.areWeLoggedIn()) return; // Ensure user is logged in\n\tif (!changeWasMade) return; // Only send if preferences were changed\n\tchangeWasMade = false; // Reset the flag after sending\n\n\tconsole.log('Sending preferences to the server!');\n\tconst preparedPrefs: ServerSidePreferences = preparePrefs(); // Prepare the preferences to send\n\tPOSTPrefs(preparedPrefs);\n}\n\nasync function POSTPrefs(preparedPrefs: ServerSidePreferences): Promise<void> {\n\t// Configure the POST request\n\tconst config = {\n\t\tmethod: 'POST',\n\t\theaders: {\n\t\t\t'Content-Type': 'application/json',\n\t\t\t'is-fetch-request': 'true', // Custom header\n\t\t} as Record<string, string>,\n\t\tbody: JSON.stringify({ preferences: preparedPrefs }), // Send the preferences as JSON\n\t};\n\n\t// Get the access token and add it to the Authorization header\n\tconst token: string | undefined = await validatorama.getAccessToken();\n\tif (token) config.headers['Authorization'] = `Bearer ${token}`; // If you use tokens for authentication\n\n\ttry {\n\t\tconst response: Response = await fetch('/api/set-preferences', config);\n\n\t\t// Check if the response status code indicates success (e.g., 200-299 range)\n\t\tif (response.ok) {\n\t\t\tconsole.log('Preferences updated successfully on the server.');\n\t\t} else {\n\t\t\t// Handle unsuccessful response\n\t\t\tconst errorData: any = await response.json();\n\t\t\tconsole.error(\n\t\t\t\t'Failed to update preferences on the server:',\n\t\t\t\terrorData.message || errorData,\n\t\t\t);\n\t\t}\n\t} catch (error) {\n\t\tconsole.error('Error sending preferences to the server:', error);\n\t}\n}\n\nfunction preparePrefs(): ServerSidePreferences {\n\tconst prefsCopy: Preferences = jsutil.deepCopyObject(preferences);\n\tObject.keys(prefsCopy).forEach((prefName) => {\n\t\tif (clientSidePrefs.includes(prefName)) delete prefsCopy[prefName];\n\t});\n\t// console.log(`Original preferences: ${JSON.stringify(preferences)}`);\n\t// console.log(`Prepared preferences: ${JSON.stringify(prefsCopy)}`);\n\treturn prefsCopy;\n}\n\nfunction getTheme(): string {\n\treturn preferences.theme || themes.defaultTheme;\n}\n\nfunction setTheme(theme: string): void {\n\tpreferences.theme = theme;\n\tconsole.log('Set theme');\n\tonChangeMade();\n\tsavePreferences();\n}\n\nfunction getCoordinatesEnabled(): boolean {\n\treturn preferences.coordinates_enabled ?? default_coordinates_enabled;\n}\n\nfunction setCoordinatesEnabled(value: boolean): void {\n\tpreferences.coordinates_enabled = value;\n\tsavePreferences();\n}\n\nfunction getStarfieldMode(): boolean {\n\treturn preferences.starfield_enabled ?? default_starfield_enabled;\n}\n\nfunction setStarfieldMode(value: boolean): void {\n\tpreferences.starfield_enabled = value;\n\tsavePreferences();\n\n\t// Dispatch an event so that the game code can detect it, if present.\n\tdocument.dispatchEvent(new CustomEvent('starfield-toggle', { detail: value }));\n}\n\nfunction getLegalMovesShape(): 'dots' | 'squares' {\n\treturn preferences.legal_moves || default_legal_moves;\n}\n\nfunction setLegalMovesShape(legal_moves: 'dots' | 'squares'): void {\n\tif (typeof legal_moves !== 'string')\n\t\tthrow new Error('Cannot set preference legal_moves when it is not a string.');\n\tpreferences.legal_moves = legal_moves;\n\tonChangeMade();\n\tsavePreferences();\n}\n\nfunction getDragEnabled(): boolean {\n\treturn preferences.drag_enabled ?? default_drag_enabled;\n}\n\nfunction setDragEnabled(drag_enabled: boolean): void {\n\tif (typeof drag_enabled !== 'boolean')\n\t\tthrow new Error('Cannot set preference drag_enabled when it is not a boolean.');\n\tpreferences.drag_enabled = drag_enabled;\n\tsavePreferences();\n}\n\nfunction getPremoveEnabled(): boolean {\n\treturn preferences.premove_enabled ?? default_premove_enabled;\n}\n\nfunction setPremoveMode(value: boolean): void {\n\tpreferences.premove_enabled = value;\n\tsavePreferences();\n\n\t// Dispatch an event so that the game code can detect it, if present.\n\tdocument.dispatchEvent(new CustomEvent('premoves-toggle', { detail: value }));\n}\n\nfunction getFastTransitionsMode(): boolean {\n\treturn preferences.fast_transitions_enabled ?? default_fast_transitions_enabled;\n}\n\nfunction setFastTransitionsMode(value: boolean): void {\n\tpreferences.fast_transitions_enabled = value;\n\tsavePreferences();\n\n\t// Dispatch an event so that the game code can detect it, if present.\n\tdocument.dispatchEvent(new CustomEvent('fast-transitions-toggle', { detail: value }));\n}\n\nfunction getAnimationsMode(): boolean {\n\treturn preferences.animations ?? default_animations;\n}\n\nfunction setAnimationsMode(animations_enabled: boolean): void {\n\tpreferences.animations = animations_enabled;\n\tonChangeMade();\n\tsavePreferences();\n}\n\nfunction getPerspectiveSensitivity(): number {\n\treturn preferences.perspective_sensitivity || default_perspective_sensitivity;\n}\n\nfunction setPerspectiveSensitivity(perspective_sensitivity: number): void {\n\tif (typeof perspective_sensitivity !== 'number')\n\t\tthrow new Error('Cannot set preference perspective_sensitivity when it is not a number.');\n\tpreferences.perspective_sensitivity = perspective_sensitivity;\n\tsavePreferences();\n}\n\nfunction getPerspectiveFOV(): number {\n\treturn preferences.perspective_fov || default_perspective_fov;\n}\n\nfunction getDefaultPerspectiveFOV(): number {\n\treturn default_perspective_fov;\n}\n\nfunction setPerspectiveFOV(perspective_fov: number): void {\n\tif (typeof perspective_fov !== 'number')\n\t\tthrow new Error('Cannot set preference perspective_fov when it is not a number.');\n\tpreferences.perspective_fov = perspective_fov;\n\tsavePreferences();\n\tdocument.dispatchEvent(new CustomEvent('fov-change'));\n}\n\nfunction getLingeringAnnotationsMode(): boolean {\n\treturn preferences.lingering_annotations ?? default_lingering_annotations;\n}\n\nfunction setLingeringAnnotationsMode(value: boolean): void {\n\tpreferences.lingering_annotations = value;\n\tonChangeMade();\n\tsavePreferences();\n\n\t// Dispatch an event so that the game code can detect it, if present.\n\tdocument.dispatchEvent(new CustomEvent('lingering-annotations-toggle', { detail: value }));\n}\n\n/** Whether the user has enabled \"Advanced Effects\" in the settings. */\nfunction getAdvancedEffectsMode(): boolean {\n\treturn preferences.advanced_effects_enabled ?? default_advanced_effects_enabled;\n}\n\nfunction setAdvancedEffectsMode(value: boolean): void {\n\tpreferences.advanced_effects_enabled = value;\n\tsavePreferences();\n}\n\nfunction getMasterVolume(): number {\n\treturn preferences.master_volume ?? default_master_volume;\n}\n\nfunction setMasterVolume(master_volume: number): void {\n\tif (typeof master_volume !== 'number')\n\t\tthrow new Error('Cannot set preference master_volume when it is not a number.');\n\tif (master_volume > 1) throw new Error('Cannot set master_volume > 1!');\n\tpreferences.master_volume = master_volume;\n\tsavePreferences();\n\n\t// Dispatch an event so that the game code can detect it, if present.\n\tdocument.dispatchEvent(new CustomEvent('master-volume-change', { detail: master_volume }));\n}\n\n/** Whether the user has enabled \"Ambience\" in the settings. */\nfunction getAmbienceEnabled(): boolean {\n\treturn preferences.ambience_enabled ?? default_ambience_enabled;\n}\n\nfunction setAmbienceEnabled(ambience_enabled: boolean): void {\n\tif (typeof ambience_enabled !== 'boolean')\n\t\tthrow new Error('Cannot set preference ambience_enabled when it is not a boolean.');\n\tpreferences.ambience_enabled = ambience_enabled;\n\tsavePreferences();\n\n\t// Dispatch an event so that the game code can detect it, if present.\n\tdocument.dispatchEvent(new CustomEvent('ambience-toggle', { detail: ambience_enabled }));\n}\n\n// Getters for our current theme properties --------------------------------------------------------\n\nfunction getColorOfLightTiles(): Color {\n\tconst themeName: string = getTheme();\n\treturn themes.getPropertyOfTheme(themeName, 'lightTiles');\n}\n\nfunction getColorOfDarkTiles(): Color {\n\tconst themeName: string = getTheme();\n\treturn themes.getPropertyOfTheme(themeName, 'darkTiles');\n}\n\nfunction getLegalMoveHighlightColor({\n\tisOpponentPiece,\n\tisPremove,\n}: {\n\tisOpponentPiece: boolean;\n\tisPremove: boolean;\n}): Color {\n\tconst themeName: string = getTheme();\n\tif (isOpponentPiece)\n\t\treturn themes.getPropertyOfTheme(themeName, 'legalMovesHighlightColor_Opponent');\n\telse if (isPremove)\n\t\treturn themes.getPropertyOfTheme(themeName, 'legalMovesHighlightColor_Premove');\n\telse return themes.getPropertyOfTheme(themeName, 'legalMovesHighlightColor_Friendly');\n}\n\nfunction getLastMoveHighlightColor(): Color {\n\tconst themeName: string = getTheme();\n\treturn themes.getPropertyOfTheme(themeName, 'lastMoveHighlightColor');\n}\n\nfunction getCheckHighlightColor(): Color {\n\tconst themeName: string = getTheme();\n\treturn themes.getPropertyOfTheme(themeName, 'checkHighlightColor');\n}\n\nfunction getBoxOutlineColor(): Color {\n\tconst themeName: string = getTheme();\n\treturn themes.getPropertyOfTheme(themeName, 'boxOutlineColor');\n}\n\nfunction getAnnoteSquareColor(): Color {\n\tconst themeName: string = getTheme();\n\treturn themes.getPropertyOfTheme(themeName, 'annoteSquareColor');\n}\n\nfunction getAnnoteArrowColor(): Color {\n\tconst themeName: string = getTheme();\n\treturn themes.getPropertyOfTheme(themeName, 'annoteArrowColor');\n}\n\n/** Returns the tint color for a piece of the given type, according to our current theme. */\nfunction getTintColorOfType(type: number): Color {\n\tconst [r, p] = typeutil.splitType(type);\n\n\tconst baseColor: Color = pieceThemes.getBaseColorForType(r, p);\n\n\tconst themeName: string = getTheme();\n\tconst themePieceColors: Partial<PieceColorGroup> = themes.getPropertyOfTheme(\n\t\tthemeName,\n\t\t'pieceTheme',\n\t);\n\tconst tint: Color = themePieceColors[p] ?? [1, 1, 1, 1];\n\n\t// Multiply the colors together to get the final color\n\treturn [\n\t\tbaseColor[0] * tint[0],\n\t\tbaseColor[1] * tint[1],\n\t\tbaseColor[2] * tint[2],\n\t\tbaseColor[3] * tint[3],\n\t];\n}\n\n// /**\n//  * Determines the theme based on the current date.\n//  * @returns {string} The theme for the current date ('halloween', 'christmas', or 'default').\n//  */\n// function getHollidayTheme() {\n// \tif (timeutil.isCurrentDateWithinRange(10, 25, 10, 31)) return 'halloween'; // Halloween week (October 25 to 31)\n// \t// if (timeutil.isCurrentDateWithinRange(11, 23, 11, 29)) return 'thanksgiving'; // Thanksgiving week (November 23 to 29)\n// \tif (timeutil.isCurrentDateWithinRange(12, 19, 12, 25)) return 'christmas'; // Christmas week (December 19 to 25)\n// \treturn themes.defaultTheme; // Default theme if not in a holiday week\n// }\n\n/*\n * The commented stuff below is ONLY used for fast\n * modifying of theme players using the keyboard keys!\n */\n\n// import { listener_document } from \"../../game/chess/game.js\";\n\n// const allProperties = Object.keys(themes.themes[themes.defaultTheme]!);\n// let currPropertyIndex = 0;\n// let currProperty = allProperties[currPropertyIndex]!;\n// function update() {\n\n// \tconst themeProperties = themes.themes[preferences.theme]!;\n\n// \tif (listener_document.isKeyDown('KeyU')) {\n// \t\tcurrPropertyIndex--;\n// \t\tif (currPropertyIndex < 0) currPropertyIndex = allProperties.length - 1;\n// \t\tcurrProperty = allProperties[currPropertyIndex]!;\n// \t\tconsole.log(`Selected property: ${currProperty}`);\n// \t}\n// \tif (listener_document.isKeyDown('KeyI')) {\n// \t\tcurrPropertyIndex++;\n// \t\tif (currPropertyIndex > allProperties.length - 1) currPropertyIndex = 0;\n// \t\tcurrProperty = allProperties[currPropertyIndex]!;\n// \t\tconsole.log(`Selected property: ${currProperty}`);\n// \t}\n\n// \tconst amount = 0.02;\n\n// \tif (listener_document.isKeyDown('KeyJ')) {\n// \t\tconst dig = 0;\n// \t\t// @ts-ignore\n// \t\tthemeProperties[currProperty][dig] += amount;\n// \t\t// @ts-ignore\n// \t\tif (themeProperties[currProperty][dig] > 1) themeProperties[currProperty][dig] = 1;\n// \t\t// @ts-ignore\n// \t\tconsole.log(themeProperties[currProperty]);\n// \t}\n// \tif (listener_document.isKeyDown('KeyM')) {\n// \t\tconst dig = 0;\n// \t\t// @ts-ignore\n// \t\tthemeProperties[currProperty][dig] -= amount;\n// \t\t// @ts-ignore\n// \t\tif (themeProperties[currProperty][dig] < 0) themeProperties[currProperty][dig] = 0;\n// \t\t// @ts-ignore\n// \t\tconsole.log(themeProperties[currProperty]);\n// \t}\n\n// \tif (listener_document.isKeyDown('KeyK')) {\n// \t\tconst dig = 1;\n// \t\t// @ts-ignore\n// \t\tthemeProperties[currProperty][dig] += amount;\n// \t\t// @ts-ignore\n// \t\tif (themeProperties[currProperty][dig] > 1) themeProperties[currProperty][dig] = 1;\n// \t\t// @ts-ignore\n// \t\tconsole.log(themeProperties[currProperty]);\n// \t}\n// \tif (listener_document.isKeyDown('Comma')) {\n// \t\tconst dig = 1;\n// \t\t// @ts-ignore\n// \t\tthemeProperties[currProperty][dig] -= amount;\n// \t\t// @ts-ignore\n// \t\tif (themeProperties[currProperty][dig] < 0) themeProperties[currProperty][dig] = 0;\n// \t\t// @ts-ignore\n// \t\tconsole.log(themeProperties[currProperty]);\n// \t}\n\n// \tif (listener_document.isKeyDown('KeyL')) {\n// \t\tconst dig = 2;\n// \t\t// @ts-ignore\n// \t\tthemeProperties[currProperty][dig] += amount;\n// \t\t// @ts-ignore\n// \t\tif (themeProperties[currProperty][dig] > 1) themeProperties[currProperty][dig] = 1;\n// \t\t// @ts-ignore\n// \t\tconsole.log(themeProperties[currProperty]);\n// \t}\n// \tif (listener_document.isKeyDown('Period')) {\n// \t\tconst dig = 2;\n// \t\t// @ts-ignore\n// \t\tthemeProperties[currProperty][dig] -= amount;\n// \t\t// @ts-ignore\n// \t\tif (themeProperties[currProperty][dig] < 0) themeProperties[currProperty][dig] = 0;\n// \t\t// @ts-ignore\n// \t\tconsole.log(themeProperties[currProperty]);\n// \t}\n\n// \tif (listener_document.isKeyDown('Semicolon')) {\n// \t\tconst dig = 3;\n// \t\t// @ts-ignore\n// \t\tthemeProperties[currProperty][dig] += amount;\n// \t\t// @ts-ignore\n// \t\tif (themeProperties[currProperty][dig] > 1) themeProperties[currProperty][dig] = 1;\n// \t\t// @ts-ignore\n// \t\tconsole.log(themeProperties[currProperty]);\n// \t}\n// \tif (listener_document.isKeyDown('Slash')) {\n// \t\tconst dig = 3;\n// \t\t// @ts-ignore\n// \t\tthemeProperties[currProperty][dig] -= amount;\n// \t\t// @ts-ignore\n// \t\tif (themeProperties[currProperty][dig] < 0) themeProperties[currProperty][dig] = 0;\n// \t\t// @ts-ignore\n// \t\tconsole.log(themeProperties[currProperty]);\n// \t}\n\n// \tif (listener_document.isKeyDown('Backslash')) {\n// \t\tconsole.log(JSON.stringify(themes.themes[preferences.theme]));\n// \t}\n\n// }\n\n// function dispatchThemeChangeEvent() {\n// \tdocument.dispatchEvent(new Event('theme-change'));\n// }\n// setInterval(dispatchThemeChangeEvent, 1000);\n\n// Exports -----------------------------------------------------------------------------------------\n\nexport default {\n\tgetTheme,\n\tsetTheme,\n\tgetCoordinatesEnabled,\n\tsetCoordinatesEnabled,\n\tgetStarfieldMode,\n\tsetStarfieldMode,\n\tgetLegalMovesShape,\n\tsetLegalMovesShape,\n\tgetDragEnabled,\n\tsetDragEnabled,\n\tgetPremoveEnabled,\n\tsetPremoveMode,\n\tgetFastTransitionsMode,\n\tsetFastTransitionsMode,\n\tgetAnimationsMode,\n\tsetAnimationsMode,\n\tgetPerspectiveSensitivity,\n\tsetPerspectiveSensitivity,\n\tgetPerspectiveFOV,\n\tgetDefaultPerspectiveFOV,\n\tsetPerspectiveFOV,\n\tgetLingeringAnnotationsMode,\n\tsetLingeringAnnotationsMode,\n\tgetAdvancedEffectsMode,\n\tsetAdvancedEffectsMode,\n\tgetMasterVolume,\n\tsetMasterVolume,\n\tgetAmbienceEnabled,\n\tsetAmbienceEnabled,\n\tsendPrefsToServer,\n\tgetColorOfLightTiles,\n\tgetColorOfDarkTiles,\n\tgetLegalMoveHighlightColor,\n\tgetLastMoveHighlightColor,\n\tgetCheckHighlightColor,\n\tgetBoxOutlineColor,\n\tgetAnnoteSquareColor,\n\tgetAnnoteArrowColor,\n\tgetTintColorOfType,\n\n\t// Only used for temporarily micro adjusting theme properties & colors\n\t// update,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/components/header/settings.ts",
    "content": "// src/client/scripts/esm/components/header/settings.ts\n\n// This script opens and closes our settings drop-down menu when it is clicked.\n\nimport math from '../../../../../shared/util/math/math.js';\nimport themes from '../../../../../shared/components/header/themes.js';\n\nimport style from '../../game/gui/style.js';\nimport preferences from './preferences.js';\nimport sounddropdown from './dropdowns/sounddropdown.js';\nimport languagedropdown from './dropdowns/languagedropdown.js';\nimport gameplaydropdown from './dropdowns/gameplaydropdown.js';\nimport legalmovedropdown from './dropdowns/legalmovedropdown.js';\nimport appearancedropdown from './dropdowns/appearancedropdown.js';\nimport perspectivedropdown from './dropdowns/perspectivedropdown.js';\n\nimport './pingmeter.js'; // Only imported so its code runs\n\n// Document Elements -------------------------------------------------------------------------\n\n// Main settings dropdown\nconst settings = document.getElementById('settings')!;\nconst settingsDropdown = document.querySelector('.settings-dropdown')!;\n\n// All buttons to open nested dropdowns\nconst languageDropdownSelection = document.getElementById('language-settings-dropdown-item')!;\nconst appearanceDropdownSelection = document.getElementById('appearance-settings-dropdown-item')!;\nconst legalmoveDropdownSelection = document.getElementById('legalmove-settings-dropdown-item')!;\nconst mouseDropdownSelection = document.getElementById('perspective-settings-dropdown-item')!;\nconst gameplayDropdownSelection = document.getElementById('gameplay-settings-dropdown-item')!;\nconst soundDropdownSelection = document.getElementById('sound-settings-dropdown-item')!;\n\n// All nested dropdowns\nconst languageDropdown = document.querySelector('.language-dropdown')!;\nconst appearanceDropdown = document.querySelector('.appearance-dropdown')!;\nconst legalmoveDropdown = document.querySelector('.legalmove-dropdown')!;\nconst perspectiveDropdown = document.querySelector('.perspective-dropdown')!;\nconst gameplayDropdown = document.querySelector('.gameplay-dropdown')!;\nconst soundDropdown = document.querySelector('.sound-dropdown')!;\nconst allSettingsDropdownsExceptMainOne = [\n\tlanguageDropdown,\n\tappearanceDropdown,\n\tlegalmoveDropdown,\n\tperspectiveDropdown,\n\tgameplayDropdown,\n\tsoundDropdown,\n];\n\n// Variables ---------------------------------------------------------------------------------\n\nconst allSettingsDropdowns = [...allSettingsDropdownsExceptMainOne, settingsDropdown];\nlet settingsIsOpen = settings.classList.contains('open');\n\n// Functions ---------------------------------------------------------------------------------\n\n(function init() {\n\tsettings.addEventListener('click', (event) => {\n\t\tif (didEventClickAnyDropdown(event)) return; // We clicked any dropdown, don't toggle it off\n\t\ttoggleSettingsDropdown();\n\t});\n\n\t// Close the dropdown if clicking outside of it\n\tdocument.addEventListener('click', closeSettingsDropdownIfClickedAway);\n\tdocument.addEventListener('touchstart', closeSettingsDropdownIfClickedAway);\n\n\tupdateBackgroundColor();\n\tdocument.addEventListener('theme-change', updateBackgroundColor);\n\n\t// [DEBUGGING] Instantly open the settings dropdown on page refresh\n\t// openSettingsDropdown();\n})();\n\nfunction toggleSettingsDropdown(): void {\n\tif (settingsIsOpen) closeAllSettingsDropdowns();\n\telse openSettingsDropdown();\n}\nfunction openSettingsDropdown(): void {\n\t// Opens the initial settings dropdown\n\tsettings.classList.add('open');\n\tsettingsDropdown.classList.remove('visibility-hidden'); // The stylesheet adds a short delay animation to when it becomes hidden\n\tinitSettingsListeners();\n\tsettingsIsOpen = true;\n}\nfunction closeAllSettingsDropdowns(): void {\n\t// Closes all dropdowns that may be open\n\tsettings.classList.remove('open');\n\tcloseMainSettingsDropdown();\n\tcloseAllSettingsDropdownsExceptMainOne();\n\tsettingsIsOpen = false;\n}\nfunction closeMainSettingsDropdown(): void {\n\tsettingsDropdown.classList.add('visibility-hidden'); // The stylesheet adds a short delay animation to when it becomes hidden\n\tcloseSettingsListeners();\n\tpreferences.sendPrefsToServer();\n}\nfunction closeAllSettingsDropdownsExceptMainOne(): void {\n\tlanguagedropdown.close();\n\tappearancedropdown.close();\n\tlegalmovedropdown.close();\n\tgameplaydropdown.close();\n\tperspectivedropdown.close();\n\tsounddropdown.close();\n}\n\nfunction initSettingsListeners(): void {\n\tlanguageDropdownSelection.addEventListener('click', closeAllSettingsDropdownsExceptMainOne);\n\tlanguageDropdownSelection.addEventListener('click', languagedropdown.open);\n\tappearanceDropdownSelection.addEventListener('click', closeAllSettingsDropdownsExceptMainOne);\n\tappearanceDropdownSelection.addEventListener('click', appearancedropdown.open);\n\tlegalmoveDropdownSelection.addEventListener('click', closeAllSettingsDropdownsExceptMainOne);\n\tlegalmoveDropdownSelection.addEventListener('click', legalmovedropdown.open);\n\tmouseDropdownSelection.addEventListener('click', closeAllSettingsDropdownsExceptMainOne);\n\tmouseDropdownSelection.addEventListener('click', perspectivedropdown.open);\n\tgameplayDropdownSelection.addEventListener('click', closeAllSettingsDropdownsExceptMainOne);\n\tgameplayDropdownSelection.addEventListener('click', gameplaydropdown.open);\n\tsoundDropdownSelection.addEventListener('click', closeAllSettingsDropdownsExceptMainOne);\n\tsoundDropdownSelection.addEventListener('click', sounddropdown.open);\n}\nfunction closeSettingsListeners(): void {\n\tlanguageDropdownSelection.removeEventListener('click', closeAllSettingsDropdownsExceptMainOne);\n\tlanguageDropdownSelection.removeEventListener('click', languagedropdown.open);\n\tappearanceDropdownSelection.removeEventListener(\n\t\t'click',\n\t\tcloseAllSettingsDropdownsExceptMainOne,\n\t);\n\tappearanceDropdownSelection.removeEventListener('click', appearancedropdown.open);\n\tlegalmoveDropdownSelection.removeEventListener('click', closeAllSettingsDropdownsExceptMainOne);\n\tlegalmoveDropdownSelection.removeEventListener('click', legalmovedropdown.open);\n\tmouseDropdownSelection.removeEventListener('click', closeAllSettingsDropdownsExceptMainOne);\n\tmouseDropdownSelection.removeEventListener('click', perspectivedropdown.open);\n\tgameplayDropdownSelection.removeEventListener('click', closeAllSettingsDropdownsExceptMainOne);\n\tgameplayDropdownSelection.removeEventListener('click', gameplaydropdown.open);\n\tsoundDropdownSelection.removeEventListener('click', closeAllSettingsDropdownsExceptMainOne);\n\tsoundDropdownSelection.removeEventListener('click', sounddropdown.open);\n}\n\nfunction closeSettingsDropdownIfClickedAway(event: MouseEvent | TouchEvent): void {\n\t// Check if it is actually a Node before using .contains\n\tif (\n\t\tevent.target instanceof Node &&\n\t\t!settings.contains(event.target) &&\n\t\t!didEventClickAnyDropdown(event)\n\t) {\n\t\tcloseAllSettingsDropdowns();\n\t}\n}\nfunction didEventClickAnyDropdown(event: MouseEvent | TouchEvent): boolean {\n\t// Check if the click was outside the dropdown\n\tlet clickedDropdown = false;\n\tallSettingsDropdowns.forEach((dropdown) => {\n\t\tif (event.target instanceof Node && dropdown.contains(event.target)) clickedDropdown = true;\n\t});\n\treturn clickedDropdown;\n}\n\n/** Updates the stylesheet colors --background-theme-color and --switch-on-color based on the current theme. */\nfunction updateBackgroundColor(): void {\n\tconst theme = preferences.getTheme();\n\tconst lightTiles = themes.getPropertyOfTheme(theme, 'lightTiles');\n\tconst darkTiles = themes.getPropertyOfTheme(theme, 'darkTiles');\n\n\tconst AvgR = (lightTiles[0] + darkTiles[0]) / 2;\n\tconst AvgG = (lightTiles[1] + darkTiles[1]) / 2;\n\tconst AvgB = (lightTiles[2] + darkTiles[2]) / 2;\n\n\tconst switchR = AvgR * 255;\n\tconst switchG = AvgG * 255;\n\tconst switchB = AvgB * 255;\n\n\tconst cssSwitch = style.rgbToCssString(switchR, switchG, switchB);\n\n\t// Also set the --background-theme-color property, which is just a slightly brightened version!\n\t// The board editor uses this for the background of selected tools.\n\n\t// Convert to HSL Color\n\tconst backgroundHSL = style.rgbToHsl(switchR, switchG, switchB);\n\n\t// Brighten by 5%\n\tbackgroundHSL.l += 0.05;\n\t// Min lightness of 0.6 (Prevent dark themes from making accent colors too dark)\n\tbackgroundHSL.l = math.clamp(backgroundHSL.l, 0.6, 1);\n\n\t// Create CSS string\n\tconst cssBackground = style.hslToCssString(backgroundHSL);\n\n\t// Set CSS properties\n\n\tconst root = document.documentElement;\n\troot.style.setProperty('--switch-on-color', cssSwitch);\n\troot.style.setProperty('--background-theme-color', cssBackground);\n}\n\nexport default {};\n"
  },
  {
    "path": "src/client/scripts/esm/components/header/spacing.ts",
    "content": "// src/client/scripts/esm/components/header/spacing.ts\n\n// Spacing: This script handles the spacing of our header elements at various screen widths\n\nconst header = document.querySelector('header')!;\nconst home = document.querySelector('.home')!; // \"Infinite Chess\" text\nconst nav = document.querySelector('nav')!;\nconst links = document.querySelectorAll('nav a');\n// Paddings allowed between each of our header links (right of logo & left of gear)\nconst maxPadding = parseInt(\n\tgetComputedStyle(document.documentElement).getPropertyValue('--header-link-max-padding'),\n);\nconst minPadding = parseInt(\n\tgetComputedStyle(document.documentElement).getPropertyValue('--header-link-min-padding'),\n);\n// const gear = document.querySelector('.settings');\n\n// These things are hidden in our stylesheet off the bat to give our javascript\n// here time to calculate the spacing of everything before rendering\nfor (const child of header.children) child.classList.remove('visibility-hidden');\n\nlet compactnessLevel = 0;\n\nupdateSpacing(); // Initial spacing on page load\nwindow.addEventListener('resize', updateSpacing); // Continuous spacing on page-resizing\n\nfunction updateSpacing(): void {\n\t// Reset to least compact, so that we can measure if each stage fits.\n\t// If it doesn't, we go down to the next compact stage\n\tcompactnessLevel = 0;\n\tupdateMode();\n\tupdatePadding();\n\n\tlet spaceBetween = getSpaceBetweenHeaderFlexElements();\n\n\twhile (spaceBetween === 0 && compactnessLevel < 4) {\n\t\tcompactnessLevel++;\n\t\tupdateMode();\n\t\tupdatePadding();\n\t\tspaceBetween = getSpaceBetweenHeaderFlexElements(); // Recalculate space after adjusting compactness and padding\n\t}\n}\n\n/**\n * Updates the left-right padding of the navigation links (right of logo and left of gear)\n * according to how much space is available.\n */\nfunction updatePadding(): void {\n\tconst spaceBetween = getSpaceBetweenHeaderFlexElements();\n\n\t// If the space is less than 100px, reduce padding gradually\n\tif (spaceBetween >= 100) {\n\t\t// Reset to max padding when space is larger than 100px\n\t\tlinks.forEach((link) => {\n\t\t\tif (!(link instanceof HTMLElement)) return;\n\t\t\tlink.style.paddingLeft = `${maxPadding}px`;\n\t\t\tlink.style.paddingRight = `${maxPadding}px`;\n\t\t});\n\t} else {\n\t\tconst newPadding = Math.max(minPadding, maxPadding * (spaceBetween / 100));\n\t\tlinks.forEach((link) => {\n\t\t\tif (!(link instanceof HTMLElement)) return;\n\t\t\tlink.style.paddingLeft = `${newPadding}px`;\n\t\t\tlink.style.paddingRight = `${newPadding}px`;\n\t\t});\n\t}\n}\n\nfunction updateMode(): void {\n\tif (compactnessLevel === 0) {\n\t\thome.classList.remove('compact-1'); // Show the \"Infinite Chess\" text\n\t\tnav.classList.remove('compact-2'); // Show the navigation SVGs\n\t\tnav.classList.remove('compact-3'); // Show the navigation TEXT\n\t} else if (compactnessLevel === 1) {\n\t\thome.classList.add('compact-1'); // Hide the \"Infinite Chess\" text\n\t\tnav.classList.remove('compact-2'); // Show the navigation SVGs\n\t\tnav.classList.remove('compact-3'); // Show the navigation TEXT\n\t} else if (compactnessLevel === 2) {\n\t\thome.classList.add('compact-1'); // Hide the \"Infinite Chess\" text\n\t\tnav.classList.add('compact-2'); // Hide the navigation SVGs\n\t\tnav.classList.remove('compact-3'); // Show the navigation TEXT\n\t} else if (compactnessLevel === 3) {\n\t\thome.classList.add('compact-1'); // Hide the \"Infinite Chess\" text\n\t\tnav.classList.remove('compact-2'); // Show the navigation SVGs\n\t\tnav.classList.add('compact-3'); // Hide the navigation TEXT\n\t}\n}\n\nfunction getSpaceBetweenHeaderFlexElements(): number {\n\tconst homeRight = home.getBoundingClientRect().right;\n\tconst navLeft = nav.getBoundingClientRect().left;\n\treturn navLeft - homeRight;\n}\n\nexport default {};\n"
  },
  {
    "path": "src/client/scripts/esm/game/GameBus.ts",
    "content": "// src/client/scripts/esm/game/GameBus.ts\n\nimport type { Piece } from '../../../../shared/chess/util/boardutil';\nimport type { LegalMoves } from '../../../../shared/chess/logic/legalmoves';\n\nimport { EventBus } from '../../../../shared/util/EventBus';\n\ninterface GameBusEvents {\n\t// =========== Logical Events ============\n\t'game-loaded': void;\n\t'game-unloaded': void;\n\t/** Dispatched when games end, and the termination is shown on screen. */\n\t'game-concluded': void;\n\t'piece-selected': { piece: Piece; legalMoves: LegalMoves };\n\t'piece-unselected': void;\n\t// /** Dispatched immediately before legal move generation. */\n\t// 'pre-move-gen': {\n\t// \tgamefile: FullGame;\n\t// \tpiece: Piece;\n\t// \t/** Mod scripts should define this if they would like to totally override normal legal move gen. */\n\t// \tmoveOverrides: LegalMoves | undefined;\n\t// };\n\t// /** Dispatched immediately after legal move gen. Mods may add additional legal moves. */\n\t// 'post-move-gen': { gamefile: FullGame; piece: Piece; legalMoves: LegalMoves };\n\t/** Dispatched when a physical (not premove or simulated) move is made by us, NOT our opponent. */\n\t'user-move-played': void;\n\t/** Dispatched when a physical move is made on the board by any player, even our own premoves, or making a board editor edit. */\n\t'physical-move': void;\n\t// =========== Graphical Events ===========\n\t'render-below-pieces': void;\n\t'render-above-pieces': void;\n}\n\nexport const GameBus: EventBus<GameBusEvents> = new EventBus<GameBusEvents>();\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/actions/eactions.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/actions/eactions.ts\n\n/**\n * Editor Actions\n *\n * Contains handlers for the one-time action buttons on the Board Editor UI, such as:\n *\n * * Reset position\n * * Clear position\n * * Saved positions\n * * Copy notation\n * * Paste notation\n * * Game rules\n * * Start local game from position\n */\n\nimport type { Edit } from '../../../../../../shared/chess/logic/movepiece';\nimport type { VariantOptions } from '../../../../../../shared/chess/logic/initvariant';\nimport type { EngineUIConfig } from '../../gui/boardeditor/actions/guistartenginegame';\nimport type { EditorSaveState } from '../editortypes';\nimport type { MetaData, MovePacket } from '../../../../../../shared/types.js';\nimport type { EnPassant, GlobalGameState } from '../../../../../../shared/chess/logic/state';\nimport type { ActivePosition, StorageType } from '../boardeditor';\n\nimport bimath from '../../../../../../shared/util/math/bimath';\nimport variant from '../../../../../../shared/chess/variants/variant';\nimport typeutil from '../../../../../../shared/chess/util/typeutil';\nimport movepiece from '../../../../../../shared/chess/logic/movepiece';\nimport checkdetection from '../../../../../../shared/chess/logic/checkdetection';\nimport boardutil, { Piece } from '../../../../../../shared/chess/util/boardutil';\nimport coordutil, { Coords, CoordsKey } from '../../../../../../shared/chess/util/coordutil';\nimport organizedpieces, {\n\tOrganizedPieces,\n} from '../../../../../../shared/chess/logic/organizedpieces';\nimport gamefile, {\n\tAdditional,\n\tBoard,\n\tFullGame,\n} from '../../../../../../shared/chess/logic/gamefile';\nimport icnconverter, {\n\tMoveParsed,\n\tLongFormatIn,\n\tLongFormatOut,\n} from '../../../../../../shared/chess/logic/icn/icnconverter';\n\nimport toast from '../../gui/toast';\nimport docutil from '../../../util/docutil';\nimport gameslot from '../../chess/gameslot';\nimport pastegame from '../../chess/pastegame';\nimport gameloader from '../../chess/gameloader';\nimport egamerules from '../egamerules';\nimport annotations from '../../rendering/highlights/annotations/annotations';\nimport boardeditor from '../boardeditor';\nimport edithistory from '../edithistory';\nimport validatorama from '../../../util/validatorama';\nimport guinavigation from '../../gui/guinavigation';\nimport selectiontool from '../tools/selection/selectiontool';\nimport hydrochess_card from '../../chess/engines/enginecards/hydrochess_card';\nimport clientmetadatautil from '../../chess/clientmetadatautil';\nimport { engineDictionary } from '../../chess/engines/engine';\nimport gamecompressor, { SimplifiedGameState } from '../../chess/gamecompressor';\n\n// Constants ----------------------------------------------------------------------\n\n/**\n * If a position with less pieces than this is pasted, the position dependent\n * game rules (pawnDoublePush, castling) are accurately updated,\n * else they are set to undetermined.\n */\nconst PIECE_LIMIT_KEEP_TRACK_OF_GLOBAL_SPECIAL_RIGHTS = 2_000_000;\n\n// Actions ----------------------------------------------------------------------\n\n/** Resets the board editor position to the Classical position. */\nasync function reset(): Promise<void> {\n\tif (!boardeditor.areInBoardEditor()) return;\n\n\t// Unload logical and rendering parts of current position\n\tgameloader.unloadLogicalAndRendering();\n\n\t// Load default board editor position\n\tboardeditor.clearActivePosition();\n\tawait gameloader.startBoardEditor();\n}\n\n/** Clears the entire board editor position. */\nasync function clearAll(): Promise<void> {\n\tif (!boardeditor.areInBoardEditor()) return;\n\n\t// Unload logical and rendering parts of current position\n\tgameloader.unloadLogicalAndRendering();\n\n\t// Initialize board editor with empty position and bare minimum game rules\n\tconst gameRules = variant.getBareMinimumGameRules();\n\tconst position: Map<CoordsKey, number> = new Map();\n\tconst specialRights: Set<CoordsKey> = new Set();\n\tconst state_global: GlobalGameState = { specialRights };\n\tconst variantOptions: VariantOptions = {\n\t\tfullMove: 1,\n\t\tgameRules,\n\t\tposition,\n\t\tstate_global,\n\t};\n\n\tboardeditor.clearActivePosition();\n\tawait gameloader.startBoardEditorFromCustomPosition(\n\t\t{\n\t\t\tadditional: {\n\t\t\t\tvariantOptions,\n\t\t\t},\n\t\t},\n\t\ttrue, // Dirty position (unsaved changes)\n\t\tfalse,\n\t);\n}\n\n/** Loads a position from a savestate. */\nasync function load(editorSaveState: EditorSaveState, storage_type: StorageType): Promise<void> {\n\tif (!boardeditor.areInBoardEditor()) return;\n\n\t// Unload logical and rendering parts of current position\n\tgameloader.unloadLogicalAndRendering();\n\n\t// prettier-ignore\n\tconst new_active_position: ActivePosition =\n\t\tstorage_type === 'cloud'\n\t\t\t? { name: editorSaveState.position_name, storage_type: 'cloud', owner: validatorama.getOurUsername()! }\n\t\t\t: { name: editorSaveState.position_name, storage_type: 'local' };\n\tboardeditor.setActivePosition(new_active_position);\n\n\tawait gameloader.startBoardEditorFromCustomPosition(\n\t\t{\n\t\t\tadditional: {\n\t\t\t\tvariantOptions: editorSaveState.variantOptions,\n\t\t\t},\n\t\t},\n\t\tfalse, // Clean position (no unsaved changes) since we're loading one that was already saved\n\t\teditorSaveState.pawnDoublePush,\n\t\teditorSaveState.castling,\n\t);\n\ttoast.show(translations.editor.position_loaded);\n}\n\n/**\n * copygame uses the move list instead of the position\n * which doesn't work for the board editor.\n * This function uses the position of pieces on the board.\n */\nfunction copy(): void {\n\tif (!boardeditor.areInBoardEditor()) return;\n\n\tconst variantOptions = getCurrentPositionInformation(false);\n\tconst LongFormatIn: LongFormatIn = {\n\t\tmetadata:\n\t\t\t{} as MetaData /** Empty metadata, in order to make copied codes easier to share */,\n\t\t...variantOptions,\n\t};\n\tconst shortFormatOut = icnconverter.LongToShort_Format(LongFormatIn, {\n\t\tskipPosition: false,\n\t\tcompact: true,\n\t\tspaces: false,\n\t\tcomments: false,\n\t\tmake_new_lines: false,\n\t\tmove_numbers: false,\n\t});\n\tdocutil.copyToClipboard(shortFormatOut);\n\ttoast.show(translations.copypaste.copied_position);\n}\n\n/** Loads the position from the clipboard. */\nasync function paste(): Promise<undefined> {\n\tif (!boardeditor.areInBoardEditor()) return;\n\n\tlet longformOut: LongFormatOut;\n\n\t// Do we have clipboard permission?\n\tlet clipboard: string;\n\ttry {\n\t\tclipboard = await navigator.clipboard.readText();\n\t} catch (error) {\n\t\tconst message: string = translations.copypaste.clipboard_denied;\n\t\ttoast.show(message + '\\n' + error, { error: true });\n\t\treturn;\n\t}\n\n\t// Convert clipboard text to longformat\n\ttry {\n\t\tlongformOut = icnconverter.ShortToLong_Format(clipboard);\n\t} catch (e) {\n\t\tconsole.error(e);\n\t\ttoast.show(translations.copypaste.clipboard_invalid, { error: true });\n\t\treturn;\n\t}\n\n\tloadFromLongformat(longformOut);\n\tselectiontool.resetState(); // Clear current selection\n\ttoast.show(translations.copypaste.loaded_position_from_clipboard);\n}\n\n/** Starts a local game from the current board editor position, to test play. */\nfunction startLocalGame(): void {\n\tif (!boardeditor.areInBoardEditor()) return;\n\n\tconst variantOptions = getCurrentPositionInformation(true);\n\tif (isPositionIllegal(variantOptions)) {\n\t\ttoast.show(translations.editor.illegal_position_king_capture, { error: true });\n\t\treturn;\n\t}\n\tif (variantOptions.position.size === 0) {\n\t\ttoast.show(translations.editor.cannot_start_local_empty, { error: true });\n\t\treturn;\n\t}\n\n\tgameloader.unloadGame();\n\tgameloader.startCustomLocalGame({\n\t\tadditional: {\n\t\t\tvariantOptions,\n\t\t},\n\t});\n}\n\nfunction startEngineGame(engineUIConfig: EngineUIConfig): void {\n\tif (!boardeditor.areInBoardEditor()) return;\n\n\tconst currentEngine = 'hydrochess';\n\n\t// Get current position\n\tconst variantOptions = getCurrentPositionInformation(true);\n\tif (isPositionIllegal(variantOptions)) {\n\t\ttoast.show(translations.editor.illegal_position_king_capture, { error: true });\n\t\treturn;\n\t}\n\n\t// Determine whether it's not supported...\n\n\tif (variantOptions.position.size === 0) {\n\t\ttoast.show(translations.editor.cannot_start_engine_empty, { error: true });\n\t\treturn;\n\t}\n\n\t// Set world border automatically, if wished\n\tif (engineUIConfig.setDefaultWorldBorder) {\n\t\t// Calculate minimum bounding box of all pieces\n\t\tconst bb = boardutil.getBoundingBoxOfAllPieces(gameslot.getGamefile()!.boardsim.pieces)!; // Guaranteed defined since above we check if there's > 0 pieces\n\n\t\t/*\n\t\t * Priority:\n\t\t * 1. Default distance\n\t\t * 2. Capped at engine's cap\n\t\t */\n\n\t\tconst worldBorderProperty = engineDictionary[currentEngine].worldBorder;\n\t\tconst cap = hydrochess_card.BORDER_CAP;\n\n\t\t// How far can we extend in each direction before hitting ±limit?\n\t\tconst availableLeft = bb.left + cap;\n\t\tconst availableRight = cap - bb.right;\n\t\tconst availableBottom = bb.bottom + cap;\n\t\tconst availableTop = cap - bb.top;\n\n\t\t// Calculate separate limiting distances for horizontal and vertical axes\n\t\tconst availableHorz = bimath.min(availableLeft, availableRight);\n\t\tconst availableVert = bimath.min(availableBottom, availableTop);\n\n\t\t// Use the minimum between the default and the capped\n\t\tconst distHorz = bimath.min(worldBorderProperty, availableHorz);\n\t\tconst distVert = bimath.min(worldBorderProperty, availableVert);\n\n\t\tvariantOptions.gameRules.worldBorder = {\n\t\t\tleft: bb.left - distHorz,\n\t\t\tright: bb.right + distHorz,\n\t\t\tbottom: bb.bottom - distVert,\n\t\t\ttop: bb.top + distVert,\n\t\t};\n\t}\n\n\t// Does the engine support the position and settings?\n\tconst supported_result = hydrochess_card.isPositionSupported(variantOptions);\n\tif (!supported_result.supported) {\n\t\ttoast.show(`${translations.editor.position_not_supported} ${supported_result.reason}`, {\n\t\t\terror: true,\n\t\t});\n\t\treturn;\n\t}\n\n\tgameloader.unloadGame();\n\tgameloader.startCustomEngineGame({\n\t\ttimeControl: engineUIConfig.timeControl,\n\t\tadditional: {\n\t\t\tvariantOptions,\n\t\t},\n\t\tyouAreColor: engineUIConfig.youAreColor,\n\t\tcurrentEngine,\n\t\tengineConfig: {\n\t\t\tengineTimeLimitPerMoveMillis:\n\t\t\t\tengineDictionary[currentEngine].defaultTimeLimitPerMoveMillis,\n\t\t\tstrengthLevel: engineUIConfig.strengthLevel,\n\t\t},\n\t});\n}\n\n// Helpers ----------------------------------------------------------------\n\n/**\n * Returns true if the current editor position is illegal to start a checkmate game from,\n * because the 2nd player to move is already in check on turn 1 — meaning the 1st player\n * could immediately capture their royal piece, which can only happen in illegal positions.\n */\nfunction isPositionIllegal(variantOptions: VariantOptions): boolean {\n\t// Only applicable when checkmate is used by any player\n\tconst checkmateUsed = Object.values(variantOptions.gameRules.winConditions).some((conds) =>\n\t\tconds.includes('checkmate'),\n\t);\n\tif (!checkmateUsed) return false; // King capture legal in non-checkmate variants\n\n\t// The 2nd player to move is the one whose royal could be captured on the 1st move\n\tconst secondPlayer = variantOptions.gameRules.turnOrder[1];\n\tif (secondPlayer === undefined) return false; // Umm why did this happen?\n\n\tconst result = checkdetection.detectCheck(gameslot.getGamefile()!, secondPlayer);\n\treturn result.check; // Illegal position (allows king capture)\n}\n\n/** Queues the removal of all pieces from the position. */\nfunction queueRemovalOfAllPieces(gamefile: FullGame, edit: Edit, pieces: OrganizedPieces): void {\n\tfor (const idx of pieces.coords.values()) {\n\t\tconst pieceToDelete: Piece = boardutil.getDefinedPieceFromIdx(pieces, idx)!;\n\t\tedithistory.queueRemovePiece(gamefile, edit, pieceToDelete);\n\t}\n}\n\n/**\n * Reconstructs the current VariantOptions object (including position, gameRules and state_global) from the current board editor position\n * @param revokeRedundantRights - If true, special rights of pieces that no longer have a valid castling partner are revoked.\n */\nfunction getCurrentPositionInformation(revokeRedundantRights: boolean): VariantOptions {\n\t// Get current game rules and state\n\tconst { gameRules, moveRuleState, enpassantcoords } = egamerules.getCurrentGamerulesAndState();\n\n\t// Construct position\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst position = organizedpieces.generatePositionFromPieces(gamefile.boardsim.pieces);\n\n\t// Construct state_global\n\n\tconst specialRights = new Set(gamefile.boardsim.state.global.specialRights); // Makes a copy so we don't modify the original belonging to the current gamefile\n\tif (revokeRedundantRights) revokeRedundantSpecialRights(gamefile.boardsim, specialRights);\n\n\tlet enpassant: EnPassant | undefined;\n\tif (enpassantcoords !== undefined) {\n\t\tconst playerToMove = egamerules.getPlayerToMove();\n\t\t// prettier-ignore\n\t\tconst pawn: Coords = playerToMove === 'white' ? [enpassantcoords[0], enpassantcoords[1] - 1n] : playerToMove === 'black' ? [enpassantcoords[0], enpassantcoords[1] + 1n] : (() => { throw new Error(\"Invalid player to move\"); })(); // Future protection\n\t\tenpassant = { square: enpassantcoords, pawn };\n\t}\n\tconst state_global: GlobalGameState = {\n\t\tspecialRights,\n\t\tmoveRuleState,\n\t\tenpassant,\n\t};\n\n\t// Construct VariantOptions\n\tconst variantOptions: VariantOptions = {\n\t\tfullMove: 1,\n\t\tgameRules,\n\t\tposition,\n\t\tstate_global,\n\t};\n\n\treturn variantOptions;\n}\n\n/**\n * Revokes special rights from pieces that no longer have a valid castling partner.\n * MUTATES the input specialRights set.\n * @param boardsim\n * @param specialRights - MUST be a copy of the gamefile's specialRights set! This will be mutated, NOT the gamefile's internal one.\n */\nfunction revokeRedundantSpecialRights(boardsim: Board, specialRights: Set<CoordsKey>): void {\n\t// Iterate through each piece with special rights, and remove them if they don't have a valid castling partner\n\tfor (const coordsKey of specialRights) {\n\t\tconst candidate = boardutil.getPieceFromCoordsKey(boardsim.pieces, coordsKey)!; // Guaranteed defined because it wouldn't be in specialRights otherwise\n\n\t\tconst rawType = typeutil.getRawType(candidate.type);\n\t\tif (egamerules.pawnDoublePushTypes.includes(rawType)) continue; // Pawns can't castle\n\n\t\tconst hasValidCastlingPartner = movepiece.hasCastlingPartner(boardsim, candidate);\n\t\tif (!hasValidCastlingPartner) specialRights.delete(coordsKey);\n\t}\n}\n\n/**\n * pastegame loads in a new position by creating a new gamefile and loading it\n * which doesn't work for the board editor.\n * This function simply applies an edit to the position of the pieces on the board.\n * @param longformat - If this optional parameter is defined, it is used as the position to load instead of getting the position from the clipboard\n */\nasync function loadFromLongformat(longformOut: LongFormatIn): Promise<void> {\n\t// Resolve variant code from the ICN metadata, normalizing it to the English display name.\n\tconst resolvedVariantCode = variant.resolveAndNormalizeVariantInMetadata(longformOut.metadata);\n\tconst timestamp = clientmetadatautil.resolveTimestampFromMetadata(\n\t\tlongformOut.metadata.UTCDate,\n\t\tlongformOut.metadata.UTCTime,\n\t);\n\n\tlet { position, specialRights } = pastegame.getPositionAndSpecialRightsFromLongFormat(\n\t\tlongformOut,\n\t\tresolvedVariantCode,\n\t\ttimestamp,\n\t);\n\tlet stateGlobal = longformOut.state_global;\n\n\t// If longformat contains moves, then we construct a FullGame object and use it to fast forward to the final position\n\t// If it contains no moves, then we skip all that, thus saving time\n\tif (longformOut.moves && longformOut.moves.length !== 0) {\n\t\tconst state_global = { ...longformOut.state_global, specialRights };\n\t\tconst variantOptions: VariantOptions = {\n\t\t\tposition,\n\t\t\tstate_global,\n\t\t\tfullMove: longformOut.fullMove,\n\t\t\tgameRules: longformOut.gameRules,\n\t\t};\n\t\tconst additional: Additional = {\n\t\t\tvariantOptions,\n\t\t\tmoves: longformOut.moves.map((m: MoveParsed) => {\n\t\t\t\tconst move: MovePacket = { token: m.token };\n\t\t\t\treturn move;\n\t\t\t}),\n\t\t};\n\t\tconst loadedGamefile = gamefile.initFullGame(\n\t\t\tlongformOut.metadata,\n\t\t\ttimestamp,\n\t\t\tresolvedVariantCode,\n\t\t\tadditional,\n\t\t);\n\t\tconst gamestate: SimplifiedGameState = {\n\t\t\tposition,\n\t\t\tstate_global,\n\t\t\tfullMove: longformOut.fullMove,\n\t\t\tturnOrder: longformOut.gameRules.turnOrder,\n\t\t};\n\t\tconst new_gamestate = gamecompressor.GameToPosition(\n\t\t\tgamestate,\n\t\t\tloadedGamefile.boardsim.moves,\n\t\t\tloadedGamefile.boardsim.moves.length,\n\t\t);\n\t\tposition = new_gamestate.position;\n\t\tspecialRights = new_gamestate.state_global.specialRights!;\n\t\tstateGlobal = new_gamestate.state_global;\n\t}\n\n\tconst thisGamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh()!;\n\tconst pieces = thisGamefile.boardsim.pieces;\n\tconst edit: Edit = { changes: [], state: { local: [], global: [] } };\n\n\t// Remove all current pieces from position\n\tqueueRemovalOfAllPieces(thisGamefile, edit, pieces);\n\n\tconst keepTrackOfGlobalSpecialRights =\n\t\tposition.size < PIECE_LIMIT_KEEP_TRACK_OF_GLOBAL_SPECIAL_RIGHTS;\n\tlet pawnDoublePush: boolean | undefined = undefined;\n\tlet castling: boolean | undefined = undefined;\n\n\t// Add all new pieces as dictated by the pasted position\n\tlet all_pawns_have_double_push = true;\n\tlet at_least_one_pawn_has_double_push = false;\n\tlet all_pieces_obey_normal_castling = true;\n\tlet at_least_one_piece_obeys_normal_castling = false;\n\tfor (const [coordKey, pieceType] of position.entries()) {\n\t\tconst coords = coordutil.getCoordsFromKey(coordKey);\n\t\tconst hasSpecialRights = specialRights.has(coordKey);\n\t\tedithistory.queueAddPiece(thisGamefile, edit, coords, pieceType, hasSpecialRights);\n\n\t\tif (!keepTrackOfGlobalSpecialRights) continue; // One if statement cost is very tiny per iteration\n\n\t\tconst rawtype = typeutil.getRawType(pieceType);\n\t\tif (egamerules.pawnDoublePushTypes.includes(rawtype)) {\n\t\t\tif (hasSpecialRights) at_least_one_pawn_has_double_push = true;\n\t\t\telse all_pawns_have_double_push = false;\n\t\t} else if (egamerules.castlingTypes.includes(rawtype)) {\n\t\t\tif (hasSpecialRights) at_least_one_piece_obeys_normal_castling = true;\n\t\t\telse all_pieces_obey_normal_castling = false;\n\t\t} else if (hasSpecialRights) {\n\t\t\tat_least_one_piece_obeys_normal_castling = true;\n\t\t\tall_pieces_obey_normal_castling = false;\n\t\t}\n\t}\n\n\tif (keepTrackOfGlobalSpecialRights) {\n\t\t// prettier-ignore\n\t\tpawnDoublePush = at_least_one_pawn_has_double_push ? (all_pawns_have_double_push ? true : undefined) : false;\n\t\t// prettier-ignore\n\t\tcastling = at_least_one_piece_obeys_normal_castling ? (all_pieces_obey_normal_castling ? true : undefined) : false;\n\t}\n\n\tegamerules.setGamerulesGUIinfo(longformOut.gameRules, stateGlobal, pawnDoublePush, castling); // Set gamerules object according to pasted game\n\n\tedithistory.runEdit(thisGamefile, mesh, edit, true);\n\tedithistory.addEditToHistory(edit);\n\tannotations.resetState(); // Clear all annotations\n\n\tguinavigation.callback_Expand(); // Virtually press the \"Expand to fit all\" button after position is loaded\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\treset,\n\tclearAll,\n\tload,\n\tcopy,\n\tpaste,\n\tstartLocalGame,\n\tstartEngineGame,\n\tgetCurrentPositionInformation,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/actions/eautosave.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/actions/eautosave.ts\n\n/**\n * This script handles autosaving the board editor position\n * It autosaves periodically, but only if the position is dirty, aka if it has changed since last time.\n */\n\nimport type { EditorAutosaveState } from '../editortypes';\n\nimport eactions from './eactions';\nimport IndexedDB from '../../../util/IndexedDB';\nimport egamerules from '../egamerules';\nimport boardeditor from '../boardeditor';\nimport editortypes from '../editortypes';\nimport validatorama from '../../../util/validatorama';\n\n// Constants -------------------------------------------------------------\n\n/** Name of editor autosave in local storage */\nconst EDITOR_AUTOSAVE_NAME = 'editor-autosave';\n\n// Variables --------------------------------------------------------------\n\n/** Number of milliseconds for period of position autosave */\nconst positionAutosaveIntervalMillis = 10000;\n\n/** Interval object for position autosave */\nlet positionAutosaveTimer: number | undefined;\n\n/** Prevent overlapping IndexedDB writes (single-flight): is autosave ongoing */\nlet positionAutosaveInFlight = false;\n/** Prevent overlapping IndexedDB writes (single-flight): is autosave pending */\nlet positionAutosavePending = false;\n\n/** Track whether anything changed since last save */\nlet positionDirty = true;\n\n// Functions --------------------------------------------------------------\n\n/**\n * Mark position as needing save.\n * This is called when the position or the game rules change.\n */\nfunction markPositionDirty(): void {\n\tpositionDirty = true;\n}\n\n/** Auto saves the board editor position once. */\nasync function autosaveCurrentPositionOnce(): Promise<void> {\n\t// Track dirtiness: skip unnecessary writes that don't change anything\n\tif (!positionDirty) return;\n\n\t// Coalesce: if a save is already running, request another and return.\n\tif (positionAutosaveInFlight) {\n\t\tpositionAutosavePending = true;\n\t\treturn;\n\t}\n\n\tpositionAutosaveInFlight = true;\n\tpositionAutosavePending = false;\n\n\ttry {\n\t\tconst variantOptions = eactions.getCurrentPositionInformation(false);\n\t\tconst { pawnDoublePush, castling } = egamerules.getPositionDependentGameRules();\n\n\t\tawait IndexedDB.saveItem(EDITOR_AUTOSAVE_NAME, {\n\t\t\tactive_position: boardeditor.getActivePosition(),\n\t\t\tdirty: boardeditor.isPositionDirty(),\n\t\t\ttimestamp: Date.now(),\n\t\t\tpiece_count: variantOptions.position.size,\n\t\t\tvariantOptions,\n\t\t\tpawnDoublePush,\n\t\t\tcastling,\n\t\t} satisfies EditorAutosaveState);\n\n\t\tpositionDirty = false;\n\t} catch (err) {\n\t\t// Don't crash the editor over failed autosave\n\t\tconsole.error('Failed to autosave board editor position:', err);\n\t} finally {\n\t\tpositionAutosaveInFlight = false;\n\n\t\t// If something changed while saving, immediately save again (latest wins).\n\t\tif (positionAutosavePending) {\n\t\t\tpositionAutosavePending = false;\n\t\t\t// Mark dirty because we want to flush latest state.\n\t\t\tpositionDirty = true;\n\t\t\t// Fire and forget; caller doesn't need to await.\n\t\t\tvoid autosaveCurrentPositionOnce();\n\t\t}\n\t}\n}\n\n/** Initialize new autosave interval */\nfunction startPositionAutosave(): void {\n\tstopPositionAutosave(); // Stop existing interval if we opened a new save\n\n\t// Do an initial save after init (for safety)\n\tpositionDirty = true;\n\tvoid autosaveCurrentPositionOnce();\n\n\tpositionAutosaveTimer = window.setInterval(() => {\n\t\t// Don't save if editor is closed mid-tick\n\t\tif (!boardeditor.areInBoardEditor()) return;\n\n\t\tvoid autosaveCurrentPositionOnce();\n\t}, positionAutosaveIntervalMillis);\n}\n\n/** Kill running autosave interval */\nfunction stopPositionAutosave(): void {\n\tif (positionAutosaveTimer !== undefined) {\n\t\tclearInterval(positionAutosaveTimer);\n\t\tpositionAutosaveTimer = undefined;\n\t}\n}\n\nfunction clearAutosave(): void {\n\tIndexedDB.deleteItem(EDITOR_AUTOSAVE_NAME).catch((err) => {\n\t\tconsole.error('Failed to clear board editor autosave:', err);\n\t});\n}\n\n/**\n * Reads and validates the autosave from IndexedDB.\n * Returns undefined if no autosave exists.\n * Clears and returns undefined if the data is corrupted or if the active\n * position is a cloud save owned by a different user (e.g. after logout or\n * account switch).\n */\nasync function loadAutosave(): Promise<EditorAutosaveState | undefined> {\n\tconst raw = await IndexedDB.loadItem(EDITOR_AUTOSAVE_NAME);\n\tif (raw === undefined) return undefined;\n\tconst parsed = editortypes.AutosaveStateSchema.safeParse(raw);\n\tif (!parsed.success) {\n\t\tconsole.error('Corrupted board editor autosave data found, clearing autosave.');\n\t\tclearAutosave();\n\t\treturn undefined;\n\t}\n\n\t// If the autosave belongs to a cloud save owned by a different user, discard it.\n\t// Prevents accidentally trying to save the posiiton to a user that isn't logged in.\n\tconst ap = parsed.data.active_position;\n\tif (ap?.storage_type === 'cloud' && ap.owner !== validatorama.getOurUsername()) {\n\t\tconsole.log('Clearing editor auto save from a different user.');\n\t\tclearAutosave();\n\t\treturn undefined;\n\t}\n\treturn parsed.data;\n}\n\nexport default {\n\tmarkPositionDirty,\n\tstartPositionAutosave,\n\tautosaveCurrentPositionOnce,\n\tstopPositionAutosave,\n\tclearAutosave,\n\tloadAutosave,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/actions/ecloud.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/actions/ecloud.ts\n\n/**\n * Handles cloud (server) save/load operations for the board editor.\n * Mirrors esave.ts for cloud storage.\n */\n\nimport type { MetaData } from '../../../../../../shared/types';\nimport type { LongFormatIn } from '../../../../../../shared/chess/logic/icn/icnconverter';\nimport type { VariantOptions } from '../../../../../../shared/chess/logic/initvariant';\nimport type { EditorSaveState } from '../editortypes';\nimport type { CloudPositionRecord, CloudSaveListRecord } from './editorSavesAPI';\n\nimport editorutil from '../../../../../../shared/util/editorutil';\nimport icnconverter from '../../../../../../shared/chess/logic/icn/icnconverter';\n\nimport toast from '../../gui/toast';\nimport esave from './esave';\nimport eactions from './eactions';\nimport eautosave from './eautosave';\nimport egamerules from '../egamerules';\nimport compression from '../../../util/compression';\nimport boardeditor from '../boardeditor';\nimport validatorama from '../../../util/validatorama';\nimport editorSavesAPI from './editorSavesAPI';\n\n// Actions ----------------------------------------------------------------------\n\n/**\n * Parses a CloudPositionRecord into an EditorSaveState, decompressing the ICN\n * if necessary.\n * @returns An EditorSaveState on success, undefined on failure (errors are toasted internally).\n */\nasync function parseCloudPosition(\n\tposition_name: string,\n\tcloudPosition: CloudPositionRecord,\n): Promise<EditorSaveState | undefined> {\n\tlet icn: string;\n\ttry {\n\t\ticn = await compression.decompressString(cloudPosition.icn, cloudPosition.compression);\n\t} catch (err) {\n\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\tconsole.error('Failed to decompress cloud position ICN:', err);\n\t\ttoast.show(`${translations.editor.failed_to_load} ${errMsg}`, { error: true });\n\t\treturn undefined;\n\t}\n\n\tlet longFormOut;\n\ttry {\n\t\tlongFormOut = icnconverter.ShortToLong_Format(icn);\n\t} catch (err) {\n\t\tconsole.error('Failed to parse cloud position ICN:', err);\n\t\ttoast.show(translations.editor.position_corrupted, { error: true });\n\t\treturn;\n\t}\n\tconst variantOptions: VariantOptions = {\n\t\tposition: longFormOut.position ?? new Map(),\n\t\tgameRules: longFormOut.gameRules,\n\t\tstate_global: {\n\t\t\t...longFormOut.state_global,\n\t\t\tspecialRights: longFormOut.state_global.specialRights ?? new Set(),\n\t\t},\n\t\tfullMove: longFormOut.fullMove,\n\t};\n\treturn {\n\t\tposition_name,\n\t\ttimestamp: cloudPosition.timestamp,\n\t\tpiece_count: variantOptions.position.size,\n\t\tvariantOptions,\n\t\tpawnDoublePush: cloudPosition.pawn_double_push,\n\t\tcastling: cloudPosition.castling,\n\t};\n}\n\n/**\n * Converts an EditorSaveState to ICN and uploads it to the cloud.\n * Does NOT modify local storage or the active position state.\n * @returns `{ success: true, saves }` on success, `{ success: false }` on failure (errors are toasted internally).\n */\nasync function saveCloudState(\n\teditorSaveState: EditorSaveState,\n): Promise<{ success: true; saves: CloudSaveListRecord[] } | { success: false }> {\n\t// Convert variantOptions to ICN\n\tconst longFormatIn: LongFormatIn = {\n\t\tmetadata: {} as MetaData, // Empty metadata object required by ICN converter\n\t\tposition: editorSaveState.variantOptions.position,\n\t\tgameRules: editorSaveState.variantOptions.gameRules,\n\t\tstate_global: editorSaveState.variantOptions.state_global,\n\t\tfullMove: editorSaveState.variantOptions.fullMove ?? 1,\n\t};\n\tlet icn: string;\n\ttry {\n\t\ticn = icnconverter.LongToShort_Format(longFormatIn, {\n\t\t\tskipPosition: false,\n\t\t\tcompact: true,\n\t\t\tspaces: false,\n\t\t\tcomments: false,\n\t\t\tmake_new_lines: false,\n\t\t\tmove_numbers: false,\n\t\t});\n\t} catch (err) {\n\t\tconsole.error('Failed to convert position to ICN:', err);\n\t\ttoast.show(translations.editor.failed_to_convert_icn, { error: true });\n\t\treturn { success: false };\n\t}\n\n\t// Compress ICN first\n\tconst { data: compressedICN, compression: compressionMode } =\n\t\tawait compression.compressString(icn);\n\n\tif (compressedICN.length > editorutil.MAX_ICN_LENGTH) {\n\t\ttoast.show(translations.editor.too_large_for_cloud, { error: true });\n\t\treturn { success: false };\n\t}\n\n\tlet saves: CloudSaveListRecord[];\n\ttry {\n\t\tsaves = await editorSavesAPI.savePosition(\n\t\t\teditorSaveState.position_name,\n\t\t\teditorSaveState.piece_count,\n\t\t\teditorSaveState.timestamp,\n\t\t\tcompressedICN,\n\t\t\tcompressionMode,\n\t\t\teditorSaveState.pawnDoublePush,\n\t\t\teditorSaveState.castling,\n\t\t);\n\t} catch (err) {\n\t\tconsole.error('Failed to upload position to cloud:', err);\n\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\ttoast.show(translations.editor.failed_to_upload + ' ' + errMsg, { error: true });\n\t\treturn { success: false };\n\t}\n\n\ttoast.show(translations.editor.saved_to_cloud);\n\treturn { success: true, saves };\n}\n\n/**\n * Uploads the currently loaded editor position to the cloud,\n * saving over whatever is already there.\n * Reads live game state instead of local storage.\n */\nasync function saveCloud(position_name: string): Promise<void> {\n\tif (!boardeditor.isPositionDirty()) {\n\t\ttoast.show(translations.editor.no_changes);\n\t\treturn;\n\t}\n\n\tconst variantOptions = eactions.getCurrentPositionInformation(false);\n\tconst { pawnDoublePush, castling } = egamerules.getPositionDependentGameRules();\n\tconst timestamp = Date.now();\n\tconst piece_count = variantOptions.position.size;\n\n\tconst editorSaveState: EditorSaveState = {\n\t\tposition_name,\n\t\ttimestamp,\n\t\tpiece_count,\n\t\tvariantOptions,\n\t\tpawnDoublePush,\n\t\tcastling,\n\t};\n\n\tconst result = await saveCloudState(editorSaveState);\n\tif (result.success) {\n\t\tboardeditor.markPositionClean();\n\t\teautosave.markPositionDirty();\n\t\tvoid eautosave.autosaveCurrentPositionOnce();\n\t}\n}\n\n/**\n * Downloads a position from the server.\n * @returns An EditorSaveState on success, undefined on failure.\n */\nasync function readCloud(position_name: string): Promise<EditorSaveState | undefined> {\n\tlet cloudPosition: CloudPositionRecord;\n\ttry {\n\t\tcloudPosition = await editorSavesAPI.getPosition(position_name);\n\t} catch (err) {\n\t\tconsole.error('Failed to load cloud position:', err);\n\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\ttoast.show(translations.editor.failed_to_load_cloud + ' ' + errMsg, { error: true });\n\t\treturn;\n\t}\n\treturn parseCloudPosition(position_name, cloudPosition);\n}\n\n/**\n * Deletes a position from the server.\n * @returns The updated cloud saves list on success, undefined on failure.\n */\nasync function deleteCloud(position_name: string): Promise<CloudSaveListRecord[] | undefined> {\n\ttry {\n\t\treturn await editorSavesAPI.deletePosition(position_name);\n\t} catch (err) {\n\t\tconsole.error('Failed to delete cloud position:', err);\n\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\ttoast.show(translations.editor.failed_to_delete_cloud + ' ' + errMsg, { error: true });\n\t\treturn undefined;\n\t}\n}\n\n/**\n * Transfers a local position to the server and removes the local copy.\n * @returns The updated cloud saves list on success, undefined on failure.\n */\nasync function transferPositionToCloud(\n\tposition_name: string,\n): Promise<CloudSaveListRecord[] | undefined> {\n\tconst editorSaveState = await esave.readLocal(position_name);\n\tif (editorSaveState === undefined) return;\n\n\tconst result = await saveCloudState(editorSaveState);\n\tif (!result.success) return;\n\n\t// Success! Delete local copy now.\n\tawait esave.deleteLocal(position_name);\n\n\tif (boardeditor.isActivePosition(position_name, 'local'))\n\t\tboardeditor.setActivePosition({\n\t\t\tname: position_name,\n\t\t\tstorage_type: 'cloud',\n\t\t\towner: validatorama.getOurUsername()!,\n\t\t});\n\n\treturn result.saves;\n}\n\n/**\n * Downloads a cloud position to local storage and removes it from the server.\n * @returns The updated cloud saves list on success, undefined on failure.\n */\nasync function removePositionFromCloud(\n\tposition_name: string,\n): Promise<CloudSaveListRecord[] | undefined> {\n\t// Read first so that we don't lose the position if the delete succeeds but request doesn't return\n\tconst editorSaveState = await readCloud(position_name);\n\tif (editorSaveState === undefined) return;\n\n\t// Delete from server (returns the updated list)\n\tlet saves: CloudSaveListRecord[];\n\ttry {\n\t\tsaves = await editorSavesAPI.deletePosition(position_name);\n\t} catch (err) {\n\t\tconsole.error('Failed to delete cloud position after download:', err);\n\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\ttoast.show(translations.editor.failed_to_remove_cloud + ' ' + errMsg, { error: true });\n\t\treturn;\n\t}\n\n\t// Success! Save locally now.\n\tawait esave.saveState(editorSaveState);\n\n\tif (boardeditor.isActivePosition(position_name, 'cloud'))\n\t\tboardeditor.setActivePosition({ name: position_name, storage_type: 'local' });\n\n\ttoast.show(translations.editor.saved_locally);\n\treturn saves;\n}\n\n/**\n * Fetches all cloud saves for the current user.\n * Mirrors esave.getAllLocalSaveInfos() for cloud storage.\n * @returns An array of cloud save records, or an empty array on failure.\n */\nasync function getAllCloudSaveInfos(): Promise<CloudSaveListRecord[]> {\n\ttry {\n\t\treturn await editorSavesAPI.getSavedPositions();\n\t} catch (err) {\n\t\tconsole.error('Failed to fetch cloud saves:', err);\n\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\ttoast.show(translations.editor.failed_to_fetch_cloud + ' ' + errMsg, { error: true });\n\t\treturn [];\n\t}\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\tsaveCloud,\n\treadCloud,\n\tdeleteCloud,\n\ttransferPositionToCloud,\n\tremovePositionFromCloud,\n\tgetAllCloudSaveInfos,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/actions/editorSavesAPI.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/actions/editorSavesAPI.ts\n\n/**\n * Client-side wrappers for the editor saves server API endpoints.\n */\n\nimport type { CompressionMode } from '../../../util/compression';\n\nimport validatorama from '../../../util/validatorama';\n\n// Types ----------------------------------------------------------------------------\n\n/** Abridged info returned by getSavedPositions */\nexport interface CloudSaveListRecord {\n\tname: string;\n\tpiece_count: number;\n\ttimestamp: number;\n}\n\n/** Full position info returned by getPosition */\nexport interface CloudPositionRecord {\n\ttimestamp: number;\n\t/** The compressed ICN */\n\ticn: string;\n\t/** Compression mode used for the ICN */\n\tcompression: CompressionMode;\n\t/** undefined represents the indeterminate (third) tristate */\n\tpawn_double_push?: boolean;\n\t/** undefined represents the indeterminate (third) tristate */\n\tcastling?: boolean;\n}\n\n// Helpers --------------------------------------------------------------------------\n\nasync function buildAuthHeaders(): Promise<Record<string, string>> {\n\tconst headers: Record<string, string> = {\n\t\t'Content-Type': 'application/json',\n\t\t'is-fetch-request': 'true',\n\t};\n\tconst token = await validatorama.getAccessToken();\n\tif (token) headers['Authorization'] = `Bearer ${token}`;\n\treturn headers;\n}\n\n// API Wrappers --------------------------------------------------------------------\n\n/**\n * GET /api/editor-saves\n * Returns an array of abridged save records for the logged-in user.\n * @throws If the request fails or the server returns a non-OK response.\n */\nasync function getSavedPositions(): Promise<CloudSaveListRecord[]> {\n\tconst headers = await buildAuthHeaders();\n\tconst response = await fetch('/api/editor-saves', {\n\t\tmethod: 'GET',\n\t\theaders,\n\t});\n\tif (!response.ok) {\n\t\tconst errorData = (await response.json()) as { error?: string };\n\t\tthrow new Error(errorData.error || 'Failed to get saved positions');\n\t}\n\tconst data = (await response.json()) as { saves: CloudSaveListRecord[] };\n\treturn data.saves;\n}\n\n/**\n * POST /api/editor-saves\n * Saves a position to the server for the logged-in user.\n * @throws If the request fails or the server returns a non-OK response.\n */\nasync function savePosition(\n\tname: string,\n\tpiece_count: number,\n\ttimestamp: number,\n\ticn: string,\n\tcompression: string,\n\tpawn_double_push?: boolean,\n\tcastling?: boolean,\n): Promise<CloudSaveListRecord[]> {\n\tconst headers = await buildAuthHeaders();\n\tconst response = await fetch('/api/editor-saves', {\n\t\tmethod: 'POST',\n\t\theaders,\n\t\tbody: JSON.stringify({\n\t\t\tname,\n\t\t\tpiece_count,\n\t\t\ttimestamp,\n\t\t\ticn,\n\t\t\tcompression,\n\t\t\tpawn_double_push,\n\t\t\tcastling,\n\t\t}),\n\t});\n\tif (!response.ok) {\n\t\tconst errorData = (await response.json()) as { error?: string };\n\t\tthrow new Error(errorData.error || 'Unknown error');\n\t}\n\tconst data = (await response.json()) as { success: true; saves: CloudSaveListRecord[] };\n\treturn data.saves;\n}\n\n/**\n * GET /api/editor-saves/:position_name\n * Returns the full ICN and game rules for a saved position.\n * @throws If the request fails or the server returns a non-OK response.\n */\nasync function getPosition(position_name: string): Promise<CloudPositionRecord> {\n\tconst headers = await buildAuthHeaders();\n\tconst response = await fetch(`/api/editor-saves/${encodeURIComponent(position_name)}`, {\n\t\tmethod: 'GET',\n\t\theaders,\n\t});\n\tif (!response.ok) {\n\t\tconst errorData = (await response.json()) as { error?: string };\n\t\tthrow new Error(errorData.error || 'Unknown error');\n\t}\n\treturn (await response.json()) as CloudPositionRecord;\n}\n\n/**\n * DELETE /api/editor-saves/:position_name\n * Deletes a saved position from the server.\n * Returns the updated list of abridged save records for the user.\n * @throws If the request fails or the server returns a non-OK response.\n */\nasync function deletePosition(position_name: string): Promise<CloudSaveListRecord[]> {\n\tconst headers = await buildAuthHeaders();\n\tconst response = await fetch(`/api/editor-saves/${encodeURIComponent(position_name)}`, {\n\t\tmethod: 'DELETE',\n\t\theaders,\n\t});\n\tif (!response.ok) {\n\t\tconst errorData = (await response.json()) as { error?: string };\n\t\tthrow new Error(errorData.error || 'Failed to delete position');\n\t}\n\tconst data = (await response.json()) as { success: true; saves: CloudSaveListRecord[] };\n\treturn data.saves;\n}\n\n// Exports -------------------------------------------------------------------------\n\nexport default {\n\tgetSavedPositions,\n\tsavePosition,\n\tgetPosition,\n\tdeletePosition,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/actions/esave.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/actions/esave.ts\n\n/**\n * Handles the saving of positions in boardeditor\n */\n\nimport type { EditorAbridgedSaveState, EditorSaveState } from '../editortypes';\n\nimport toast from '../../gui/toast';\nimport eactions from './eactions';\nimport IndexedDB from '../../../util/IndexedDB';\nimport eautosave from './eautosave';\nimport egamerules from '../egamerules';\nimport editortypes from '../editortypes';\nimport boardeditor from '../boardeditor';\n\n// Constants ----------------------------------------------------------------------\n\n/** Prefix for editor saves in local storage */\nconst EDITOR_SAVE_PREFIX = 'editor-save-' as const;\n\n/** Prefix for editor saveinfo in local storage */\nconst EDITOR_SAVEINFO_PREFIX = 'editor-saveinfo-' as const;\n\n// State --------------------------------------------------------------------\n\n/** Prevent overlapping IndexedDB saves (single-flight): is save ongoing */\nlet positionSaveInFlight = false;\n/** Prevent overlapping IndexedDB writes (single-flight): is save pending */\nlet positionSavePending = false;\n\n// Helpers ----------------------------------------------------------------------\n\n/** Returns the IndexedDB key for the full save data of a position. */\nfunction saveKey(position_name: string): string {\n\treturn `${EDITOR_SAVE_PREFIX}${position_name}`;\n}\n\n/** Returns the IndexedDB key for the abridged save info of a position. */\nfunction saveinfoKey(position_name: string): string {\n\treturn `${EDITOR_SAVEINFO_PREFIX}${position_name}`;\n}\n\n// Actions ----------------------------------------------------------------------\n\n/** Saves current position under \"position_name\". */\nasync function saveLocal(position_name: string): Promise<void> {\n\tif (!boardeditor.areInBoardEditor()) return;\n\n\t// Coalesce: if a save is already running, request another and return.\n\tif (positionSaveInFlight) {\n\t\tpositionSavePending = true;\n\t\treturn;\n\t}\n\n\tpositionSaveInFlight = true;\n\tpositionSavePending = false;\n\n\ttry {\n\t\tconst variantOptions = eactions.getCurrentPositionInformation(false);\n\t\tconst { pawnDoublePush, castling } = egamerules.getPositionDependentGameRules();\n\t\tconst timestamp = Date.now();\n\t\tconst piece_count = variantOptions.position.size;\n\n\t\tawait saveState({\n\t\t\tposition_name,\n\t\t\ttimestamp,\n\t\t\tpiece_count,\n\t\t\tvariantOptions,\n\t\t\tpawnDoublePush,\n\t\t\tcastling,\n\t\t});\n\t} catch (err) {\n\t\t// Don't crash the editor over failed save\n\t\tconsole.error('Failed to save board editor position:', err);\n\t} finally {\n\t\tpositionSaveInFlight = false;\n\n\t\t// If something changed while saving, immediately save again (latest wins).\n\t\tif (positionSavePending) {\n\t\t\tpositionSavePending = false;\n\t\t\tawait saveLocal(position_name);\n\t\t} else {\n\t\t\tboardeditor.markPositionClean();\n\t\t\teautosave.markPositionDirty();\n\t\t\tvoid eautosave.autosaveCurrentPositionOnce();\n\t\t\ttoast.show(translations.editor.saved_in_browser);\n\t\t}\n\t}\n}\n\n/**\n * Persists a fully constructed SaveState to IndexedDB.\n * Writes both the full save (for loading) and the abridged save (for display).\n */\nasync function saveState(editorSaveState: EditorSaveState): Promise<void> {\n\tconst { position_name, timestamp, piece_count } = editorSaveState;\n\tawait Promise.all([\n\t\t// Save full info for loading purposes\n\t\tIndexedDB.saveItem(saveKey(position_name), editorSaveState),\n\t\t// Save abridged info for display purposes\n\t\tIndexedDB.saveItem(saveinfoKey(position_name), {\n\t\t\tposition_name,\n\t\t\ttimestamp,\n\t\t\tpiece_count,\n\t\t}),\n\t]);\n}\n\n/** Deletes a locally saved position from IndexedDB. */\nasync function deleteLocal(position_name: string): Promise<void> {\n\tawait Promise.all([\n\t\tIndexedDB.deleteItem(saveinfoKey(position_name)),\n\t\tIndexedDB.deleteItem(saveKey(position_name)),\n\t]);\n}\n\n/** Returns true if a local save exists for the given position name. */\nasync function localSaveExists(position_name: string): Promise<boolean> {\n\tconst raw = await IndexedDB.loadItem(saveinfoKey(position_name));\n\treturn editortypes.AbridgedSaveStateSchema.safeParse(raw).success;\n}\n\n/**\n * Returns an array of all abridged save states stored locally.\n * Deletes and logs any corrupted entries.\n */\nasync function getAllLocalSaveInfos(): Promise<EditorAbridgedSaveState[]> {\n\tconst saveinfo_keys = (await IndexedDB.getAllKeys()).filter((key) =>\n\t\tkey.startsWith(EDITOR_SAVEINFO_PREFIX),\n\t);\n\tconst results = await Promise.all(\n\t\tsaveinfo_keys.map(async (key) => {\n\t\t\tconst raw = await IndexedDB.loadItem(key);\n\t\t\tconst parsed = editortypes.AbridgedSaveStateSchema.safeParse(raw);\n\t\t\tif (!parsed.success) {\n\t\t\t\tconst position_name = key.slice(EDITOR_SAVEINFO_PREFIX.length);\n\t\t\t\tconsole.error(\n\t\t\t\t\t`Corrupted local save \"${position_name}\" found, deleting it. Error: ${parsed.error}`,\n\t\t\t\t);\n\t\t\t\tawait deleteLocal(position_name);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\treturn parsed.data;\n\t\t}),\n\t);\n\treturn results.filter((x) => x !== undefined);\n}\n\n/**\n * Reads a locally saved position from IndexedDB.\n * @returns An EditorSaveState on success, undefined if not found or corrupted.\n */\nasync function readLocal(position_name: string): Promise<EditorSaveState | undefined> {\n\tconst editorSaveStateRaw = await IndexedDB.loadItem(saveKey(position_name));\n\tconst editorSaveStateParsed = editortypes.SaveStateSchema.safeParse(editorSaveStateRaw);\n\tif (!editorSaveStateParsed.success) {\n\t\tconsole.error(\n\t\t\t`Corrupted local save \"${position_name}\" found. Error: ${editorSaveStateParsed.error}`,\n\t\t);\n\t\ttoast.show(translations.editor.position_corrupted, { error: true });\n\t\treturn;\n\t}\n\treturn editorSaveStateParsed.data;\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\tsaveLocal,\n\tsaveState,\n\tdeleteLocal,\n\treadLocal,\n\tlocalSaveExists,\n\tgetAllLocalSaveInfos,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/boardeditor.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/boardeditor.ts\n\n/**\n * Core manager for the Board Editor.\n *\n * Handles the lifecycle (open/close), dirty/clean state,\n * active position tracking, and the main update/render loop.\n */\n\nimport type { VariantOptions } from '../../../../../shared/chess/logic/initvariant.js';\n\nimport jsutil from '../../../../../shared/util/jsutil.js';\nimport icnconverter from '../../../../../shared/chess/logic/icn/icnconverter.js';\nimport gamefileutility from '../../../../../shared/chess/util/gamefileutility.js';\nimport { players as p } from '../../../../../shared/chess/util/typeutil.js';\n\nimport gameslot from '../chess/gameslot.js';\nimport eautosave from './actions/eautosave.js';\nimport egamerules from './egamerules.js';\nimport eclipboard from './eclipboard.js';\nimport drawingtool from './tools/drawingtool.js';\nimport editortypes from './editortypes.js';\nimport edithistory from './edithistory.js';\nimport etoolmanager from './tools/etoolmanager.js';\nimport selectiontool from './tools/selection/selectiontool.js';\nimport stransformations from './tools/selection/stransformations.js';\nimport guipositionheader from '../gui/boardeditor/guipositionheader.js';\n\n// Types ------------------------------------------------------------------------\n\n/** The active position loaded in the board editor, if any. */\nexport type ActivePosition =\n\t| { name: string; storage_type: 'local' }\n\t| { name: string; storage_type: 'cloud'; owner: string };\n\n/** Whether a position is stored locally (IndexedDB) or on the server (cloud) */\nexport type StorageType = (typeof editortypes)['STORAGE_TYPES'][number];\n\n// State -------------------------------------------------------------------------\n\n/** Whether we are currently using the editor. */\nlet inBoardEditor = false;\n\n/** The active position, if any, as displayed on editor bar and used for \"Save\" button by default */\nlet active_position: ActivePosition | undefined = undefined;\n\n/** Whether the current board position has unsaved changes. */\nlet positionDirty = false;\n\n// Initialization ------------------------------------------------------------------------\n\n/**\n * Initializes the board editor.\n * Should be called AFTER loading the game logically.\n * May optionally be supplied with custom game rules.\n */\nasync function initBoardEditor(\n\t/** Whether the position has unsaved changes. */\n\tdirty: boolean,\n\tvariantOptions?: VariantOptions,\n\tpawnDoublePush?: boolean,\n\tcastling?: boolean,\n): Promise<void> {\n\tinBoardEditor = true;\n\tif (dirty) markPositionDirty();\n\telse markPositionClean();\n\n\tetoolmanager.setTool('normal');\n\tdrawingtool.init();\n\n\tlet initial_pawnDoublePush: boolean | undefined;\n\tlet initial_castling: boolean | undefined;\n\n\tif (variantOptions === undefined) {\n\t\tconst gamefile = gameslot.getGamefile()!;\n\t\t// Set gamerulesGUIinfo object according to loaded Classical variant\n\t\tconst gameRules = jsutil.deepCopyObject(gamefile.basegame.gameRules);\n\t\tgameRules.winConditions[p.WHITE] = [icnconverter.default_win_condition];\n\t\tgameRules.winConditions[p.BLACK] = [icnconverter.default_win_condition];\n\t\tconst globalState = jsutil.deepCopyObject(gamefile.boardsim.state.global);\n\t\tinitial_pawnDoublePush = true;\n\t\tinitial_castling = true;\n\t\tegamerules.setGamerulesGUIinfo(\n\t\t\tgameRules,\n\t\t\tglobalState,\n\t\t\tinitial_pawnDoublePush,\n\t\t\tinitial_castling,\n\t\t);\n\t} else {\n\t\t// Set game rules according to provided variantOptions object\n\t\tinitial_pawnDoublePush = pawnDoublePush;\n\t\tinitial_castling = castling;\n\t\tegamerules.setGamerulesGUIinfo(\n\t\t\tvariantOptions.gameRules,\n\t\t\tvariantOptions.state_global,\n\t\t\tpawnDoublePush,\n\t\t\tcastling,\n\t\t);\n\t}\n\n\tedithistory.init(initial_pawnDoublePush, initial_castling);\n\n\t// Erase the `inCheck` and `checks` state of the gamefile, which were auto-calculated in the constructor.\n\t// Prevents check highlights from rendering when opening the board editor.\n\tconst gamefile = gameslot.getGamefile()!;\n\tgamefile.boardsim.state.local.inCheck = false;\n\tgamefile.boardsim.state.local.checks = [];\n\t// Also set gameConclusion to undefined. Otherwise, starting from a position that\n\t// would have otherwise been checkmate/stalemate will prevent us from selecting pieces.\n\tgamefileutility.setConclusion(gamefile.basegame, undefined);\n\n\teclipboard.addEventListeners();\n\teautosave.startPositionAutosave();\n}\n\n/** Closes the board editor and resets all state. */\nfunction closeBoardEditor(): void {\n\t// Perform last autosave\n\teautosave.markPositionDirty();\n\tvoid eautosave.autosaveCurrentPositionOnce();\n\teautosave.stopPositionAutosave();\n\n\t// Reset state\n\tinBoardEditor = false;\n\tedithistory.reset();\n\tetoolmanager.reset();\n\tdrawingtool.onCloseEditor();\n\tselectiontool.resetState();\n\tstransformations.resetState(); // Drops reference to clipboard\n\n\teclipboard.removeEventListeners();\n}\n\n// Update & Render -------------------------------------------------------------\n\n/** Called every frame while the board editor is open. */\nfunction update(): void {\n\tif (!inBoardEditor) return;\n\n\tetoolmanager.testShortcuts();\n\n\t// Handle starting and ending the drawing state\n\tconst currentTool = etoolmanager.getTool();\n\tif (drawingtool.isToolADrawingTool(currentTool)) drawingtool.update(currentTool);\n\t// Update selection tool, if that is active\n\telse if (currentTool === 'selection-tool') selectiontool.update();\n}\n\n/** Renders any graphics of the active tool, if we are in the board editor. */\nfunction render(): void {\n\tif (!inBoardEditor) return;\n\n\t// Render selection-tool graphics, if that is active\n\tif (etoolmanager.getTool() === 'selection-tool') selectiontool.render();\n}\n\n// Utility --------------------------------------------------------------------\n\n/** Returns true if the board editor is currently open. */\nfunction areInBoardEditor(): boolean {\n\treturn inBoardEditor;\n}\n\n/** Returns true if the current board position has unsaved changes. */\nfunction isPositionDirty(): boolean {\n\treturn positionDirty;\n}\n\n/**\n * Marks the current board position as having unsaved changes,\n * and notifies eautosave to schedule a background autosave.\n */\nfunction markPositionDirty(): void {\n\t// console.error('Position marked dirty');\n\tpositionDirty = true;\n\tguipositionheader.updateDirtyIndicator(true);\n\teautosave.markPositionDirty();\n}\n\n/** Marks the current board position as clean (saved). */\nfunction markPositionClean(): void {\n\t// console.error('Position marked clean');\n\tpositionDirty = false;\n\tguipositionheader.updateDirtyIndicator(false);\n}\n\n/** Returns the active position, if any. */\nfunction getActivePosition(): ActivePosition | undefined {\n\treturn active_position;\n}\n\n/** Returns true if the provided position name and storage type match the current active position. */\nfunction isActivePosition(name: string, storage_type: StorageType): boolean {\n\treturn (\n\t\tactive_position !== undefined &&\n\t\tactive_position.name === name &&\n\t\tactive_position.storage_type === storage_type\n\t);\n}\n\n/** Sets the currently active position and flushes the autosave. */\nfunction setActivePosition(new_position: ActivePosition): void {\n\tactive_position = new_position;\n\tguipositionheader.updateActivePositionElement(new_position.name);\n\tflushActivePositionToAutosave();\n}\n\n/** Clears the active position and marks the position as dirty. */\nfunction clearActivePosition(): void {\n\tactive_position = undefined;\n\tmarkPositionDirty();\n\tguipositionheader.updateActivePositionElement(undefined);\n\tflushActivePositionToAutosave();\n}\n\n/**\n * Immediately flushes the autosave so a page refresh immediately\n * after a save/delete operation reflects the current active position.\n */\nfunction flushActivePositionToAutosave(): void {\n\tif (gameslot.getGamefile() === undefined) return; // Some callers run before the gamefile exists\n\teautosave.markPositionDirty();\n\tvoid eautosave.autosaveCurrentPositionOnce();\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\t// State\n\tareInBoardEditor,\n\t// Initialization\n\tinitBoardEditor,\n\tcloseBoardEditor,\n\t// Update & Render\n\tupdate,\n\trender,\n\t// Dirty State\n\tisPositionDirty,\n\tmarkPositionDirty,\n\tmarkPositionClean,\n\t// Active Position\n\tgetActivePosition,\n\tisActivePosition,\n\tsetActivePosition,\n\tclearActivePosition,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/eclipboard.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/eclipboard.ts\n\n/**\n * Clipboard handlers for the Board Editor.\n *\n * Manages copy, cut, and paste operations, delegating to the\n * selection tool transformations or the game notation actions.\n */\n\nimport toast from '../gui/toast.js';\nimport gameslot from '../chess/gameslot.js';\nimport eactions from './actions/eactions.js';\nimport gameloader from '../chess/gameloader.js';\nimport etoolmanager from './tools/etoolmanager.js';\nimport selectiontool from './tools/selection/selectiontool.js';\nimport stransformations from './tools/selection/stransformations.js';\n\n// Event Listeners ------------------------------------------------------------\n\n/** Registers the copy/cut/paste event listeners on the document. */\nfunction addEventListeners(): void {\n\tdocument.addEventListener('copy', onCopy);\n\tdocument.addEventListener('cut', onCut);\n\tdocument.addEventListener('paste', onPaste);\n\tdocument.addEventListener('copy-game', onCopyGame);\n\tdocument.addEventListener('paste-game', onPasteGame);\n}\n\n/** Removes the copy/cut/paste event listeners from the document. */\nfunction removeEventListeners(): void {\n\tdocument.removeEventListener('copy', onCopy);\n\tdocument.removeEventListener('cut', onCut);\n\tdocument.removeEventListener('paste', onPaste);\n\tdocument.removeEventListener('copy-game', onCopyGame);\n\tdocument.removeEventListener('paste-game', onPasteGame);\n}\n\n// Handlers -------------------------------------------------------------------\n\n/** Custom Board Editor handler for the Copy event. */\nfunction onCopy(): void {\n\tif (document.activeElement instanceof HTMLInputElement) return; // Don't copy if the user is typing in an input field\n\tif (window.getSelection()?.toString()) return; // Don't copy if the user has text selected in the UI\n\n\tif (etoolmanager.getTool() !== 'selection-tool') {\n\t\t// Copy game notation\n\t\tdocument.dispatchEvent(new Event('copy-game'));\n\t} else if (selectiontool.isExistingSelection()) {\n\t\t// Copy current selection\n\t\tconst gamefile = gameslot.getGamefile()!;\n\t\tconst selectionBox = selectiontool.getSelectionIntBox()!;\n\t\tstransformations.Copy(gamefile, selectionBox);\n\t}\n}\n\n/** Board Editor handler for the Cut event. */\nfunction onCut(): void {\n\tif (document.activeElement instanceof HTMLInputElement) return; // Don't cut if the user is typing in an input field\n\tif (window.getSelection()?.toString()) return; // Don't cut if the user has text selected in the UI\n\n\tif (etoolmanager.getTool() !== 'selection-tool' || !selectiontool.isExistingSelection()) return;\n\n\t// Cut current selection\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh()!;\n\tconst selectionBox = selectiontool.getSelectionIntBox()!;\n\tstransformations.Copy(gamefile, selectionBox);\n\tstransformations.Delete(gamefile, mesh, selectionBox);\n}\n\n/** Custom Board Editor handler for the Paste event. */\nfunction onPaste(): void {\n\tif (document.activeElement instanceof HTMLInputElement) return; // Don't paste if the user is typing in an input field\n\tif (gameloader.areWeLoadingGame()) return toast.showPleaseWaitForTask();\n\n\tif (etoolmanager.getTool() !== 'selection-tool') {\n\t\t// Paste game notation\n\t\tdocument.dispatchEvent(new Event('paste-game'));\n\t} else if (selectiontool.isExistingSelection()) {\n\t\t// Paste clipboard at current selection\n\t\tconst gamefile = gameslot.getGamefile()!;\n\t\tconst mesh = gameslot.getMesh()!;\n\t\tconst selectionBox = selectiontool.getSelectionIntBox()!;\n\t\tstransformations.Paste(gamefile, mesh, selectionBox);\n\t}\n}\n\n/** Board Editor handler for the 'copy-game' custom event. Copies the full position as game notation. */\nfunction onCopyGame(): void {\n\teactions.copy();\n}\n\n/** Board Editor handler for the 'paste-game' custom event. Pastes game notation from the clipboard. */\nfunction onPasteGame(): void {\n\teactions.paste();\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\taddEventListeners,\n\tremoveEventListeners,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/edithistory.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/edithistory.ts\n\n/**\n * Edit History for the Board Editor.\n *\n * Manages the undo/redo stack, running edits logically and graphically,\n * and queuing individual piece/special-rights changes into an Edit object.\n */\n\nimport type { Edit } from '../../../../../shared/chess/logic/movepiece.js';\nimport type { Mesh } from '../rendering/piecemodels.js';\nimport type { Piece } from '../../../../../shared/chess/util/boardutil.js';\nimport type { Coords } from '../../../../../shared/chess/util/coordutil.js';\nimport type { FullGame } from '../../../../../shared/chess/logic/gamefile.js';\n\nimport state from '../../../../../shared/chess/logic/state.js';\nimport coordutil from '../../../../../shared/chess/util/coordutil.js';\nimport movepiece from '../../../../../shared/chess/logic/movepiece.js';\nimport boardutil from '../../../../../shared/chess/util/boardutil.js';\nimport boardchanges from '../../../../../shared/chess/logic/boardchanges.js';\n\nimport arrows from '../rendering/arrows/arrows.js';\nimport gameslot from '../chess/gameslot.js';\nimport miniimage from '../rendering/miniimage.js';\nimport selection from '../chess/selection.js';\nimport egamerules from './egamerules.js';\nimport drawingtool from './tools/drawingtool.js';\nimport { GameBus } from '../GameBus.js';\nimport boardeditor from './boardeditor.js';\nimport movesequence from '../chess/movesequence.js';\nimport guinavigation from '../gui/guinavigation.js';\n\n// Types ----------------------------------------------------------------------\n\n/**\n * An edit that also keeps track of the state of certain position-dependent game rules AFTER the edit is made.\n * Used exclusively for game history purposes.\n */\ninterface EditWithRules extends Edit {\n\t/** The state of the pawn double push gamerules checkbox AFTER this edit was made. */\n\tpawnDoublePush?: boolean;\n\t/** The state of the castling gamerules checkbox AFTER this edit was made. */\n\tcastling?: boolean;\n}\n\n// Constants ------------------------------------------------------------------\n\n/**\n * The maximum allowed summed changes in the edit history before oldest edits are pruned.\n * This is to prevent excessive memory usage crashing the browser.\n *\n * Naviary's machine got to 26 million changes before slowing, then crashing.\n * The tab was using roughly 5 GB of memory at that point.\n * I guess maybe a max of 8 million could be safe on most machines?\n */\nconst EDIT_HISTORY_MAX_CHANGES = 8_000_000;\n\n// State ----------------------------------------------------------------------\n\n/** The list of all edits the user has made. */\nlet edits: Array<EditWithRules> | undefined;\nlet indexOfThisEdit: number | undefined;\n\n/** The value of the pawnDoublePush game rule in the initial zeroth edit */\nlet initial_pawnDoublePush: boolean | undefined = true;\n/** The value of the castling game rule in the initial zeroth edit */\nlet initial_castling: boolean | undefined = true;\n\n// Initialization -------------------------------------------------------------\n\n/** Initializes the edit history state when the board editor is opened. */\nfunction init(pawnDoublePush: boolean | undefined, castling: boolean | undefined): void {\n\tedits = [];\n\tindexOfThisEdit = 0;\n\tinitial_pawnDoublePush = pawnDoublePush;\n\tinitial_castling = castling;\n\tguinavigation.update_EditButtons();\n}\n\n/** Resets the edit history state when the board editor is closed. */\nfunction reset(): void {\n\tedits = undefined;\n\tindexOfThisEdit = undefined;\n}\n\n// Running Edits --------------------------------------------------------------\n\n/** Runs both logical and graphical changes. */\nfunction runEdit(gamefile: FullGame, mesh: Mesh, edit: Edit, forward: boolean = true): void {\n\t// Pieces must be unselected before they are modified\n\tselection.unselectPiece();\n\n\t// Run logical changes\n\tmovepiece.applyEdit(gamefile, edit, forward, true);\n\tGameBus.dispatch('physical-move');\n\n\t// Run graphical changes\n\tmovesequence.runMeshChanges(gamefile.boardsim, mesh, edit, forward);\n\n\t// If the piece count is now high enough, disable icons and arrows.\n\tconst pieceCount = boardutil.getPieceCountOfGame(gamefile.boardsim.pieces);\n\tif (pieceCount > miniimage.pieceCountToDisableMiniImages || pieceCount > arrows.MAX_PIECES) {\n\t\tminiimage.disable();\n\t\tarrows.setMode(0);\n\t}\n\n\t// Prune the oldest edits in the history if we exceed the cap, to help prevent memory crashes.\n\tconst totalChanges: number = edits!.reduce((sum, edit) => sum + edit.changes.length, 0);\n\t// console.log(\"Total changes in edit history: \" + totalChanges);\n\tif (totalChanges > EDIT_HISTORY_MAX_CHANGES) {\n\t\tlet changesToRemove = totalChanges - EDIT_HISTORY_MAX_CHANGES;\n\t\twhile (changesToRemove > 0 && edits!.length > 0) {\n\t\t\tconst oldestEdit = edits!.shift()!;\n\t\t\tchangesToRemove -= oldestEdit.changes.length;\n\t\t\tindexOfThisEdit!--;\n\t\t}\n\t\t// console.log(\"Pruned oldest edits.\");\n\t}\n}\n\n/** Appends the given edit to the history stack, discarding any future (redo) edits. */\nfunction addEditToHistory(edit: Edit): void {\n\tif (\n\t\tedit.changes.length === 0 &&\n\t\tedit.state.local.length === 0 &&\n\t\tedit.state.global.length === 0\n\t)\n\t\treturn;\n\tedits!.length = indexOfThisEdit!; // Truncate any \"redo\" edits, that timeline is being erased.\n\tconst { pawnDoublePush, castling } = egamerules.getPositionDependentGameRules();\n\tconst editWithRules: EditWithRules = {\n\t\t...edit,\n\t\tpawnDoublePush,\n\t\tcastling,\n\t};\n\tedits!.push(editWithRules);\n\tindexOfThisEdit!++;\n\tguinavigation.update_EditButtons();\n\n\tboardeditor.markPositionDirty();\n}\n\n/** Undoes the most recent edit. */\nfunction undo(): void {\n\tif (!boardeditor.areInBoardEditor())\n\t\tthrow Error(\"Cannot undo edit when we're not using the board editor.\");\n\tif (drawingtool.isEditInProgress()) return; // Do not allow undoing or redoing while currently making an edit\n\tif (indexOfThisEdit! <= 0) return;\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh()!;\n\tindexOfThisEdit!--;\n\tconst thisEdit = edits![indexOfThisEdit!]!;\n\trunEdit(gamefile, mesh, thisEdit, false);\n\n\t// Restore position dependent game rules to what they were before this edit\n\tif (indexOfThisEdit! !== 0) {\n\t\tconst previousEdit = edits![indexOfThisEdit! - 1]!;\n\t\tegamerules.setPositionDependentGameRules({\n\t\t\tpawnDoublePush: previousEdit.pawnDoublePush,\n\t\t\tcastling: previousEdit.castling,\n\t\t});\n\t} else {\n\t\t// Reset to initial state\n\t\tegamerules.setPositionDependentGameRules({\n\t\t\tpawnDoublePush: initial_pawnDoublePush,\n\t\t\tcastling: initial_castling,\n\t\t});\n\t}\n\n\tguinavigation.update_EditButtons();\n\n\tboardeditor.markPositionDirty();\n}\n\n/** Redoes the next edit in the history. */\nfunction redo(): void {\n\tif (!boardeditor.areInBoardEditor())\n\t\tthrow Error(\"Cannot redo edit when we're not using the board editor.\");\n\tif (drawingtool.isEditInProgress()) return; // Do not allow undoing or redoing while currently making an edit\n\tif (indexOfThisEdit! >= edits!.length) return;\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh()!;\n\tconst thisEdit = edits![indexOfThisEdit!]!;\n\trunEdit(gamefile, mesh, thisEdit, true);\n\n\t// Update position dependent game rules to what they are after this edit\n\tegamerules.setPositionDependentGameRules({\n\t\tpawnDoublePush: thisEdit.pawnDoublePush,\n\t\tcastling: thisEdit.castling,\n\t});\n\n\tindexOfThisEdit!++;\n\tguinavigation.update_EditButtons();\n\n\tboardeditor.markPositionDirty();\n}\n\n/** Returns true if there is an edit to undo. */\nfunction canUndo(): boolean {\n\t// comparing undefined always returns false\n\treturn indexOfThisEdit !== undefined && indexOfThisEdit > 0;\n}\n\n/** Returns true if there is an edit to redo. */\nfunction canRedo(): boolean {\n\t// comparing undefined always returns false\n\treturn indexOfThisEdit !== undefined && edits !== undefined && indexOfThisEdit < edits.length;\n}\n\n// Queuing Edits --------------------------------------------------------------\n\n/** Queues the deletion of a piece, including its special rights, if present, to the edit changes. */\nfunction queueRemovePiece(gamefile: FullGame, edit: Edit, piece: Piece): void {\n\tboardchanges.queueDeletePiece(edit.changes, false, piece);\n\tqueueSpecialRights(gamefile, edit, piece.coords, false);\n}\n\n/**\n * Queues the addition of a piece, including its special rights, if specified, to the edit changes.\n * If specialrights is left undefined, it is set according to the game rules\n */\nfunction queueAddPiece(\n\tgamefile: FullGame,\n\tedit: Edit,\n\tcoords: Coords,\n\ttype: number,\n\tspecialright: boolean,\n): void {\n\tconst piece: Piece = { type, coords, index: -1 };\n\tboardchanges.queueAddPiece(edit.changes, piece);\n\tif (specialright) queueSpecialRights(gamefile, edit, coords, specialright);\n}\n\n/** Queues the addition/removal of a specialright at the specified coordinates. */\nfunction queueSpecialRights(gamefile: FullGame, edit: Edit, coords: Coords, add: boolean): void {\n\tconst coordsKey = coordutil.getKeyFromCoords(coords);\n\tconst current = gamefile.boardsim.state.global.specialRights.has(coordsKey);\n\tstate.createSpecialRightsState(edit, coordsKey, current, add);\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\t// Initialization\n\tinit,\n\treset,\n\t// Running Edits\n\trunEdit,\n\taddEditToHistory,\n\tundo,\n\tredo,\n\t// Querying\n\tcanUndo,\n\tcanRedo,\n\t// Queuing Edits\n\tqueueAddPiece,\n\tqueueRemovePiece,\n\tqueueSpecialRights,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/editortypes.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/editortypes.ts\n\n/**\n * All TypeScript types, constants, and Zod schemas for the board editor save system.\n *\n * Centralized here to avoid circular-dependency issues — this file only uses\n * type-only imports from other modules, so it can never be part of a circular\n * dependency chain at runtime.\n */\n\nimport type { VariantOptions } from '../../../../../shared/chess/logic/initvariant.js';\nimport type { ActivePosition } from './boardeditor.js';\n\nimport * as z from 'zod';\n\n// Constants ------------------------------------------------------------------\n\n/** All valid storage locations for a saved editor position */\nconst STORAGE_TYPES = ['local', 'cloud'] as const;\n\n// Types ------------------------------------------------------------------\n\n/** Minimal information about a saved position — used for display in the saved positions list */\nexport interface EditorAbridgedSaveState {\n\tposition_name: string;\n\ttimestamp: number;\n\tpiece_count: number;\n}\n\n/** Position data shared between normal saves and autosaves */\nexport interface EditorPositionData {\n\ttimestamp: number;\n\tpiece_count: number;\n\tvariantOptions: VariantOptions;\n\tpawnDoublePush?: boolean;\n\tcastling?: boolean;\n}\n\n/** Complete information about a saved position (local or cloud) */\nexport interface EditorSaveState extends EditorPositionData {\n\tposition_name: string;\n}\n\n/**\n * Complete save state as written by the autosave.\n * active_position is optional because the user may not have a named/saved position open.\n */\nexport interface EditorAutosaveState extends EditorPositionData {\n\tactive_position?: ActivePosition;\n\t/** Whether the position has unsaved changes. */\n\tdirty: boolean;\n}\n\n// Zod Schemas --------------------------------------------------------------------\n\n/** Shared Zod fields for EditorSaveState and EditorAutosaveState */\nconst positionDataFields = {\n\ttimestamp: z.number(),\n\tpiece_count: z.number().int('Piece count must be an integer'),\n\tvariantOptions: z\n\t\t.object()\n\t\t.loose()\n\t\t.transform((v) => v as unknown as VariantOptions), // Workaround for lack of VariantOptions schema\n\tpawnDoublePush: z.boolean().optional(),\n\tcastling: z.boolean().optional(),\n};\n\n/** Shared position_name schema */\nconst positionNameSchema = z.string().min(1, 'Position name is required');\n\n/** Schema for validating an AbridgedSaveState */\nconst AbridgedSaveStateSchema = z.strictObject({\n\tposition_name: positionNameSchema,\n\ttimestamp: positionDataFields.timestamp,\n\tpiece_count: positionDataFields.piece_count,\n});\n\n/** Schema for validating a SaveState */\nconst SaveStateSchema = z.strictObject({\n\tposition_name: positionNameSchema,\n\t...positionDataFields,\n});\n\n/** Schema for validating an AutosaveState */\nconst AutosaveStateSchema = z.strictObject({\n\tactive_position: z\n\t\t.union([\n\t\t\tz.object({ name: z.string(), storage_type: z.literal('local') }),\n\t\t\tz.object({ name: z.string(), storage_type: z.literal('cloud'), owner: z.string() }),\n\t\t])\n\t\t.optional(),\n\tdirty: z.boolean(),\n\t...positionDataFields,\n});\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\tSTORAGE_TYPES,\n\n\tpositionDataFields,\n\tAbridgedSaveStateSchema,\n\tSaveStateSchema,\n\tAutosaveStateSchema,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/egamerules.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/egamerules.ts\n\n/**\n * Editor Game Rules\n *\n * Manages the game rules of the board editor position.\n */\n\nimport type { Edit } from '../../../../../shared/chess/logic/movepiece';\nimport type { Piece } from '../../../../../shared/chess/util/boardutil';\nimport type { Coords } from '../../../../../shared/chess/util/coordutil';\nimport type { GameRules } from '../../../../../shared/chess/util/gamerules';\nimport type { UnboundedRectangle } from '../../../../../shared/util/math/bounds';\nimport type { RawType, PlayerGroup } from '../../../../../shared/chess/util/typeutil';\nimport type { GameruleWinCondition } from '../../../../../shared/chess/util/winconutil';\n\nimport boardutil from '../../../../../shared/chess/util/boardutil';\nimport icnconverter from '../../../../../shared/chess/logic/icn/icnconverter';\nimport { EnPassant, GlobalGameState } from '../../../../../shared/chess/logic/state';\nimport typeutil, { players as p, rawTypes as r } from '../../../../../shared/chess/util/typeutil';\n\nimport gameslot from '../chess/gameslot';\nimport boardeditor from './boardeditor';\nimport edithistory from './edithistory';\nimport guigamerules from '../gui/boardeditor/actions/guigamerules';\n\n// Types -------------------------------------------------------------------------\n\n/** Type encoding information for the game rules object of the editor position */\ninterface GameRulesGUIinfo {\n\tplayerToMove: 'white' | 'black';\n\tenPassant?: {\n\t\tx: bigint;\n\t\ty: bigint;\n\t};\n\tmoveRule?: {\n\t\tcurrent: number;\n\t\tmax: number;\n\t};\n\tpromotionRanks?: {\n\t\twhite?: bigint[];\n\t\tblack?: bigint[];\n\t};\n\tpromotionsAllowed?: RawType[];\n\tpawnDoublePush?: boolean;\n\tcastling?: boolean;\n\twinConditions: GameruleWinCondition[];\n\tworldBorder?: UnboundedRectangle;\n}\n\n// Constants -------------------------------------------------------------\n\n// Game rule relevant piece types\n\n/** All piece types affected by the pawnDoublePush rule */\nconst pawnDoublePushTypes: RawType[] = [r.PAWN];\n/** All piece types affected by the castling rule. These pieces are the only pieces allowed to castle under the castling rule. */\nconst castlingTypes: RawType[] = [r.ROOK, r.KING, r.ROYALCENTAUR];\n\n// State -------------------------------------------------------------\n\n/** Virtual game rules object for the position */\nlet gamerulesGUIinfo: GameRulesGUIinfo = {\n\tplayerToMove: 'white',\n\twinConditions: [icnconverter.default_win_condition],\n};\n\n// Getting & Setting -------------------------------------------------------------\n\nfunction getPlayerToMove(): 'white' | 'black' {\n\treturn gamerulesGUIinfo.playerToMove;\n}\n\nfunction getCurrentGamerulesAndState(): {\n\tgameRules: GameRules;\n\tmoveRuleState: number | undefined;\n\tenpassantcoords: Coords | undefined;\n} {\n\t// Construct gameRules\n\t// prettier-ignore\n\tconst turnOrder = gamerulesGUIinfo.playerToMove === \"white\" ? [p.WHITE, p.BLACK] : gamerulesGUIinfo.playerToMove === \"black\" ? [p.BLACK, p.WHITE] : (() => { throw Error(\"Invalid player to move\"); })(); // Future protection\n\tconst moveRule =\n\t\tgamerulesGUIinfo.moveRule !== undefined ? gamerulesGUIinfo.moveRule.max : undefined;\n\tconst winConditions = {\n\t\t[p.WHITE]: gamerulesGUIinfo.winConditions,\n\t\t[p.BLACK]: gamerulesGUIinfo.winConditions,\n\t};\n\tlet promotionRanks: PlayerGroup<bigint[]> | undefined = undefined;\n\tlet promotionsAllowed: PlayerGroup<RawType[]> | undefined = undefined;\n\tif (\n\t\tgamerulesGUIinfo.promotionsAllowed !== undefined &&\n\t\tgamerulesGUIinfo.promotionRanks !== undefined\n\t) {\n\t\tpromotionsAllowed = {};\n\t\tpromotionRanks = {};\n\t\tif (\n\t\t\tgamerulesGUIinfo.promotionRanks.white !== undefined &&\n\t\t\tgamerulesGUIinfo.promotionRanks.white.length !== 0\n\t\t) {\n\t\t\tpromotionRanks[p.WHITE] = gamerulesGUIinfo.promotionRanks.white;\n\t\t\tpromotionsAllowed[p.WHITE] = gamerulesGUIinfo.promotionsAllowed;\n\t\t}\n\t\tif (\n\t\t\tgamerulesGUIinfo.promotionRanks.black !== undefined &&\n\t\t\tgamerulesGUIinfo.promotionRanks.black.length !== 0\n\t\t) {\n\t\t\tpromotionRanks[p.BLACK] = gamerulesGUIinfo.promotionRanks.black;\n\t\t\tpromotionsAllowed[p.BLACK] = gamerulesGUIinfo.promotionsAllowed;\n\t\t}\n\t}\n\n\tconst gameRules: GameRules = {\n\t\tturnOrder,\n\t\tmoveRule,\n\t\tpromotionRanks,\n\t\tpromotionsAllowed,\n\t\twinConditions,\n\t\tworldBorder: gamerulesGUIinfo.worldBorder,\n\t};\n\n\tconst moveRuleState =\n\t\tgamerulesGUIinfo.moveRule !== undefined ? gamerulesGUIinfo.moveRule.current : undefined;\n\t// prettier-ignore\n\tconst enpassantcoords: Coords | undefined = gamerulesGUIinfo.enPassant !== undefined ? [gamerulesGUIinfo.enPassant.x, gamerulesGUIinfo.enPassant.y] : undefined;\n\n\treturn {\n\t\tgameRules,\n\t\tmoveRuleState,\n\t\tenpassantcoords,\n\t};\n}\n\n/**\n * Update the game rules object keeping track of all current game rules by using new gameRules and state_global.\n * Optionally, pawnDoublePush and castling can also be passed into this function, if they should take values other than undefined.\n * Optionally, an Edit object can be passed to this function if the board state should be updated\n */\nfunction setGamerulesGUIinfo(\n\tgameRules: GameRules,\n\tstate_global: Partial<GlobalGameState>,\n\tpawnDoublePush: boolean | undefined,\n\tcastling: boolean | undefined,\n): void {\n\tconst firstPlayer = gameRules.turnOrder[0];\n\t// prettier-ignore\n\tgamerulesGUIinfo.playerToMove = firstPlayer === p.WHITE ? 'white' : firstPlayer === p.BLACK ? 'black' : (() => { throw new Error('Invalid first player'); })(); // Future protection\n\n\tif (state_global.enpassant !== undefined) {\n\t\tgamerulesGUIinfo.enPassant = {\n\t\t\tx: state_global.enpassant.square[0],\n\t\t\ty: state_global.enpassant.square[1],\n\t\t};\n\t} else {\n\t\tgamerulesGUIinfo.enPassant = undefined;\n\t}\n\n\tif (gameRules.moveRule !== undefined) {\n\t\tgamerulesGUIinfo.moveRule = {\n\t\t\tcurrent: state_global.moveRuleState || 0,\n\t\t\tmax: gameRules.moveRule,\n\t\t};\n\t} else {\n\t\tgamerulesGUIinfo.moveRule = undefined;\n\t}\n\n\tif (gameRules.promotionRanks !== undefined) {\n\t\tgamerulesGUIinfo.promotionRanks = {\n\t\t\twhite: gameRules.promotionRanks[p.WHITE],\n\t\t\tblack: gameRules.promotionRanks[p.BLACK],\n\t\t};\n\t} else {\n\t\tgamerulesGUIinfo.promotionRanks = undefined;\n\t}\n\n\tif (gameRules.promotionsAllowed !== undefined) {\n\t\tgamerulesGUIinfo.promotionsAllowed = [\n\t\t\t...new Set([\n\t\t\t\t...(gameRules.promotionsAllowed[p.WHITE] || []),\n\t\t\t\t...(gameRules.promotionsAllowed[p.BLACK] || []),\n\t\t\t]),\n\t\t];\n\t\tif (gamerulesGUIinfo.promotionsAllowed.length === 0)\n\t\t\tgamerulesGUIinfo.promotionsAllowed = undefined;\n\t} else {\n\t\tgamerulesGUIinfo.promotionsAllowed = undefined;\n\t}\n\n\tgamerulesGUIinfo.winConditions = [\n\t\t...new Set([\n\t\t\t...(gameRules.winConditions[p.WHITE] || [icnconverter.default_win_condition]),\n\t\t\t...(gameRules.winConditions[p.BLACK] || [icnconverter.default_win_condition]),\n\t\t]),\n\t];\n\n\t// Update pawn double push specialrights of position, if necessary\n\tgamerulesGUIinfo.pawnDoublePush = pawnDoublePush;\n\t// Update castling with rooks specialrights of position, if necessary\n\tgamerulesGUIinfo.castling = castling;\n\n\t// Read World Border from the gamefile\n\tgamerulesGUIinfo.worldBorder = gameRules.worldBorder;\n\n\t// Update gamefile properties for rendering purposes and correct legal move calculation\n\t// prettier-ignore\n\tconst enpassantSquare: Coords | undefined = gamerulesGUIinfo.enPassant !== undefined ? [gamerulesGUIinfo.enPassant.x, gamerulesGUIinfo.enPassant.y] : undefined;\n\tupdateGamefileProperties(\n\t\tenpassantSquare,\n\t\tgamerulesGUIinfo.promotionRanks,\n\t\tgamerulesGUIinfo.playerToMove,\n\t\tgamerulesGUIinfo.worldBorder,\n\t);\n\n\tguigamerules.setGameRules(gamerulesGUIinfo); // Update the game rules GUI\n}\n\n/**\n * This gets called when undoing or redoing moves, to forget the pawnDoublePush and castling entries of the gamerules\n * since we do not keep track of the checkbox state between edits.\n * This also gets called when resetting the position.\n * @param value - The value to set pawnDoublePush and castling to, or undefined to set them to indeterminate.\n */\nfunction setPositionDependentGameRules(\n\toptions: { pawnDoublePush?: boolean | undefined; castling?: boolean | undefined } = {},\n): void {\n\tgamerulesGUIinfo.pawnDoublePush = options.pawnDoublePush;\n\tgamerulesGUIinfo.castling = options.castling;\n\n\tguigamerules.setGameRules(gamerulesGUIinfo); // Update the game rules GUI\n}\n\nfunction getPositionDependentGameRules(): {\n\tpawnDoublePush: boolean | undefined;\n\tcastling: boolean | undefined;\n} {\n\treturn {\n\t\tpawnDoublePush: gamerulesGUIinfo.pawnDoublePush,\n\t\tcastling: gamerulesGUIinfo.castling,\n\t};\n}\n\n/** Update the game rules object keeping track of all current game rules by using changes from guiboardeditor */\nfunction updateGamerulesGUIinfo(new_gamerulesGUIinfo: GameRulesGUIinfo): void {\n\tgamerulesGUIinfo = new_gamerulesGUIinfo;\n}\n\n/**\n * When a special rights change gets queued, this function gets called\n * to potentially set gamerulesGUIinfo.pawnDoublePush and gamerulesGUIinfo.castling to indeterminate\n * @param type - The piece type whose special right is being changed\n * @param future - The future value of the special right being changed\n */\nfunction updateGamerulesUponQueueToggleSpecialRight(type: number, future: boolean): void {\n\tif (gamerulesGUIinfo.pawnDoublePush !== undefined) {\n\t\tconst rawtype = typeutil.getRawType(type);\n\t\tif (pawnDoublePushTypes.includes(rawtype) && gamerulesGUIinfo.pawnDoublePush !== future)\n\t\t\tgamerulesGUIinfo.pawnDoublePush = undefined;\n\t}\n\n\tif (gamerulesGUIinfo.castling !== undefined) {\n\t\tconst rawtype = typeutil.getRawType(type);\n\t\tif (castlingTypes.includes(rawtype)) {\n\t\t\tif (gamerulesGUIinfo.castling !== future) gamerulesGUIinfo.castling = undefined;\n\t\t} else if (!pawnDoublePushTypes.includes(rawtype)) {\n\t\t\tif (future) gamerulesGUIinfo.castling = undefined;\n\t\t}\n\t}\n\n\tguigamerules.setGameRules(gamerulesGUIinfo); // Update the game rules GUI\n}\n\n// Updating Special Rights -------------------------------------------------------------\n\n/** Gives or removes all special rights of pawns according to the value of pawnDoublePush. */\nfunction queueToggleGlobalPawnDoublePush(pawnDoublePush: boolean, edit: Edit): void {\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst pieces = gamefile.boardsim.pieces;\n\n\tfor (const idx of pieces.coords.values()) {\n\t\tconst piece: Piece = boardutil.getDefinedPieceFromIdx(pieces, idx)!;\n\t\tif (pawnDoublePushTypes.includes(typeutil.getRawType(piece.type)))\n\t\t\tedithistory.queueSpecialRights(gamefile, edit, piece.coords, pawnDoublePush);\n\t}\n}\n\n/** Gives or removes all special rights of rooks and jumping royals according to the value of castling. */\nfunction queueToggleGlobalCastlingWithRooks(castling: boolean, edit: Edit): void {\n\tif (!boardeditor.areInBoardEditor()) return;\n\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst pieces = gamefile.boardsim.pieces;\n\n\tfor (const idx of pieces.coords.values()) {\n\t\tconst piece: Piece = boardutil.getDefinedPieceFromIdx(pieces, idx)!;\n\t\tconst rawType = typeutil.getRawType(piece.type);\n\t\tif (castlingTypes.includes(rawType))\n\t\t\tedithistory.queueSpecialRights(gamefile, edit, piece.coords, castling);\n\t\telse if (!pawnDoublePushTypes.includes(rawType))\n\t\t\tedithistory.queueSpecialRights(gamefile, edit, piece.coords, false);\n\t}\n}\n\n// Updating Gamefile State -------------------------------------------------------------\n\n/**\n * Updates the en passant square, promotion lines, and turn order in the current gamefile.\n * Needed for display purposes and correct legal move calculation.\n */\nfunction updateGamefileProperties(\n\tenpassantCoords: Coords | undefined,\n\tpromotionRanks: { white?: bigint[]; black?: bigint[] } | undefined,\n\tplayerToMove: 'white' | 'black',\n\tworldBorder: UnboundedRectangle | undefined,\n): void {\n\tconst gamefile = gameslot.getGamefile()!;\n\n\t// Update en passant state for rendering purposes, and correct enpassant legality calculation\n\tif (enpassantCoords === undefined) {\n\t\tgamefile.boardsim.state.global.enpassant = undefined;\n\t} else {\n\t\t// prettier-ignore\n\t\tconst pawn: Coords = playerToMove === 'white' ? [enpassantCoords[0], enpassantCoords[1] - 1n] : playerToMove === 'black' ? [enpassantCoords[0], enpassantCoords[1] + 1n] : (() => { throw new Error(\"Invalid player to move\"); })(); // Future protection\n\t\tconst enpassant: EnPassant = { square: enpassantCoords, pawn };\n\t\tgamefile.boardsim.state.global.enpassant = enpassant;\n\t}\n\n\t// Update the promotionlines in the gamefile for rendering purposes\n\tif (promotionRanks === undefined) {\n\t\tgamefile.basegame.gameRules.promotionRanks = undefined;\n\t} else {\n\t\tgamefile.basegame.gameRules.promotionRanks = {};\n\t\tgamefile.basegame.gameRules.promotionRanks[p.WHITE] = promotionRanks.white;\n\t\tgamefile.basegame.gameRules.promotionRanks[p.BLACK] = promotionRanks.black;\n\t}\n\n\t// Update turn order so in the Normal tool, pawns correctly show enpassant as legal.\n\t// prettier-ignore\n\tgamefile.basegame.gameRules.turnOrder = playerToMove === 'white' ? [p.WHITE, p.BLACK] : playerToMove === 'black' ? [p.BLACK, p.WHITE] : (() => { throw new Error(\"Invalid player to move\"); })(); // Future protection\n\t// Update whosTurn as well\n\tgamefile.basegame.whosTurn = gamefile.basegame.gameRules.turnOrder[0]!;\n\n\t// Update World Border\n\tgamefile.basegame.gameRules.worldBorder = worldBorder;\n}\n\n// Exports -------------------------------------------------------------\n\nexport type { GameRulesGUIinfo };\n\nexport default {\n\tpawnDoublePushTypes,\n\tcastlingTypes,\n\t// Getting & Setting\n\tgetPlayerToMove,\n\tgetCurrentGamerulesAndState,\n\tsetGamerulesGUIinfo,\n\tsetPositionDependentGameRules,\n\tgetPositionDependentGameRules,\n\tupdateGamerulesGUIinfo,\n\tupdateGamerulesUponQueueToggleSpecialRight,\n\t// Updating Special Rights\n\tqueueToggleGlobalPawnDoublePush,\n\tqueueToggleGlobalCastlingWithRooks,\n\t// Updating Gamefile State\n\tupdateGamefileProperties,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/tools/drawingtool.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/tools/drawingtool.ts\n\n/**\n * Editor Drawing Tool\n *\n * Manages all drawing tools\n */\n\nimport type { Edit } from '../../../../../../shared/chess/logic/movepiece';\nimport type { Tool } from './etoolmanager';\nimport type { FullGame } from '../../../../../../shared/chess/logic/gamefile';\n\nimport state from '../../../../../../shared/chess/logic/state';\nimport bounds from '../../../../../../shared/util/math/bounds';\nimport boardutil, { Piece } from '../../../../../../shared/chess/util/boardutil';\nimport coordutil, { Coords } from '../../../../../../shared/chess/util/coordutil';\nimport typeutil, {\n\tPlayer,\n\tplayers as p,\n\trawTypes as r,\n} from '../../../../../../shared/chess/util/typeutil';\n\nimport mouse from '../../../util/mouse';\nimport arrows from '../../rendering/arrows/arrows';\nimport gameslot from '../../chess/gameslot';\nimport selection from '../../chess/selection';\nimport { Mouse } from '../../input';\nimport egamerules from '../egamerules';\nimport guipalette from '../../gui/boardeditor/guipalette';\nimport edithistory from '../edithistory';\nimport specialrighthighlights from '../../rendering/highlights/specialrighthighlights';\n\n// Constants -------------------------------------------------------\n\n/** All tools that support drawing. */\nconst drawingTools: Tool[] = ['placer', 'eraser', 'specialrights'];\n\n// State -----------------------------------------------------------\n\nlet currentColor: Player = p.WHITE;\nlet currentPieceType: number = typeutil.buildType(r.PAWN, currentColor);\n\n/**\n * Changes are stored in `thisEdit` until the user releases the button.\n * Grouping changes together allow the user to undo an entire\n * brush stroke at once instead of one piece at a time.\n */\nlet thisEdit: Edit | undefined;\n/** The ID of the pointer currently being used for drawing an edit with a DRAWING tool (excludes Selection tool) */\nlet drawingToolPointerId: string | undefined;\n\n/** Whether a drawing stroke is currently ongoing. */\nlet drawing = false;\n/** The last coordinate the stroke was over. */\nlet previousSquare: Coords | undefined;\n/** Whether special rights are currently being added or removed with the current drawing stroke. Undefined if neither. */\nlet addingSpecialRights: boolean | undefined;\n\n// Initialization ---------------------------------------------------------\n\nfunction init(): void {\n\tguipalette.updatePieceColors(currentColor);\n\tguipalette.markPiece(currentPieceType);\n}\n\nfunction onCloseEditor(): void {\n\tresetState();\n\tspecialrighthighlights.disable();\n}\n\nfunction resetState(): void {\n\tthisEdit = undefined;\n\tdrawingToolPointerId = undefined;\n\tdrawing = false;\n\tpreviousSquare = undefined;\n\taddingSpecialRights = undefined;\n}\n\n// Managing the Edit --------------------------------------------\n\nfunction beginEdit(): void {\n\tdrawing = true;\n\tthisEdit = { changes: [], state: { local: [], global: [] } };\n\t// Pieces must be unselected before they are modified\n\tselection.unselectPiece();\n}\n\nfunction endEdit(): void {\n\tif (!drawing || !thisEdit) return;\n\tedithistory.addEditToHistory(thisEdit);\n\tresetState();\n}\n\n/** Cancels the current edit, undoing any changes made during the stroke. */\nfunction cancelEdit(): void {\n\tif (!drawing || thisEdit === undefined) return;\n\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh()!;\n\t// Undo the changes made during this edit\n\tedithistory.runEdit(gamefile, mesh, thisEdit, false);\n\tresetState();\n}\n\n/** Handle starting and ending the drawing state */\nfunction update(currentTool: Tool): void {\n\tif (!drawingTools.includes(currentTool)) return; // Not using a drawing tool\n\n\tif (mouse.isMouseDown(Mouse.LEFT) && !drawing && !arrows.areHoveringAtleastOneArrow()) {\n\t\tmouse.claimMouseDown(Mouse.LEFT); // Remove the pointer down so other scripts don't use it\n\t\tmouse.cancelMouseClick(Mouse.LEFT); // Cancel any potential future click so other scripts don't use it\n\t\tdrawingToolPointerId = mouse.getMouseId(Mouse.LEFT)!;\n\t\tbeginEdit();\n\t} else if (!mouse.isMouseHeld(Mouse.LEFT) && drawing) return endEdit();\n\n\tif (!drawing || !thisEdit) return; // If not currently drawing, nothing more to do\n\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh()!;\n\tconst mouseCoords = mouse.getTileMouseOver_Integer();\n\tif (mouseCoords === undefined) return;\n\tif (previousSquare !== undefined && coordutil.areCoordsEqual(mouseCoords, previousSquare))\n\t\treturn; // We've already drawn on this square\n\tpreviousSquare = mouseCoords;\n\n\t// Make sure we don't paint outside the world border\n\tif (\n\t\tgamefile.basegame.gameRules.worldBorder &&\n\t\t!bounds.boxContainsSquare(gamefile.basegame.gameRules.worldBorder, mouseCoords)\n\t)\n\t\treturn;\n\n\tconst pieceHovered = boardutil.getPieceFromCoords(gamefile.boardsim.pieces, mouseCoords);\n\tconst edit: Edit = { changes: [], state: { local: [], global: [] } };\n\n\tswitch (currentTool) {\n\t\tcase 'placer': {\n\t\t\t// Replace piece logic. If we need this in more than one place, we can then make a queueReplacePiece() method.\n\t\t\tif (pieceHovered?.type === currentPieceType) break; // Equal to the new piece => don't replace\n\t\t\tif (pieceHovered) edithistory.queueRemovePiece(gamefile, edit, pieceHovered); // Delete existing piece first\n\t\t\t// Determine if special right should be given to the new piece, depending on gamerule checkboxes.\n\t\t\tconst { pawnDoublePush, castling } = egamerules.getPositionDependentGameRules();\n\t\t\t// prettier-ignore\n\t\t\tconst specialright: boolean = (\n\t\t\t\t(!!pawnDoublePush && egamerules.pawnDoublePushTypes.includes(typeutil.getRawType(currentPieceType))) ||\n\t\t\t\t(!!castling && egamerules.castlingTypes.includes(typeutil.getRawType(currentPieceType)))\n\t\t\t);\n\t\t\tedithistory.queueAddPiece(gamefile, edit, mouseCoords, currentPieceType, specialright);\n\t\t\tbreak;\n\t\t}\n\t\tcase 'eraser':\n\t\t\tif (pieceHovered) edithistory.queueRemovePiece(gamefile, edit, pieceHovered);\n\t\t\tbreak;\n\t\tcase 'specialrights':\n\t\t\tqueueToggleSpecialRight(gamefile, edit, pieceHovered);\n\t\t\tbreak;\n\t\tdefault:\n\t\t\tthrow Error('Tried to draw with a non-drawing tool.');\n\t}\n\n\tif (\n\t\tedit.changes.length === 0 &&\n\t\tedit.state.local.length === 0 &&\n\t\tedit.state.global.length === 0\n\t)\n\t\treturn;\n\tedithistory.runEdit(gamefile, mesh, edit, true);\n\tthisEdit.changes.push(...edit.changes);\n\tthisEdit.state.local.push(...edit.state.local);\n\tthisEdit.state.global.push(...edit.state.global);\n}\n\n/** Queues a specialrights state addition/deletion on the specified piece. */\nfunction queueToggleSpecialRight(\n\tgamefile: FullGame,\n\tedit: Edit,\n\tpieceHovered: Piece | undefined,\n): void {\n\tif (pieceHovered === undefined) return;\n\tconst coordsKey = coordutil.getKeyFromCoords(pieceHovered.coords);\n\tconst current = gamefile.boardsim.state.global.specialRights.has(coordsKey);\n\tconst future = !current;\n\n\tif (addingSpecialRights === undefined) addingSpecialRights = future;\n\telse if (addingSpecialRights !== future) return;\n\n\tstate.createSpecialRightsState(edit, coordsKey, current, future);\n\n\tegamerules.updateGamerulesUponQueueToggleSpecialRight(pieceHovered.type, future);\n}\n\n// API ---------------------------------------------------------\n\nfunction onToolChange(tool: Tool): void {\n\tendEdit();\n\n\tif (tool === 'specialrights') specialrighthighlights.enable();\n\telse specialrighthighlights.disable();\n\n\tif (tool !== 'placer') guipalette.markPiece(null);\n\telse guipalette.markPiece(currentPieceType);\n}\n\nfunction isEditInProgress(): boolean {\n\treturn drawing;\n}\n\nfunction isToolADrawingTool(tool: Tool): boolean {\n\treturn drawingTools.includes(tool);\n}\n\nfunction stealPointer(pointerIdToSteal: string): void {\n\tif (drawingToolPointerId !== pointerIdToSteal) return; // Not the pointer drawing the edit, don't stop using it.\n\tcancelEdit();\n}\n\n/** Set the piece type to be added to the board */\nfunction setPiece(pieceType: number): void {\n\tcurrentPieceType = pieceType;\n}\n\nfunction getPiece(): number {\n\treturn currentPieceType;\n}\n\nfunction setColor(color: Player): void {\n\tcurrentColor = color;\n}\n\nfunction getColor(): Player {\n\treturn currentColor;\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\t// Initialization\n\tinit,\n\tonCloseEditor,\n\tupdate,\n\t// API\n\tonToolChange,\n\tisEditInProgress,\n\tisToolADrawingTool,\n\tstealPointer,\n\tsetPiece,\n\tgetPiece,\n\tsetColor,\n\tgetColor,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/tools/etoolmanager.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/tools/etoolmanager.ts\n\n/**\n * Tool Manager for the Board Editor.\n *\n * Tracks the currently selected tool, handles tool switching,\n * keyboard shortcuts, and pointer reservation.\n */\n\nimport selection from '../../chess/selection.js';\nimport guitoolbar from '../../gui/boardeditor/guitoolbar.js';\nimport drawingtool from './drawingtool.js';\nimport perspective from '../../rendering/perspective.js';\nimport boardeditor from '../boardeditor.js';\nimport edithistory from '../edithistory.js';\nimport selectiontool from './selection/selectiontool.js';\nimport { listener_document } from '../../chess/game.js';\n\n// Types ----------------------------------------------------------------------\n\nexport type Tool = (typeof validTools)[number];\n\n// Constants ------------------------------------------------------------------\n\n/** All tools that can be used in the board editor. */\nconst validTools = ['normal', 'placer', 'eraser', 'specialrights', 'selection-tool'] as const;\n\n// State ----------------------------------------------------------------------\n\n/** The tool currently selected. */\nlet currentTool: Tool = 'normal';\n\n// Initialization -------------------------------------------------------------\n\n/** Resets the tool state when the board editor is closed. */\nfunction reset(): void {\n\tcurrentTool = 'normal';\n\tguitoolbar.markTool(currentTool); // Effectively resets classes state\n}\n\n// Tool Management ------------------------------------------------------------\n\n/** Returns the currently active tool. */\nfunction getTool(): Tool {\n\treturn currentTool;\n}\n\n/** Changes the active tool. */\nfunction setTool(tool: string): void {\n\tif (!validTools.includes(tool as Tool)) return console.error('Invalid tool: ' + tool);\n\tcurrentTool = tool as Tool;\n\tdrawingtool.onToolChange(currentTool);\n\n\t// Prevents you from being able to draw while a piece is selected.\n\t// Should this not always unselect when moving off the \"normal\" tool?\n\t// Buttons that perform one-time actions like \"clear\" or \"reset\" should not be treated as tools.\n\tselection.unselectPiece();\n\n\tguitoolbar.markTool(currentTool);\n\n\t// Reset selection tool state when switching to another tool\n\tselectiontool.resetState();\n}\n\n/** Whether any of the editor tools are actively using the left mouse button. */\nfunction isLeftMouseReserved(): boolean {\n\tif (!boardeditor.areInBoardEditor()) return false;\n\treturn drawingtool.isToolADrawingTool(currentTool) || currentTool === 'selection-tool';\n}\n\n/** If the given pointer is currently being used by a drawing tool for an edit, this stops using it. */\nfunction stealPointer(pointerIdToSteal: string): void {\n\tif (!boardeditor.areInBoardEditor()) return;\n\tif (currentTool === 'selection-tool')\n\t\treturn; // Don't steal (selection tool isn't capable of reverting to previous selection before starting a new one)\n\telse if (drawingtool.isToolADrawingTool(currentTool))\n\t\tdrawingtool.stealPointer(pointerIdToSteal);\n}\n\n// Shortcuts ------------------------------------------------------------------\n\n/** Tests for keyboard shortcuts in the board editor. */\nfunction testShortcuts(): void {\n\tif (perspective.getEnabled()) return; // Disable shortcuts while in perspective mode, WASD is reserved for camera movement\n\n\t// Select all\n\tif (listener_document.isKeyDown('KeyA', true)) selectiontool.selectAll();\n\n\t// Undo/Redo\n\tif (listener_document.isKeyDown('KeyY', true)) edithistory.redo();\n\tif (listener_document.isKeyDown('KeyZ', true, true))\n\t\tedithistory.redo(); // Also requires shift key\n\telse if (listener_document.isKeyDown('KeyZ', true)) edithistory.undo();\n\n\t// Tools\n\tif (listener_document.isKeyDown('KeyF')) setTool('normal');\n\telse if (listener_document.isKeyDown('KeyG')) setTool('eraser');\n\telse if (listener_document.isKeyDown('KeyH')) setTool('selection-tool');\n\telse if (listener_document.isKeyDown('KeyJ')) setTool('specialrights');\n\telse if (listener_document.isKeyDown('KeyK')) setTool('placer');\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\t// Initialization\n\treset,\n\t// Tool Management\n\tgetTool,\n\tsetTool,\n\tisLeftMouseReserved,\n\tstealPointer,\n\t// Shortcuts\n\ttestShortcuts,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/tools/normaltool.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/tools/normaltool.ts\n\n/**\n * Normal Tool for the Board Editor\n *\n * This tool can drag pieces around.\n */\n\nimport type { Mesh } from '../../rendering/piecemodels';\nimport type { Edit } from '../../../../../../shared/chess/logic/movepiece';\nimport type { MoveCoords } from '../../../../../../shared/chess/logic/icn/icnconverter';\nimport type { Board, FullGame } from '../../../../../../shared/chess/logic/gamefile';\n\nimport state from '../../../../../../shared/chess/logic/state';\nimport movepiece from '../../../../../../shared/chess/logic/movepiece';\nimport boardutil from '../../../../../../shared/chess/util/boardutil';\nimport coordutil from '../../../../../../shared/chess/util/coordutil';\n\nimport edithistory from '../edithistory';\nimport { GameBus } from '../../GameBus';\nimport movesequence from '../../chess/movesequence';\n\n// Making Move Edits in the Game ---------------------------------------------\n\n/**\n * Similar to {@link movesequence.makeMove}, but doesn't push the move to the game's\n * moves list, nor update gui, clocks, or do game over checks, nor the moveIndex property updated.\n */\nfunction makeMoveEdit(gamefile: FullGame, mesh: Mesh | undefined, moveCoords: MoveCoords): Edit {\n\tconst edit = generateMoveEdit(gamefile.boardsim, moveCoords);\n\n\tmovepiece.applyEdit(gamefile, edit, true, true); // forward & global are always true\n\tGameBus.dispatch('physical-move');\n\n\tif (mesh) movesequence.runMeshChanges(gamefile.boardsim, mesh, edit, true);\n\n\tedithistory.addEditToHistory(edit);\n\n\treturn edit;\n}\n\n/**\n * Similar to {@link movepiece.generateMove}, but specifically for editor moves,\n * which don't execute special moves, nor are appeneded to the game's moves list,\n * nor the gamefile's moveIndex property updated.\n */\nfunction generateMoveEdit(boardsim: Board, moveCoords: MoveCoords): Edit {\n\tconst piece = boardutil.getPieceFromCoords(boardsim.pieces, moveCoords.startCoords);\n\tif (!piece)\n\t\tthrow Error(\n\t\t\t`Cannot generate move edit because no piece exists at coords ${JSON.stringify(moveCoords.startCoords)}.`,\n\t\t);\n\n\t// Initialize the state, and change list, as empty for now.\n\tconst edit: Edit = {\n\t\tchanges: [],\n\t\tstate: { local: [], global: [] },\n\t};\n\n\tmovepiece.calcMovesChanges(boardsim, piece, moveCoords, edit); // Move piece regularly (no specials)\n\n\t// Queue the state change transfer of this edit's special right to its new destination.\n\tconst startCoordsKey = coordutil.getKeyFromCoords(moveCoords.startCoords);\n\tconst endCoordsKey = coordutil.getKeyFromCoords(moveCoords.endCoords);\n\tconst hasSpecialRight = boardsim.state.global.specialRights.has(startCoordsKey);\n\tconst destinationHasSpecialRight = boardsim.state.global.specialRights.has(endCoordsKey);\n\tstate.createSpecialRightsState(edit, startCoordsKey, hasSpecialRight, false); // Delete the special right from the startCoords, if it exists\n\tstate.createSpecialRightsState(edit, endCoordsKey, destinationHasSpecialRight, hasSpecialRight); // Transfer the special right to the endCoords, if it exists\n\n\treturn edit;\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\tmakeMoveEdit,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/tools/selection/scursor.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/tools/selection/scursor.ts\n\n/**\n * Selection Tool Cursor Style\n *\n * Handles changing the current cursor style of the canvas overlay\n * when hovering over the selection area's edges or fill handle.\n */\n\nimport game from '../../../chess/game';\n\n// Types ----------------------------------------------------\n\ntype Cursor = 'grab' | 'grabbing' | 'crosshair';\n\n// Constants ------------------------------------------------\n\n/** If multiple cursor styles are enabled, only the one with most priority is actually applied. */\nconst priority: Cursor[] = ['crosshair', 'grabbing', 'grab'];\n\n// State ----------------------------------------------------\n\n/** A list of all active cursor styles. */\nconst current: Set<Cursor> = new Set();\n\n// Methods --------------------------------------------------\n\n/** Adds a cursor style, immediately applying it if it has the highest priority. */\nfunction addCursor(cursor: Cursor): void {\n\tcurrent.add(cursor);\n\tupdateCursor();\n}\n\n/** Removes a cursor style, updating the current style to the next highest priority if needed. */\nfunction removeCursor(cursor: Cursor): void {\n\tcurrent.delete(cursor);\n\tupdateCursor();\n}\n\n/** Updates the current cursor style, if needed, to the highest priority active style. */\nfunction updateCursor(): void {\n\tconst overlay = game.getOverlay();\n\n\t// Set cursor to default if no cursor styles are active\n\tif (current.size === 0) {\n\t\toverlay.style.cursor = 'default';\n\t\treturn;\n\t}\n\n\t// Determine highest priority cursor style\n\tlet highestPrio: string;\n\tfor (const prioCursor of priority) {\n\t\tif (current.has(prioCursor)) {\n\t\t\thighestPrio = prioCursor;\n\t\t\tbreak;\n\t\t}\n\t}\n\n\tif (overlay.style.cursor === highestPrio!) return; // No change needed\n\n\toverlay.style.cursor = highestPrio!; // Apply new cursor style\n}\n\n// Exports ---------------------------------------------------\n\nexport default {\n\taddCursor,\n\tremoveCursor,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/tools/selection/sdrag.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/tools/selection/sdrag.ts\n\n/**\n * Selection Tool Drag\n *\n * This handles when the current selection has been grabbed on the edge,\n * and handles moving the selection.\n */\n\nimport bimath from '../../../../../../../shared/util/math/bimath';\nimport coordutil, { Coords } from '../../../../../../../shared/chess/util/coordutil';\nimport bounds, {\n\tBoundingBox,\n\tDoubleBoundingBox,\n} from '../../../../../../../shared/util/math/bounds';\n\nimport mouse from '../../../../util/mouse';\nimport space from '../../../misc/space';\nimport arrows from '../../../rendering/arrows/arrows';\nimport docutil from '../../../../util/docutil.js';\nimport scursor from './scursor';\nimport gameslot from '../../../chess/gameslot';\nimport { Mouse } from '../../../input';\nimport selectiontool from './selectiontool';\nimport stoolgraphics from './stoolgraphics';\nimport stransformations from './stransformations';\n\n// Constants -----------------------------------------\n\n/** The distance, in virtual screen pixels, that we may grab the edge of the selection box to drag it. */\nconst GRABBABLE_DIST = {\n\tDESKTOP: 6,\n\tMOBILE: 18,\n};\n\n// State ---------------------------------------------\n\n/** Whether the mouse is currently within the minimum distance to grab and drag the selection. */\nlet withinGrabDist = false;\n\n/** Whether we are currently dragging the selection. */\nlet areDragging = false;\n/** The ID of the pointer currently being used drag the selection. */\nlet pointerId: string | undefined = undefined;\n/** The last known square the pointer was hovering over. */\nlet lastPointerCoords: Coords | undefined;\n/** The integer coordinate the mouse has grabbed, if we're dragging the selection. */\nlet anchorCoords: Coords | undefined = undefined;\n\n// Methods -------------------------------------------\n\n/** Returns the grabbable distance in virtual pixels depending on whether a mouse or touch input is being used. */\nfunction getGrabbableDist(): number {\n\treturn docutil.isMouseSupported() ? GRABBABLE_DIST.DESKTOP : GRABBABLE_DIST.MOBILE;\n}\n\n/**\n * Updates the logic that handles dragging the selection box from the edges.\n * ONLY CALL if there's an existing selection area, and we are not currently making a new selection!\n */\nfunction update(): void {\n\tif (areDragging) {\n\t\t// Determine if the selection has been dropped\n\n\t\tconst respectiveListener = mouse.getRelevantListener();\n\t\t// Update its last known position if available\n\t\tif (respectiveListener.pointerExists(pointerId!))\n\t\t\tlastPointerCoords = mouse.getTilePointerOver_Integer(pointerId!)!;\n\t\t// Test if pointer released (execute selection translation)\n\t\tif (!respectiveListener.isPointerHeld(pointerId!)) dropSelection();\n\t} else {\n\t\t// Determine if the board needs to be picked up,\n\t\t// or if the canvas cursor style should change.\n\t\tif (isMouseHoveringOverSelectionEdge()) {\n\t\t\t// Within grab distance\n\t\t\tif (!withinGrabDist) {\n\t\t\t\twithinGrabDist = true;\n\t\t\t\tscursor.addCursor('grab');\n\t\t\t}\n\n\t\t\t// Determine if we picked up the selection\n\t\t\tif (mouse.isMouseDown(Mouse.LEFT) && !arrows.areHoveringAtleastOneArrow()) {\n\t\t\t\t// Start dragging\n\t\t\t\tmouse.claimMouseDown(Mouse.LEFT); // Remove the pointer down so other scripts don't use it\n\t\t\t\tmouse.cancelMouseClick(Mouse.LEFT); // Cancel any potential future click so other scripts don't use it\n\t\t\t\tpointerId = mouse.getMouseId(Mouse.LEFT)!;\n\t\t\t\tpickUpSelection();\n\t\t\t}\n\t\t} else {\n\t\t\t// NOT within grab distance\n\t\t\tif (withinGrabDist) {\n\t\t\t\twithinGrabDist = false;\n\t\t\t\tscursor.removeCursor('grab');\n\t\t\t}\n\t\t}\n\t}\n}\n\n/** Calculates whether the mouse is currently hovering within grab distance of the selection edge. */\nfunction isMouseHoveringOverSelectionEdge(): boolean {\n\tconst selectionWorldBox = selectiontool.getSelectionWorldBox()!;\n\n\t// Determine the mouse world coords\n\tconst mouseWorld = mouse.getMouseWorld(Mouse.LEFT);\n\tif (!mouseWorld) return false;\n\n\t// Convert grab distance to world space\n\tconst grabbableDist = space.convertPixelsToWorldSpace_Virtual(getGrabbableDist());\n\n\t// Determine if the mouse is within the grabbable edge area.\n\t// This is true if the mouse is inside the selection box expanded by the grab distance,\n\t// but not inside the selection box shrunk by the grab distance.\n\tconst mouseIsInOuterBox =\n\t\tmouseWorld[0] >= selectionWorldBox.left - grabbableDist &&\n\t\tmouseWorld[0] <= selectionWorldBox.right + grabbableDist &&\n\t\tmouseWorld[1] >= selectionWorldBox.bottom - grabbableDist &&\n\t\tmouseWorld[1] <= selectionWorldBox.top + grabbableDist;\n\tconst mouseIsInInnerBox =\n\t\tmouseWorld[0] > selectionWorldBox.left + grabbableDist &&\n\t\tmouseWorld[0] < selectionWorldBox.right - grabbableDist &&\n\t\tmouseWorld[1] > selectionWorldBox.bottom + grabbableDist &&\n\t\tmouseWorld[1] < selectionWorldBox.top - grabbableDist;\n\n\treturn mouseIsInOuterBox && !mouseIsInInnerBox;\n}\n\nfunction resetState(): void {\n\twithinGrabDist = false;\n\tscursor.removeCursor('grabbing');\n\tscursor.removeCursor('grab');\n\tareDragging = false;\n\tpointerId = undefined;\n\tlastPointerCoords = undefined;\n\tanchorCoords = undefined;\n}\n\n/** Grabs the selection box. */\nfunction pickUpSelection(): void {\n\tareDragging = true;\n\tscursor.addCursor('grabbing');\n\n\t// Determine the nearest coordinate of the selection the mouse picked up.\n\t// This will be the anchor\n\n\tconst selectionIntBox: BoundingBox = selectiontool.getSelectionIntBox()!;\n\tconst pointerCoordRounded: Coords = mouse.getTilePointerOver_Integer(pointerId!)!;\n\n\t// Clamp the pointer coord to the int box\n\tanchorCoords = [\n\t\tbimath.clamp(pointerCoordRounded[0], selectionIntBox.left, selectionIntBox.right),\n\t\tbimath.clamp(pointerCoordRounded[1], selectionIntBox.bottom, selectionIntBox.top),\n\t];\n\tlastPointerCoords = anchorCoords;\n}\n\nfunction dropSelection(): void {\n\t// Determine the final distance to translate the selection.\n\n\t// Determine by how many tiles the pointer has dragged from the anchor\n\tconst translation: Coords = coordutil.subtractCoords(lastPointerCoords!, anchorCoords!);\n\n\t// Reset state AFTER getting total translation\n\tresetState();\n\n\t// If the translation is zero, skip the transformation\n\tif (translation[0] === 0n && translation[1] === 0n) return;\n\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh()!;\n\tconst selectionBox: BoundingBox = selectiontool.getSelectionIntBox()!;\n\tstransformations.Translate(gamefile, mesh, selectionBox, translation);\n}\n\n// /**\n//  * Whether we are currently dragging the selection, AND\n//  * we have dragged it at least 1 square away from the anchor.\n//  */\n// function isDragTranslationPositive(): boolean {\n// \tif (!areDragging || !anchorCoords) return false;\n\n// \t// Determine the current int coord of the pointer\n// \tconst pointerCoordRounded: Coords = getIntCoordOfPointer();\n// \t// Determine by how many tiles the pointer has dragged from the anchor\n// \tconst translation: Coords = coordutil.subtractCoords(pointerCoordRounded, anchorCoords!);\n\n// \t// Return whether that's absolutely positive\n// \treturn translation[0] !== 0n || translation[1] !== 0n;\n// }\n\n// Rendering ---------------------------------------------\n\nfunction render(): void {\n\tif (!areDragging || !anchorCoords) return;\n\n\t// Determine the current int coord of the pointer\n\tconst pointerCoordRounded: Coords = mouse.getTilePointerOver_Integer(pointerId!)!;\n\n\t// Determine by how many tiles the pointer has dragged from the anchor\n\tconst translation: Coords = coordutil.subtractCoords(pointerCoordRounded, anchorCoords);\n\n\t// If the translation is zero, skip\n\tif (translation[0] === 0n && translation[1] === 0n) return;\n\n\tconst selectionIntBox: BoundingBox = selectiontool.getSelectionIntBox()!;\n\t// Transform the selection box so we can show graphically where it will be moved to, if let go now.\n\tconst translatedIntBox: BoundingBox = bounds.translateBoundingBox(selectionIntBox, translation);\n\n\t// Convert it to a world-space box, with edges rounded away to encapsulate the entirity of the squares.\n\tconst translatedWorldBox: DoubleBoundingBox =\n\t\tselectiontool.convertIntBoxToWorldBox(translatedIntBox);\n\n\tstoolgraphics.renderSelectionBoxWireframe(translatedWorldBox);\n\t// stoolgraphics.renderSelectionBoxFill(translatedWorldBox);\n}\n\n// Exports -----------------------------------------------\n\nexport default {\n\tgetGrabbableDist,\n\tupdate,\n\tresetState,\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/tools/selection/selectiontool.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/tools/selection/selectiontool.ts\n\n/**\n * The Selection Tool for the Board Editor\n *\n * Acts similarly to that of Google Sheets\n */\n\nimport type { Coords } from '../../../../../../../shared/chess/util/coordutil';\nimport type {\n\tBoundingBox,\n\tBoundingBoxBD,\n\tDoubleBoundingBox,\n} from '../../../../../../../shared/util/math/bounds';\n\nimport bimath from '../../../../../../../shared/util/math/bimath';\nimport boardutil from '../../../../../../../shared/chess/util/boardutil';\n\nimport mouse from '../../../../util/mouse';\nimport sfill from './sfill';\nimport sdrag from './sdrag';\nimport arrows from '../../../rendering/arrows/arrows';\nimport meshes from '../../../rendering/meshes';\nimport gameslot from '../../../chess/gameslot';\nimport { Mouse } from '../../../input';\nimport etoolmanager from '../etoolmanager';\nimport stoolgraphics from './stoolgraphics';\nimport stransformations from './stransformations';\nimport guipositionheader from '../../../gui/boardeditor/guipositionheader';\nimport { listener_document, listener_overlay } from '../../../chess/game';\n\n// State ----------------------------------------------\n\n/** Whether or now we are currently making a selection. */\nlet selecting: boolean = false;\n/** The ID of the pointer currently being used creating a selection. */\nlet pointerId: string | undefined = undefined;\n/** The last known square the pointer was hovering over. */\nlet lastPointerCoords: Coords | undefined;\n\n/** The square that the selection began at. */\nlet startPoint: Coords | undefined;\n/**\n * The square that the selection ends at.\n * ONLY DEFINED when we have an actual selection made already,\n * NOT when we are currently MAKING a selection.\n */\nlet endPoint: Coords | undefined;\n\n// Methods -------------------------------------------\n\nfunction update(): void {\n\tif (isExistingSelection()) testShortcuts(); // Is a current selection, or one is in progress\n\n\tif (!selecting) {\n\t\t// No selection in progress (either none made yet, or have already made one)\n\t\t// Update grabbing the selection box first\n\t\tif (isACurrentSelection()) {\n\t\t\tsfill.update(); // Update fill tool handler\n\t\t\tsdrag.update(); // Update selection box drag handler\n\t\t}\n\t\t// Test if a new selection is beginning\n\t\tif (mouse.isMouseDown(Mouse.LEFT) && !selecting && !arrows.areHoveringAtleastOneArrow()) {\n\t\t\t// Start new selection\n\t\t\tmouse.claimMouseDown(Mouse.LEFT); // Remove the pointer down so other scripts don't use it\n\t\t\tmouse.cancelMouseClick(Mouse.LEFT); // Cancel any potential future click so other scripts don't use it\n\t\t\tpointerId = mouse.getMouseId(Mouse.LEFT)!;\n\t\t\tbeginSelection();\n\t\t}\n\t} else {\n\t\t// Selection in progress\n\t\tconst respectiveListener = mouse.getRelevantListener();\n\t\t// Update its last known position if available\n\t\tif (respectiveListener.pointerExists(pointerId!))\n\t\t\tlastPointerCoords = mouse.getTilePointerOver_Integer(pointerId!)!;\n\t\t// Test if pointer released (finalize new selection)\n\t\tif (!respectiveListener.isPointerHeld(pointerId!)) endSelection();\n\t}\n}\n\n/** Tests for keyboard shortcuts while using the Selection Tool. */\nfunction testShortcuts(): void {\n\t// Delete selection\n\tif (listener_document.isKeyDown('Delete') || listener_document.isKeyDown('Backspace')) {\n\t\tconst gamefile = gameslot.getGamefile()!;\n\t\tconst mesh = gameslot.getMesh()!;\n\t\tconst selectionBox: BoundingBox = getSelectionIntBox()!;\n\t\tstransformations.Delete(gamefile, mesh, selectionBox);\n\t}\n}\n\nfunction beginSelection(): void {\n\t// console.log(\"Beginning selection\");\n\n\tstartPoint = undefined;\n\tendPoint = undefined;\n\tselecting = true;\n\tsfill.resetState();\n\tsdrag.resetState();\n\n\t// Set the start point\n\tstartPoint = mouse.getTilePointerOver_Integer(pointerId!)!;\n\tlastPointerCoords = startPoint;\n}\n\nfunction endSelection(): void {\n\t// console.error(\"Ending selection\");\n\n\t// Set the end point\n\tendPoint = lastPointerCoords;\n\tguipositionheader.onNewSelection();\n\n\tselecting = false;\n\tpointerId = undefined;\n}\n\n// function cancelSelection(): void {\n// \tresetState();\n// }\n\nfunction resetState(): void {\n\tselecting = false;\n\tpointerId = undefined;\n\tlastPointerCoords = undefined;\n\tstartPoint = undefined;\n\tendPoint = undefined;\n\tsfill.resetState();\n\tsdrag.resetState();\n\tguipositionheader.onClearSelection();\n}\n\n/** Whether there is a current selection, NOT whether we are currently MAKING a selection. */\nfunction isACurrentSelection(): boolean {\n\treturn !!startPoint && !!endPoint;\n}\n\n/**\n * Returns whether there is a current selection, or one in progress.\n * Also considered whether a selection area is renderable or not.\n */\nfunction isExistingSelection(): boolean {\n\treturn !!selecting || !!endPoint;\n}\n\nfunction render(): void {\n\tif (isExistingSelection()) {\n\t\t// There either is a selection, or we are currently making one\n\t\tconst selectionWorldBox = getSelectionWorldBox()!;\n\n\t\t// Render the selection box\n\t\tstoolgraphics.renderSelectionBoxWireframe(selectionWorldBox);\n\t\tstoolgraphics.renderSelectionBoxFill(selectionWorldBox);\n\n\t\tif (isACurrentSelection()) {\n\t\t\t// Render the small square in the corner\n\t\t\tstoolgraphics.renderCornerSquare(selectionWorldBox);\n\t\t\tsfill.render(); // Fill tool graphics\n\t\t\tsdrag.render(); // Selection drag graphics\n\t\t}\n\t} else {\n\t\t// No selection, and not currently making one\n\t\tif (listener_overlay.getAllPhysicalPointers().length > 1) return; // Don't render if multiple fingers down\n\t\t// Outline the rank and file of the square hovered over\n\t\tstoolgraphics.outlineRankAndFile();\n\t}\n}\n\n/** Returns the integer coordinate bounding box of our selection area. */\nfunction getSelectionIntBox(): BoundingBox | undefined {\n\tconst currentTile: Coords | undefined = endPoint || lastPointerCoords;\n\tif (!startPoint || !currentTile) return;\n\n\treturn {\n\t\tleft: bimath.min(startPoint[0], currentTile[0]),\n\t\tright: bimath.max(startPoint[0], currentTile[0]),\n\t\tbottom: bimath.min(startPoint[1], currentTile[1]),\n\t\ttop: bimath.max(startPoint[1], currentTile[1]),\n\t};\n}\n\n/** Calculates the world space edge coordinates of the current selection box. */\nfunction getSelectionWorldBox(): DoubleBoundingBox | undefined {\n\tconst intBox = getSelectionIntBox();\n\tif (!intBox) return;\n\n\treturn convertIntBoxToWorldBox(intBox);\n}\n\n/**\n * Converts an int selection box to a world-space box, rounding away\n * its edges outward to encapsulate the entirity of the squares.\n */\nfunction convertIntBoxToWorldBox(intBox: BoundingBox): DoubleBoundingBox {\n\t// Moves the edges of the box outward to encapsulate the entirity of the squares, instead of just the centers.\n\tconst roundedAwayBox: BoundingBoxBD =\n\t\tmeshes.expandTileBoundingBoxToEncompassWholeSquare(intBox);\n\t// Convert it to a world-space box\n\treturn meshes.applyWorldTransformationsToBoundingBox(roundedAwayBox);\n}\n\n/**\n * Returns the corners of the current selection.\n * ONLY CALL if you know a selection exists!\n */\nfunction getSelectionCorners(): [Coords, Coords] {\n\tif (!startPoint || !endPoint)\n\t\tthrow new Error(\"No current selection. Can't get selection corners.\");\n\n\treturn [startPoint, endPoint];\n}\n\n/**\n * Sets the current selected area.\n * ONLY CALL if this is an overwriting of the existing\n * selection, NOT to set it when it does not have a value!\n */\nfunction setSelection(corner1: Coords, corner2: Coords): void {\n\tif (!startPoint || !endPoint) throw new Error(\"No current selection. Can't set selection.\");\n\n\tstartPoint = corner1;\n\tendPoint = corner2;\n}\n\n/** Selects all pieces in the current position, and transitions to the selection. */\nfunction selectAll(): void {\n\tetoolmanager.setTool('selection-tool'); // Switch if we're not already using\n\n\tconst box = boardutil.getBoundingBoxOfAllPieces(gameslot.getGamefile()!.boardsim.pieces);\n\n\tif (box === undefined) {\n\t\t// No pieces, cancel selection\n\t\tresetState();\n\t\t// Disabled for now as I'm not sure I like Selecting all immediately transitioning\n\t\t// guinavigation.recenter();\n\t\treturn;\n\t}\n\n\tstartPoint = [box.left, box.top];\n\tendPoint = [box.right, box.bottom];\n\n\tguipositionheader.onNewSelection();\n\t// Disabled for now as I'm not sure I like Selecting all immediately transitioning\n\t// Transition.zoomToCoordsBox(box);\n}\n\n// Exports ------------------------------------------------------\n\nexport default {\n\tupdate,\n\tresetState,\n\tisExistingSelection,\n\trender,\n\tgetSelectionIntBox,\n\tgetSelectionWorldBox,\n\tconvertIntBoxToWorldBox,\n\tgetSelectionCorners,\n\tsetSelection,\n\tselectAll,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/tools/selection/sfill.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/tools/selection/sfill.ts\n\n/**\n * Selection Tool Fill\n *\n * This handles the fill operation when dragging the fill handle\n * on the bottom-right corner of the selection box.\n */\n\nimport type { Coords, DoubleCoords } from '../../../../../../../shared/chess/util/coordutil';\n\nimport bimath from '../../../../../../../shared/util/math/bimath';\nimport vectors from '../../../../../../../shared/util/math/vectors';\nimport bounds, {\n\tBoundingBox,\n\tDoubleBoundingBox,\n} from '../../../../../../../shared/util/math/bounds';\n\nimport mouse from '../../../../util/mouse';\nimport space from '../../../misc/space';\nimport sdrag from './sdrag';\nimport arrows from '../../../rendering/arrows/arrows';\nimport scursor from './scursor';\nimport gameslot from '../../../chess/gameslot';\nimport { Mouse } from '../../../input';\nimport selectiontool from './selectiontool';\nimport stoolgraphics from './stoolgraphics';\nimport stransformations from './stransformations';\n\n// State ---------------------------------------------\n\n/** Whether the mouse is currently within the minimum distance to grab the fill handle. */\nlet withinGrabDist = false;\n\n/** Whether we are currently dragging the selection. */\nlet areFilling = false;\n/** The ID of the pointer currently being used drag the selection. */\nlet pointerId: string | undefined = undefined;\n/** The last known square the pointer was hovering over. */\nlet lastPointerCoords: Coords | undefined;\n\n// Methods -------------------------------------------\n\n/** Returns whether we are currently filling. */\nfunction areWeFilling(): boolean {\n\treturn areFilling;\n}\n\n/**\n * Updates the logic that handles dragging the selection box from the edges.\n * ONLY CALL if there's an existing selection area, and we are not currently making a new selection!\n */\nfunction update(): void {\n\tif (areFilling) {\n\t\t// Determine if the selection has been dropped\n\n\t\tconst respectiveListener = mouse.getRelevantListener();\n\t\t// Update its last known position if available\n\t\tif (respectiveListener.pointerExists(pointerId!))\n\t\t\tlastPointerCoords = mouse.getTilePointerOver_Integer(pointerId!)!;\n\t\t// Test if pointer released (execute selection translation)\n\t\tif (!respectiveListener.isPointerHeld(pointerId!)) executeFill();\n\t} else {\n\t\t// Determine if the fill handle needs to be grabbed,\n\t\t// or if the canvas cursor style should change.\n\t\tif (isMouseHoveringOverFillHandle()) {\n\t\t\t// Within grab distance\n\t\t\tif (!withinGrabDist) {\n\t\t\t\twithinGrabDist = true;\n\t\t\t\tscursor.addCursor('crosshair');\n\t\t\t}\n\n\t\t\t// Determine if we started dragging the fill handle\n\t\t\tif (mouse.isMouseDown(Mouse.LEFT) && !arrows.areHoveringAtleastOneArrow()) {\n\t\t\t\t// Start dragging\n\t\t\t\tmouse.claimMouseDown(Mouse.LEFT); // Remove the pointer down so other scripts don't use it\n\t\t\t\tmouse.cancelMouseClick(Mouse.LEFT); // Cancel any potential future click so other scripts don't use it\n\t\t\t\tpointerId = mouse.getMouseId(Mouse.LEFT)!;\n\t\t\t\tstartFill();\n\t\t\t}\n\t\t} else {\n\t\t\t// NOT within grab distance\n\t\t\tif (withinGrabDist) {\n\t\t\t\twithinGrabDist = false;\n\t\t\t\tscursor.removeCursor('crosshair');\n\t\t\t}\n\t\t}\n\t}\n}\n\n/** Calculates whether the mouse is currently hovering within grab distance of the fill handle. */\nfunction isMouseHoveringOverFillHandle(): boolean {\n\tconst selectionWorldBox = selectiontool.getSelectionWorldBox()!;\n\tconst fillHandleCorner: DoubleCoords = [\n\t\t// Bottom-right corner\n\t\tselectionWorldBox.right,\n\t\tselectionWorldBox.bottom,\n\t];\n\n\t// Determine the mouse world coords\n\tconst mouseWorld = mouse.getMouseWorld(Mouse.LEFT);\n\tif (!mouseWorld) return false;\n\n\t// Convert grab distance to world space\n\tconst grabbableDist = space.convertPixelsToWorldSpace_Virtual(sdrag.getGrabbableDist());\n\n\t// Determine the distance from the mouse to the fill handle corner\n\tconst distToFillHandle = vectors.chebyshevDistanceDoubles(mouseWorld, fillHandleCorner);\n\n\t// Return whether it's within grab distance\n\treturn distToFillHandle <= grabbableDist;\n}\n\nfunction resetState(): void {\n\twithinGrabDist = false;\n\tscursor.removeCursor('crosshair');\n\tareFilling = false;\n\tpointerId = undefined;\n\tlastPointerCoords = undefined;\n}\n\n/** Grabs the selection box. */\nfunction startFill(): void {\n\tareFilling = true;\n\n\tlastPointerCoords = mouse.getTilePointerOver_Integer(pointerId!)!;\n}\n\nfunction executeFill(): void {\n\tconst fillBox = calculateFillBox();\n\n\t// Reset state AFTER calculating fill box\n\tresetState();\n\n\tif (!fillBox) return; // No fill to perform (let go within selection box)\n\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh()!;\n\tconst selectionBox: BoundingBox = selectiontool.getSelectionIntBox()!;\n\tstransformations.Fill(gamefile, mesh, selectionBox, fillBox);\n}\n\n/**\n * Determines the fill axis and distance based on the current pointer position.\n */\nfunction calculateFillBox(): BoundingBox | undefined {\n\tconst selectionBox: BoundingBox = selectiontool.getSelectionIntBox()!;\n\n\t// If the pointer is contained within the selection box, skip\n\tif (bounds.boxContainsSquare(selectionBox, lastPointerCoords!)) return;\n\n\tconst distXFromLeft = lastPointerCoords![0] - selectionBox.left;\n\tconst distXFromRight = lastPointerCoords![0] - selectionBox.right;\n\tconst distYFromBottom = lastPointerCoords![1] - selectionBox.bottom;\n\tconst distYFromTop = lastPointerCoords![1] - selectionBox.top;\n\n\tconst distXChoice =\n\t\tdistXFromRight > 0n ? distXFromRight : distXFromLeft < 0n ? distXFromLeft : 0n;\n\tconst distYChoice =\n\t\tdistYFromTop > 0n ? distYFromTop : distYFromBottom < 0n ? distYFromBottom : 0n;\n\n\t// Determine which axis has the larger distance from the selection box\n\tif (bimath.abs(distXChoice) >= bimath.abs(distYChoice)) {\n\t\t// X Axis\n\t\tif (distXChoice > 0n) {\n\t\t\t// Filling to the right\n\t\t\treturn {\n\t\t\t\tleft: selectionBox.right + 1n,\n\t\t\t\tright: lastPointerCoords![0],\n\t\t\t\tbottom: selectionBox.bottom,\n\t\t\t\ttop: selectionBox.top,\n\t\t\t};\n\t\t} else {\n\t\t\t// Filling to the left\n\t\t\treturn {\n\t\t\t\tleft: lastPointerCoords![0],\n\t\t\t\tright: selectionBox.left - 1n,\n\t\t\t\tbottom: selectionBox.bottom,\n\t\t\t\ttop: selectionBox.top,\n\t\t\t};\n\t\t}\n\t} else {\n\t\t// Y axis\n\t\tif (distYChoice > 0n) {\n\t\t\t// Filling upwards\n\t\t\treturn {\n\t\t\t\tleft: selectionBox.left,\n\t\t\t\tright: selectionBox.right,\n\t\t\t\tbottom: selectionBox.top + 1n,\n\t\t\t\ttop: lastPointerCoords![1],\n\t\t\t};\n\t\t} else {\n\t\t\t// Filling downwards\n\t\t\treturn {\n\t\t\t\tleft: selectionBox.left,\n\t\t\t\tright: selectionBox.right,\n\t\t\t\tbottom: lastPointerCoords![1],\n\t\t\t\ttop: selectionBox.bottom - 1n,\n\t\t\t};\n\t\t}\n\t}\n}\n\n// Rendering ---------------------------------------------\n\nfunction render(): void {\n\tif (!areFilling) return;\n\n\t// Determine the fill int box to render depending on the state\n\tconst fillBox = calculateFillBox();\n\tif (!fillBox) return; // No fill to perform (let go within selection box)\n\n\t// Convert it to a world-space box, with edges rounded away to encapsulate the entirity of the squares.\n\tconst worldFillBox: DoubleBoundingBox = selectiontool.convertIntBoxToWorldBox(fillBox);\n\n\tstoolgraphics.renderSelectionBoxWireframeDashed(worldFillBox);\n}\n\n// Exports -----------------------------------------------\n\nexport default {\n\tareWeFilling,\n\tupdate,\n\tresetState,\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/tools/selection/stoolgraphics.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/tools/selection/stoolgraphics.ts\n\n/**\n * Selection Tool Graphics\n *\n * Contains the methods for rendering the graphics\n * of the Selection Tool in the Board Editor\n */\n\nimport type { Color } from '../../../../../../../shared/util/math/math';\nimport type { DoubleBoundingBox } from '../../../../../../../shared/util/math/bounds';\nimport type { Coords, DoubleCoords } from '../../../../../../../shared/chess/util/coordutil';\n\nimport bounds from '../../../../../../../shared/util/math/bounds';\n\nimport mouse from '../../../../util/mouse';\nimport space from '../../../misc/space';\nimport camera from '../../../rendering/camera';\nimport meshes from '../../../rendering/meshes';\nimport primitives from '../../../rendering/primitives';\nimport { createRenderable } from '../../../../webgl/Renderable';\n\n// Constants ---------------------------------------------------\n\n/**\n * The color for the wireframe of the selection box, including the small square in the corner,\n * and the outline of the currently hovered square's rank & file, when there is no selection.\n */\nconst OUTLINE_COLOR: Color = [0, 0, 0, 1]; // Black\n/** The fill color of the selection box. */\nconst FILL_COLOR: Color = [0, 0, 0, 0.08]; // Transparent Black\n\n/** How many virtual screen pixels wide the corner square is. */\nconst CORNER_DOT_WIDTH = 6;\n\n/** How many virtual screen pixels wide the dashed outlines are. */\nconst DASHED_WIDTH = 1;\n/** How many virtual screen pixels long the dashes are. */\nconst DASHED_LENGTH = 6;\n\n// Methods -----------------------------------------------------\n\n/**\n * Outlines the current rank and file of the square\n * the mouse is currently hovering over, for a total of 4 lines.\n */\nfunction outlineRankAndFile(): void {\n\t// Determine what square the mouse is hovering over\n\tconst currentTile: Coords | undefined = mouse.getTileMouseOver_Integer();\n\tif (!currentTile) return;\n\n\t// The coordinates of the edges of the square\n\tconst { left, right, bottom, top } = meshes.getCoordBoxWorld(currentTile);\n\n\tconst data: number[] = [];\n\n\tconst screenBox = camera.getRespectiveScreenBox();\n\n\t// prettier-ignore\n\tdata.push(\n\t\t// Horizontal: Lower\n\t\tscreenBox.left, bottom,   ...OUTLINE_COLOR,\n\t\tscreenBox.right, bottom,  ...OUTLINE_COLOR,\n\t\t// Horizontal: Upper\n\t\tscreenBox.left, top,      ...OUTLINE_COLOR,\n\t\tscreenBox.right, top,     ...OUTLINE_COLOR,\n\t\t// Vertical: Lefter\n\t\tleft, screenBox.bottom,   ...OUTLINE_COLOR,\n\t\tleft, screenBox.top,      ...OUTLINE_COLOR,\n\t\t// Vertical: Righter\n\t\tright, screenBox.bottom,  ...OUTLINE_COLOR,\n\t\tright, screenBox.top,     ...OUTLINE_COLOR,\n\t);\n\n\tcreateRenderable(data, 2, 'LINES', 'color', true).render();\n}\n\n/**\n * Renders a wireframe box around the provided box.\n * @param worldBox - Contains the world space edge coordinates of the box.\n */\nfunction renderSelectionBoxWireframe(worldBox: DoubleBoundingBox): void {\n\t// Clamp to screen box to prevent overflow glitches when the box is very large.\n\tconst screenBox = camera.getRespectiveScreenBox();\n\tconst clampedBox = bounds.clampDoubleBoundingBox(worldBox, screenBox);\n\tif (bounds.areBoxesDisjoint(clampedBox, screenBox)) return; // Box is off-screen -> not visible\n\n\tconst data: number[] = primitives.Rect(\n\t\tclampedBox.left,\n\t\tclampedBox.bottom,\n\t\tclampedBox.right,\n\t\tclampedBox.top,\n\t\tOUTLINE_COLOR,\n\t);\n\tcreateRenderable(data, 2, 'LINE_LOOP', 'color', true).render();\n}\n\n/**\n * Renders a dashed wireframe box around the provided box.\n * @param worldBox - Contains the world space edge coordinates of the box.\n */\nfunction renderSelectionBoxWireframeDashed(worldBox: DoubleBoundingBox): void {\n\t// Clamp to screen box to prevent overflow glitches when the box is very large.\n\tconst screenBox = camera.getRespectiveScreenBox();\n\tconst clampedBox = bounds.clampDoubleBoundingBox(worldBox, screenBox);\n\tif (bounds.areBoxesDisjoint(clampedBox, screenBox)) return; // Box is off-screen -> not visible\n\n\t// Convert virtual pixel dimensions to world space\n\tconst dashedWidth = space.convertPixelsToWorldSpace_Virtual(DASHED_WIDTH);\n\tconst dashedLength = space.convertPixelsToWorldSpace_Virtual(DASHED_LENGTH);\n\n\tconst data: number[] = primitives.DashedRect(\n\t\tclampedBox.left,\n\t\tclampedBox.bottom,\n\t\tclampedBox.right,\n\t\tclampedBox.top,\n\t\tdashedWidth,\n\t\tdashedLength,\n\t\tdashedLength,\n\t\tOUTLINE_COLOR,\n\t);\n\tcreateRenderable(data, 2, 'TRIANGLES', 'color', true).render();\n}\n\n/**\n * Renders a filled transparent box inside the provided box.\n * @param worldBox - Contains the world space edge coordinates of the box.\n */\nfunction renderSelectionBoxFill(worldBox: DoubleBoundingBox): void {\n\t// Clamp to screen box to prevent overflow glitches when the box is very large.\n\tconst screenBox = camera.getRespectiveScreenBox();\n\tconst clampedBox = bounds.clampDoubleBoundingBox(worldBox, screenBox);\n\tif (bounds.areBoxesDisjoint(clampedBox, screenBox)) return; // Box is off-screen -> not visible\n\n\tconst fillData: number[] = primitives.Quad_Color(\n\t\tclampedBox.left,\n\t\tclampedBox.bottom,\n\t\tclampedBox.right,\n\t\tclampedBox.top,\n\t\tFILL_COLOR,\n\t);\n\tcreateRenderable(fillData, 2, 'TRIANGLES', 'color', true).render();\n}\n\n/**\n * Renders the small square in the corner of the selection box.\n * @param worldBox - Contains the world space edge coordinates of the selection box.\n */\nfunction renderCornerSquare(worldBox: DoubleBoundingBox): void {\n\t// Convert width to world space\n\tconst widthWorld = space.convertPixelsToWorldSpace_Virtual(CORNER_DOT_WIDTH);\n\n\t// Bottom right corner world space\n\tconst corner: DoubleCoords = [worldBox.right, worldBox.bottom];\n\n\t// Calculate vertex data\n\tconst left = corner[0] - widthWorld / 2;\n\tconst right = corner[0] + widthWorld / 2;\n\tconst bottom = corner[1] - widthWorld / 2;\n\tconst top = corner[1] + widthWorld / 2;\n\n\tconst fillData: number[] = primitives.Quad_Color(left, bottom, right, top, OUTLINE_COLOR);\n\tcreateRenderable(fillData, 2, 'TRIANGLES', 'color', true).render();\n}\n\n// Exports ----------------------------------------------------------\n\nexport default {\n\toutlineRankAndFile,\n\trenderSelectionBoxWireframe,\n\trenderSelectionBoxWireframeDashed,\n\trenderSelectionBoxFill,\n\trenderCornerSquare,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/boardeditor/tools/selection/stransformations.ts",
    "content": "// src/client/scripts/esm/game/boardeditor/tools/selection/stransformations.ts\n\n/**\n * Selection Tool Transformations\n *\n * Contains transformation functions for the current\n * selection from the Selection Tool in the Board Editor\n */\n\nimport type { Mesh } from '../../../rendering/piecemodels';\nimport type { Edit } from '../../../../../../../shared/chess/logic/movepiece';\nimport type { FullGame } from '../../../../../../../shared/chess/logic/gamefile';\nimport type { BoundingBox } from '../../../../../../../shared/util/math/bounds';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport bounds from '../../../../../../../shared/util/math/bounds';\nimport bimath from '../../../../../../../shared/util/math/bimath';\nimport typeutil from '../../../../../../../shared/chess/util/typeutil';\nimport bdcoords from '../../../../../../../shared/chess/util/bdcoords';\nimport organizedpieces from '../../../../../../../shared/chess/logic/organizedpieces';\nimport vectors, { Vec2 } from '../../../../../../../shared/util/math/vectors';\nimport boardutil, { Piece } from '../../../../../../../shared/chess/util/boardutil';\nimport coordutil, { BDCoords, Coords } from '../../../../../../../shared/chess/util/coordutil';\n\nimport edithistory from '../../edithistory';\nimport selectiontool from './selectiontool';\n\n// Types ---------------------------------------------------------------------\n\n/** A Piece object that also remembers its specialrights state. */\ninterface StatePiece extends Piece {\n\tspecialrights: boolean;\n}\n\n// Constants ------------------------------------------------------------------\n\nconst NEGONE = bd.fromBigInt(-1n, 1);\nconst HALF = bd.fromNumber(0.5, 1);\nconst ONE = bd.fromBigInt(1n, 1);\nconst TWO = bd.fromBigInt(2n, 1);\n\n// State ------------------------------------------------------------------------\n\n/** Whatever's copied to the clipboard via the \"Copy selection\" action button. */\nlet clipboard: StatePiece[] | undefined;\n/** The box containing all clipboard pieces. */\nlet clipboardBox: BoundingBox | undefined;\n\n/**\n * The parity of which vector the pivot point of rotations shifts\n * so as the pieces don't land on floating point coords after rotation.\n * This makes it so that 2 consecutive rotations return to the original position.\n */\nlet rotationParity: boolean = false;\n\n// Selection Box Transformations ------------------------------------------------\n\n/** Translates the selection by a given vector. */\nfunction Translate(\n\tgamefile: FullGame,\n\tmesh: Mesh,\n\tselectionBox: BoundingBox,\n\ttranslation: Coords,\n): void {\n\tconst destinationBox = bounds.translateBoundingBox(selectionBox, translation);\n\tconst newSelectionCorners: [Coords, Coords] = [\n\t\t[destinationBox.left, destinationBox.top],\n\t\t[destinationBox.right, destinationBox.bottom],\n\t];\n\t// A function controlling how each piece is transformed\n\tconst transformer = (piece: Piece): { coords: Coords; type: number } => ({\n\t\tcoords: coordutil.addCoords(piece.coords, translation),\n\t\ttype: piece.type,\n\t});\n\n\t// Execute the transformation\n\tTransform(gamefile, mesh, selectionBox, destinationBox, newSelectionCorners, transformer);\n}\n\n/** Extends the selection area by repeating its contents into the given fill box. */\nfunction Fill(\n\tgamefile: FullGame,\n\tmesh: Mesh,\n\tselectionBox: BoundingBox,\n\tfillBox: BoundingBox,\n): void {\n\tconst piecesInSelection: Piece[] = getPiecesInBox(gamefile, selectionBox);\n\tconst piecesInPasteBox: Piece[] = getPiecesInBox(gamefile, fillBox);\n\n\t// Determine the dimensions of the selection box\n\tconst selectionWidth: bigint = selectionBox.right - selectionBox.left + 1n;\n\tconst selectionHeight: bigint = selectionBox.top - selectionBox.bottom + 1n;\n\t// Dimensions of the fill box\n\tconst fillBoxWidth: bigint = fillBox.right - fillBox.left + 1n;\n\tconst fillBoxHeight: bigint = fillBox.top - fillBox.bottom + 1n;\n\n\tconst isHorizontal = fillBox.left !== selectionBox.left;\n\n\t/** How many whole copies fit in the fill box, floored. */\n\tlet wholeCopies: bigint;\n\t/** +X/+Y or -X/-Y */\n\tlet isPositiveDirection: boolean;\n\t/** How much each copy's coordinate is incremented by each iteration. May be negative. */\n\tlet axisIncrement: bigint;\n\t/** The axis coordinate the fill box ends at. Also where we stop filling. */\n\tlet fillBoxAxisEnd: bigint;\n\t/** The axis translation for the current iteration. */\n\tlet currentCopyStartAxis: bigint;\n\n\tif (isHorizontal) {\n\t\t// Horizontal fill\n\t\tisPositiveDirection = fillBox.left > selectionBox.left;\n\t\taxisIncrement = isPositiveDirection ? selectionWidth : -selectionWidth;\n\t\twholeCopies = fillBoxWidth / selectionWidth;\n\t\tfillBoxAxisEnd = isPositiveDirection ? fillBox.right : fillBox.left;\n\t\tcurrentCopyStartAxis = isPositiveDirection ? selectionBox.left : selectionBox.right;\n\t} else {\n\t\t// Vertical fill\n\t\tisPositiveDirection = fillBox.bottom > selectionBox.bottom;\n\t\taxisIncrement = isPositiveDirection ? selectionHeight : -selectionHeight;\n\t\twholeCopies = fillBoxHeight / selectionHeight;\n\t\tfillBoxAxisEnd = isPositiveDirection ? fillBox.top : fillBox.bottom;\n\t\tcurrentCopyStartAxis = isPositiveDirection ? selectionBox.bottom : selectionBox.top;\n\t}\n\n\t/** A +1/-1 multiplier to allow us to use one comparison symbol, \">\", for both positive and negative directions. */\n\tconst direction = isPositiveDirection ? 1n : -1n;\n\n\tconst edit: Edit = { changes: [], state: { local: [], global: [] } };\n\n\t// First, delete all pieces in the fill box.\n\tremoveAllPieces(gamefile, edit, piecesInPasteBox);\n\n\t// Cache frequently-used references for slightly better performance\n\tconst specialRights = gamefile.boardsim.state.global.specialRights;\n\tconst getKey = coordutil.getKeyFromCoords;\n\n\t// Iterate over each whole copy, plus one additional for a partial if needed\n\tfor (let i = 1n; i <= wholeCopies + 1n; i++) {\n\t\tcurrentCopyStartAxis += axisIncrement;\n\n\t\t/** Whether this iteration can only fit a partial copy. */\n\t\tconst partial: boolean = i === wholeCopies + 1n;\n\t\tif (partial && currentCopyStartAxis * direction > fillBoxAxisEnd * direction) break; // No more space to fill even a partial box (a whole number of copies fit exactly)\n\n\t\t// Add all the pieces from the selection box, translated to this copy's position\n\t\tfor (const piece of piecesInSelection) {\n\t\t\t// Determine the translated coordinates for this piece in this copy\n\t\t\tconst translatedCoords: Coords = isHorizontal\n\t\t\t\t? [piece.coords[0] + axisIncrement * i, piece.coords[1]]\n\t\t\t\t: [piece.coords[0], piece.coords[1] + axisIncrement * i];\n\t\t\t// Only add if within fill box (only might exceed it on the final partial copy)\n\t\t\tif (partial && !bounds.boxContainsSquare(fillBox, translatedCoords)) continue;\n\t\t\t// Queue the addition of the piece at its new location\n\t\t\tconst hasSpecialRights = specialRights.has(getKey(piece.coords));\n\t\t\tedithistory.queueAddPiece(\n\t\t\t\tgamefile,\n\t\t\t\tedit,\n\t\t\t\ttranslatedCoords,\n\t\t\t\tpiece.type,\n\t\t\t\thasSpecialRights,\n\t\t\t);\n\t\t}\n\t}\n\n\t// Apply the collective edit and add it to the history\n\tapplyEdit(gamefile, mesh, edit);\n\n\t// Update the selection area to be the box containing both the original selection and the filled area\n\n\tconst newBox: BoundingBox = bounds.mergeBoundingBoxDoubles(selectionBox, fillBox);\n\tselectiontool.setSelection([newBox.left, newBox.top], [newBox.right, newBox.bottom]);\n}\n\n// Action Button Transformations ------------------------------------------------\n\n/** Deletes the given selection box. */\nfunction Delete(gamefile: FullGame, mesh: Mesh, box: BoundingBox): void {\n\tconst piecesInSelection: Piece[] = getPiecesInBox(gamefile, box);\n\tconst edit: Edit = { changes: [], state: { local: [], global: [] } };\n\tremoveAllPieces(gamefile, edit, piecesInSelection);\n\tapplyEdit(gamefile, mesh, edit);\n}\n\n/** Copies the given selection box. */\nfunction Copy(gamefile: FullGame, box: BoundingBox): void {\n\tconst piecesInSelection: Piece[] = getPiecesInBox(gamefile, box);\n\n\t// Modify the pieces to include specialrights state\n\n\t// Cache frequently-used references for slightly better performance\n\tconst specialRights = gamefile.boardsim.state.global.specialRights;\n\tconst getKey = coordutil.getKeyFromCoords;\n\n\t// Modify the existing array in place to avoid performance hit of a new array.\n\t// Reverse loop that avoids re-evaluating length each iteration\n\tfor (let i = piecesInSelection.length - 1; i >= 0; i--) {\n\t\tconst p = piecesInSelection[i] as StatePiece;\n\t\tp.specialrights = specialRights.has(getKey(p.coords));\n\t}\n\n\tclipboard = piecesInSelection as StatePiece[];\n\tclipboardBox = box;\n}\n\n/** Pastes the copied region in whole multiples to fill the target box, but not exceed it. */\nfunction Paste(gamefile: FullGame, mesh: Mesh, targetBox: BoundingBox): void {\n\tif (!clipboard || !clipboardBox) return; // Nothing to paste\n\n\t// Determine the dimensions of the clipboard box\n\tconst clipboardWidth: bigint = clipboardBox.right - clipboardBox.left + 1n;\n\tconst clipboardHeight: bigint = clipboardBox.top - clipboardBox.bottom + 1n;\n\t// Dimensions of the target box (current selection area to paste in)\n\tconst targetBoxWidth: bigint = targetBox.right - targetBox.left + 1n;\n\tconst targetBoxHeight: bigint = targetBox.top - targetBox.bottom + 1n;\n\n\t// Determine how many whole copies fit in the target box, in both dimensions, with a minimum of 1.\n\tconst copiesX: bigint = bimath.max(targetBoxWidth / clipboardWidth, 1n);\n\tconst copiesY: bigint = bimath.max(targetBoxHeight / clipboardHeight, 1n);\n\n\t// The actual paste box dimensions is the minimum box that fits all whole copies\n\tconst actualPasteBoxWidth: bigint = clipboardWidth * copiesX;\n\tconst actualPasteBoxHeight: bigint = clipboardHeight * copiesY;\n\tconst actualPasteBox: BoundingBox = {\n\t\tleft: targetBox.left,\n\t\tright: targetBox.left + actualPasteBoxWidth - 1n,\n\t\tbottom: targetBox.top - actualPasteBoxHeight + 1n,\n\t\ttop: targetBox.top,\n\t};\n\n\t// Determine the translation vector from top-left of clipboard to top-left of target box\n\tconst clipboardCoords: Coords = [clipboardBox.left, clipboardBox.top];\n\tconst targetBoxCoords: Coords = [targetBox.left, targetBox.top];\n\tconst translation: Coords = coordutil.subtractCoords(targetBoxCoords, clipboardCoords);\n\n\tconst edit: Edit = { changes: [], state: { local: [], global: [] } };\n\n\t// First, delete all pieces in the actual paste box.\n\tconst piecesInPasteBox: Piece[] = getPiecesInBox(gamefile, actualPasteBox);\n\tremoveAllPieces(gamefile, edit, piecesInPasteBox);\n\n\t// Iterate over each copy position\n\tfor (let x = 0n; x < copiesX; x++) {\n\t\tfor (let y = 0n; y < copiesY; y++) {\n\t\t\t// Determine translation for this copy\n\t\t\tconst thisTranslation: Coords = [\n\t\t\t\ttranslation[0] + clipboardWidth * x,\n\t\t\t\ttranslation[1] + clipboardHeight * -y,\n\t\t\t];\n\n\t\t\t// Now, add all pieces from the clipboard, translated to this copy's position\n\t\t\tfor (const piece of clipboard) {\n\t\t\t\tconst translatedCoords = coordutil.addCoords(piece.coords, thisTranslation);\n\t\t\t\t// Queue the addition of the piece at its new location\n\t\t\t\tedithistory.queueAddPiece(\n\t\t\t\t\tgamefile,\n\t\t\t\t\tedit,\n\t\t\t\t\ttranslatedCoords,\n\t\t\t\t\tpiece.type,\n\t\t\t\t\tpiece.specialrights,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t}\n\n\t// Apply the collective edit and add it to the history\n\tapplyEdit(gamefile, mesh, edit);\n\n\t// Update the selection area to the actual paste box\n\n\tselectiontool.setSelection(\n\t\t[actualPasteBox.left, actualPasteBox.top],\n\t\t[actualPasteBox.right, actualPasteBox.bottom],\n\t);\n}\n\n/** Flips the selection box horizontally. */\nfunction FlipHorizontal(gamefile: FullGame, mesh: Mesh, box: BoundingBox): void {\n\tReflect(gamefile, mesh, box, 0); // Reflect across the X-axis\n}\n\n/** Flips the selection box vertically. */\nfunction FlipVertical(gamefile: FullGame, mesh: Mesh, box: BoundingBox): void {\n\tReflect(gamefile, mesh, box, 1); // Reflect across the Y-axis\n}\n\n/**\n * Reflects the selection box across a given axis.\n * @param axis The axis to reflect across (0 for X, 1 for Y).\n */\nfunction Reflect(gamefile: FullGame, mesh: Mesh, box: BoundingBox, axis: 0 | 1): void {\n\t// Determine the bounds for calculating the reflection line based on the axis\n\tconst [bound1, bound2] = axis === 0 ? [box.left, box.right] : [box.bottom, box.top];\n\n\t// Calculate the reflection line with BigDecimals, for decimal precision.\n\t// 1 precision is enough to perfectly represent 1/2 increments, which is the finest we need.\n\tconst bound1BD: BigDecimal = bd.fromBigInt(bound1, 1);\n\tconst bound2BD: BigDecimal = bd.fromBigInt(bound2, 1);\n\tconst sum: BigDecimal = bd.add(bound1BD, bound2BD);\n\tconst reflectionLine: BigDecimal = bd.divide(sum, TWO, 0); // Working precision isn't needed because the quotient is rational\n\n\t// These haven't changed from the original selection box\n\tconst selectionCorners: [Coords, Coords] = [\n\t\t[box.left, box.top],\n\t\t[box.right, box.bottom],\n\t];\n\n\t// A function for controlling each piece's new state\n\tconst transformer = (piece: Piece): { coords: Coords; type: number } => {\n\t\t// Reflect the piece's coordinate on the chosen axis\n\t\tconst coordToReflect = piece.coords[axis];\n\t\tconst coordBD: BigDecimal = bd.fromBigInt(coordToReflect, 1);\n\t\tconst distanceFromLine: BigDecimal = bd.subtract(coordBD, reflectionLine);\n\t\tconst reflectedCoordBD: BigDecimal = bd.subtract(reflectionLine, distanceFromLine);\n\t\t// We already know it's a perfect integer so this doesn't lose precision\n\t\tconst reflectedCoord: bigint = bd.toBigInt(reflectedCoordBD);\n\n\t\t// Create the new coordinates, modifying only the reflected axis\n\t\tconst reflectedCoords: Coords = [...piece.coords]; // Create a mutable copy\n\t\treflectedCoords[axis] = reflectedCoord;\n\n\t\treturn { coords: reflectedCoords, type: piece.type };\n\t};\n\n\t// Execute the transformation\n\tTransform(gamefile, mesh, box, box, selectionCorners, transformer);\n}\n\n/** Rotates the selection 90 degrees to the left (counter-clockwise). */\nfunction RotateLeft(gamefile: FullGame, mesh: Mesh, box: BoundingBox): void {\n\tRotate(gamefile, mesh, box, false); // false for counter-clockwise\n}\n\n/** Rotates the selection 90 degrees to the right (clockwise). */\nfunction RotateRight(gamefile: FullGame, mesh: Mesh, box: BoundingBox): void {\n\tRotate(gamefile, mesh, box, true); // true for clockwise\n}\n\n/** Rotates the selection 90 degrees clockwise or counter-clockwise. */\nfunction Rotate(gamefile: FullGame, mesh: Mesh, box: BoundingBox, clockwise: boolean): void {\n\t// Calculate the pivot point for rotation.\n\tconst sumXEdgesBD = bd.fromBigInt(box.left + box.right, 1);\n\tconst sumYEdgesBD = bd.fromBigInt(box.bottom + box.top, 1);\n\n\tconst pivot: BDCoords = [\n\t\tbd.divide(sumXEdgesBD, TWO, 0), // Working precision isn't needed because the quotient is rational\n\t\tbd.divide(sumYEdgesBD, TWO, 0),\n\t];\n\n\t// Adjust pivot for unstable rotations.\n\t// If that point is unstable, shift it by 0.5 to make it so.\n\t// Stable = In them middle of a square, or at a corner between squares.\n\t// Unstable = On an edge between squares, rotating the pieces would place them at floating point coords.\n\n\t// These work because with a precision of 1, only .0 and .5 fractional parts are possible.\n\tconst selectionWidthXISEven = !bd.isInteger(pivot[0]);\n\tconst selectionHeightYISEven = !bd.isInteger(pivot[1]);\n\n\t// If both dimensions are equal in evenness/oddness, then the pivot is stable (on a square or corner)\n\t// Otherwise, the rotation around an unstable pivot point on an edge causes pieces coordinates to not be integers.\n\tif (selectionWidthXISEven !== selectionHeightYISEven) {\n\t\t// This logic for parity, operation, and axis choice ensures that any sequence of\n\t\t// left/right rotations doesn't result in bias towards one vector.\n\t\tconst thisParity = clockwise ? rotationParity : !rotationParity; // Use opposite parity for CCW\n\t\tconst thisAxis = clockwise ? 1 : 0; // Shift Y axis for CW, X axis for CCW\n\t\tconst operation = thisParity ? bd.add : bd.subtract;\n\t\tpivot[thisAxis] = operation(pivot[thisAxis], HALF);\n\t\trotationParity = !rotationParity;\n\t}\n\n\t// Calculate the rotated selection box\n\tconst rotatedBoxCorner1: Coords = rotatePoint([box.left, box.top], pivot, clockwise);\n\tconst rotatedBoxCorner2: Coords = rotatePoint([box.right, box.bottom], pivot, clockwise);\n\tconst rotatedBox: BoundingBox = {\n\t\tleft: bimath.min(rotatedBoxCorner1[0], rotatedBoxCorner2[0]),\n\t\tright: bimath.max(rotatedBoxCorner1[0], rotatedBoxCorner2[0]),\n\t\tbottom: bimath.min(rotatedBoxCorner1[1], rotatedBoxCorner2[1]),\n\t\ttop: bimath.max(rotatedBoxCorner1[1], rotatedBoxCorner2[1]),\n\t};\n\n\tconst newSelectionCorners: [Coords, Coords] = [rotatedBoxCorner1, rotatedBoxCorner2];\n\n\t// A function controlling how each piece is transformed\n\tconst transformer = (piece: Piece): { coords: Coords; type: number } => ({\n\t\tcoords: rotatePoint(piece.coords, pivot, clockwise),\n\t\ttype: piece.type,\n\t});\n\n\t// Execute the transformation\n\tTransform(gamefile, mesh, box, rotatedBox, newSelectionCorners, transformer);\n}\n\n/**\n * Rotates a point around a pivot 90 degrees clockwise or counter-clockwise.\n * @param point The point to rotate.\n * @param pivot The pivot point to rotate around. MUST BE IN THE middle of a square, or on a corner between squares, otherwise there will be precision loss when rounding the rotated point to integers.\n * @param clockwise Whether to rotate clockwise (true) or counter-clockwise (false).\n * @returns The rotated point.\n */\nfunction rotatePoint(point: Coords, pivot: BDCoords, clockwise: Boolean): Coords {\n\t// Represent coord as BDCoords for high precision\n\tconst pointBD = bdcoords.FromCoords(point, 1);\n\n\t// 1. Translate to origin to get relative coordinates\n\tconst relativeX = bd.subtract(pointBD[0], pivot[0]);\n\tconst relativeY = bd.subtract(pointBD[1], pivot[1]);\n\n\t// 2. Apply the 90 degree rotation based on direction\n\t// For CCW (+90): direction = 1, (x, y) -> (-y, x)\n\t// For CW  (-90): direction = -1, (x, y) -> (y, -x)\n\tconst direction = clockwise ? NEGONE : ONE;\n\n\t// rotatedRelativeX = -direction * relativeY\n\tconst rotatedRelativeX = bd.multiply(relativeY, bd.negate(direction));\n\t// rotatedRelativeY = direction * relativeX\n\tconst rotatedRelativeY = bd.multiply(relativeX, direction);\n\n\t// 3. Translate back from the origin\n\tconst finalX = bd.add(rotatedRelativeX, pivot[0]);\n\tconst finalY = bd.add(rotatedRelativeY, pivot[1]);\n\n\treturn [bd.toBigInt(finalX), bd.toBigInt(finalY)];\n}\n\n/** Inverts the color of the pieces in the selection box. */\nfunction InvertColor(gamefile: FullGame, mesh: Mesh, box: BoundingBox): void {\n\t// These haven't changed from the original selection box\n\tconst selectionCorners: [Coords, Coords] = [\n\t\t[box.left, box.top],\n\t\t[box.right, box.bottom],\n\t];\n\n\t// A function for controlling each piece's new state\n\tconst transformer = (piece: Piece): { coords: Coords; type: number } => {\n\t\tconst newType = typeutil.invertType(piece.type);\n\t\treturn { coords: piece.coords, type: newType };\n\t};\n\n\t// Execute the transformation\n\tTransform(gamefile, mesh, box, box, selectionCorners, transformer);\n}\n\n// Transformation Helpers -----------------------------------------------------\n\n/**\n * Executes a transformation, where pieces within the selection may be moved or modified.\n * Handles clearing the destination area, clearing the selection area,\n * moving the pieces, and updating the selection area.\n */\nfunction Transform(\n\tgamefile: FullGame,\n\tmesh: Mesh,\n\tsourceBox: BoundingBox,\n\tdestinationBox: BoundingBox,\n\tnewSelectionCorners: [Coords, Coords],\n\t/** A function to transform an individual piece's coordinates and type. */\n\ttransformer: (_piece: Piece) => { coords: Coords; type: number },\n): void {\n\tconst piecesInSource = getPiecesInBox(gamefile, sourceBox);\n\tconst piecesInDestination = getPiecesInBox(gamefile, destinationBox);\n\n\t// Determine whether the destination box is entirely contained within the border\n\tconst withinBorder = gamefile.basegame.gameRules.worldBorder\n\t\t? bounds.boxContainsBox(gamefile.basegame.gameRules.worldBorder, destinationBox)\n\t\t: true;\n\n\tconst edit: Edit = { changes: [], state: { local: [], global: [] } };\n\n\t// Clear the destination area of any pieces not part of the original selection\n\tfor (const piece of piecesInDestination) {\n\t\tif (bounds.boxContainsSquare(sourceBox, piece.coords)) continue;\n\t\tedithistory.queueRemovePiece(gamefile, edit, piece);\n\t}\n\n\t// Delete all pieces in the original selection area\n\tremoveAllPieces(gamefile, edit, piecesInSource);\n\n\t// Cache frequently-used references for slightly better performance\n\tconst specialRights = gamefile.boardsim.state.global.specialRights;\n\tconst getKey = coordutil.getKeyFromCoords;\n\n\t// Add all pieces in the original selection area, but transformed\n\tfor (const piece of piecesInSource) {\n\t\t// Determine the new state for this piece\n\t\tconst transformed = transformer(piece);\n\t\t// Skip if the destination is out of bounds\n\t\tif (\n\t\t\t!withinBorder &&\n\t\t\t!bounds.boxContainsSquare(gamefile.basegame.gameRules.worldBorder!, transformed.coords)\n\t\t)\n\t\t\tcontinue;\n\t\t// Queue the addition of the piece at its new location\n\t\tconst hasSpecialRights = specialRights.has(getKey(piece.coords));\n\t\tedithistory.queueAddPiece(\n\t\t\tgamefile,\n\t\t\tedit,\n\t\t\ttransformed.coords,\n\t\t\ttransformed.type,\n\t\t\thasSpecialRights,\n\t\t);\n\t}\n\n\t// Apply the collective edit and add it to the history\n\tapplyEdit(gamefile, mesh, edit);\n\n\t// Update the selection area\n\tselectiontool.setSelection(newSelectionCorners[0], newSelectionCorners[1]);\n}\n\n// Utility ------------------------------------------------------------\n\n/** Queues all the pieces in the list to be removed in this Edit. */\nfunction removeAllPieces(gamefile: FullGame, edit: Edit, pieces: Piece[]): void {\n\tfor (const piece of pieces) {\n\t\tedithistory.queueRemovePiece(gamefile, edit, piece);\n\t}\n}\n\n/** Applies the provided edit and adds it to the history. */\nfunction applyEdit(gamefile: FullGame, mesh: Mesh, edit: Edit): void {\n\tif (edit.changes.length === 0 && edit.state.global.length === 0) return; // No changes made => don't need to apply\n\n\t// Apply the collective edit and add it to the history\n\tedithistory.runEdit(gamefile, mesh, edit, true);\n\tedithistory.addEditToHistory(edit);\n}\n\n/** Calculates all pieces within the given box area. */\nfunction getPiecesInBox(gamefile: FullGame, intBox: BoundingBox): Piece[] {\n\tconst o = gamefile.boardsim.pieces; // Organized pieces\n\n\tconst selectionBoxWidth: bigint = intBox.right - intBox.left;\n\tconst selectionBoxHeight: bigint = intBox.top - intBox.bottom;\n\n\t// The dimensions of the selection determine which organized line axis\n\t// we'll be reading from, for greater performance.\n\n\tconst axis: 0 | 1 = selectionBoxWidth >= selectionBoxHeight ? 0 : 1;\n\tconst coordPositions: bigint[] = axis === 0 ? o.XPositions : o.YPositions;\n\tconst step: Vec2 = axis === 0 ? [1n, 0n] : [0n, 1n];\n\n\tconst slideKey = vectors.getKeyFromVec2(step);\n\tconst lines = o.lines.get(slideKey)!; // All lines of pieces going in one vector direction\n\n\t/** Running list of all pieces within the box. */\n\tconst piecesInSelection: Piece[] = [];\n\n\t// The start and end keys of those lines\n\tconst linesStart = axis === 0 ? intBox.bottom : intBox.left;\n\tconst linesEnd = axis === 0 ? intBox.top : intBox.right;\n\tconst rangeStart = axis === 0 ? intBox.left : intBox.bottom;\n\tconst rangeEnd = axis === 0 ? intBox.right : intBox.top;\n\n\tconst numOfLines: bigint = linesEnd - linesStart + 1n;\n\tconst lineEntries: bigint = BigInt(lines.size);\n\n\t// If the total number of line entries is less than the number of lines in the selection box,\n\t// iterate through them instead. It's more efficient.\n\tif (lineEntries <= numOfLines) {\n\t\tfor (const thisLine of lines.values()) {\n\t\t\tfor (let a = 0; a < thisLine.length; a++) {\n\t\t\t\tconst idx = thisLine[a]!;\n\t\t\t\t// Check if the piece coords is within the selection area\n\t\t\t\tconst coords: Coords = boardutil.getCoordsFromIdx(o, idx);\n\t\t\t\tif (bounds.boxContainsSquare(intBox, coords)) {\n\t\t\t\t\tpiecesInSelection.push(boardutil.getDefinedPieceFromIdx(o, idx));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} else {\n\t\t// Iterate through each line to find all pieces within the selection box\n\t\tfor (let i = linesStart; i <= linesEnd; i++) {\n\t\t\tconst coordsForKey: Coords = axis === 0 ? [0n, i] : [i, 0n]; // 0n makes no difference for the final key of the line, it can be anything.\n\t\t\tconst lineKey = organizedpieces.getKeyFromLine(step, coordsForKey);\n\n\t\t\tconst thisLine: number[] | undefined = lines.get(lineKey);\n\t\t\tif (!thisLine) continue; // Empty line\n\t\t\tfor (let a = 0; a < thisLine.length; a++) {\n\t\t\t\tconst idx = thisLine[a]!;\n\t\t\t\t// The piece is in the selection area if its axis coord is within bounds\n\t\t\t\tconst thisCoord: bigint = coordPositions[idx]!;\n\t\t\t\tif (thisCoord >= rangeStart && thisCoord <= rangeEnd) {\n\t\t\t\t\tpiecesInSelection.push(boardutil.getDefinedPieceFromIdx(o, idx));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn piecesInSelection;\n}\n\n// API -------------------------------------------------------------------------\n\n/** Drops the reference to the clipboard contents. */\nfunction resetState(): void {\n\tclipboard = undefined;\n\tclipboardBox = undefined;\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\t// Selection Box Transformations\n\tTranslate,\n\tFill,\n\t// Action Button Transformations\n\tDelete,\n\tCopy,\n\tPaste,\n\tFlipHorizontal,\n\tFlipVertical,\n\tRotateLeft,\n\tRotateRight,\n\tInvertColor,\n\t// API\n\tresetState,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/checkmatepractice.ts",
    "content": "// src/client/scripts/esm/game/chess/checkmatepractice.ts\n\n/**\n * This script handles checkmate practice logic\n */\n\nimport type { VariantOptions } from '../../../../../shared/chess/logic/initvariant.js';\nimport type { GameConclusion } from '../../../../../shared/chess/util/winconutil.js';\nimport type { Coords, CoordsKey } from '../../../../../shared/chess/util/coordutil.js';\n\nimport bimath from '../../../../../shared/util/math/bimath.js';\nimport variant from '../../../../../shared/chess/variants/variant.js';\nimport typeutil from '../../../../../shared/chess/util/typeutil.js';\nimport coordutil from '../../../../../shared/chess/util/coordutil.js';\nimport icnconverter from '../../../../../shared/chess/logic/icn/icnconverter.js';\nimport gamefileutility from '../../../../../shared/chess/util/gamefileutility.js';\nimport validcheckmates from '../../../../../shared/chess/util/validcheckmates.js';\nimport {\n\tplayers as p,\n\text as e,\n\trawTypes as r,\n} from '../../../../../shared/chess/util/typeutil.js';\n\nimport docutil from '../../util/docutil.js';\nimport gameslot from './gameslot.js';\nimport selection from '../chess/selection.js';\nimport gameloader from './gameloader.js';\nimport enginegame from '../misc/enginegame.js';\nimport guipractice from '../gui/guipractice.js';\nimport guigameinfo from '../gui/guigameinfo.js';\nimport LocalStorage from '../../util/LocalStorage.js';\nimport movesequence from '../chess/movesequence.js';\nimport validatorama from '../../util/validatorama.js';\nimport { engineDictionary } from './engines/engine.js';\nimport { retryFetch, RetryFetchOptions } from '../../util/httputils.js';\n\n// Variables ----------------------------------------------------------------------------\n\n/** These checkmates we may place the black king nearer to the white pieces. */\nconst checkmatesWithBlackRoyalNearer = [\n\t'1K1Q1N-1k',\n\t'1Q1R1N-1k',\n\t'1Q2N-1k',\n\t'1Q1N1B-1k',\n\t'1K1N2B1B-1k',\n\t'1K2N1B1B-1k',\n\t'1K1R1N1B-1k',\n\t'1K1AR1R-1k',\n\t'1K1CH1N-1k',\n\t'1K1R2N-1k',\n\t'2K1R-1k',\n\t'1K2N6B-1k',\n\t'1K2HA1B-1k',\n\t'1K3HA-1k',\n];\n\nconst nameOfCompletedCheckmatesInStorage: string = 'checkmatePracticeCompletion';\n/**\n * A list of checkmate strings we have beaten\n * [ \"2Q-1k\", \"3R-1k\", \"2CH-1k\"]\n *\n * This will be initialized when guipractice calls {@link updateCompletedCheckmates} for the first time!\n * If we initialize it right here, we crash in production, because LocalStorage is not defined yet.\n * @type {string[]}\n */\nlet completedCheckmates: string[];\nconst expiryOfCompletedCheckmatesMillis: number = 1000 * 60 * 60 * 24 * 365; // 1 year\n\n/** Whether we are in a checkmate practice engine game. */\nlet inCheckmatePractice: boolean = false;\n\n/** Whether the player is allowed to undo a move in the current position. */\nlet undoingIsLegal: boolean = false;\n\n// Functions ----------------------------------------------------------------------------\n\n// Set a listener for the logout event, to refresh the checkmates list\ndocument.addEventListener('logout', updateCompletedCheckmates);\n\nfunction setUndoingIsLegal(value: boolean): void {\n\tundoingIsLegal = value;\n\tguigameinfo.update_GameControlButtons(value);\n}\n\nfunction areInCheckmatePractice(): boolean {\n\treturn inCheckmatePractice;\n}\n\n/**\n * Starts a checkmate practice game\n */\nfunction startCheckmatePractice(checkmateSelectedID: string): void {\n\tconsole.log('Loading practice checkmate game.');\n\tinCheckmatePractice = true;\n\tsetUndoingIsLegal(false);\n\tinitListeners();\n\n\tconst position = generateCheckmateStartingPosition(checkmateSelectedID);\n\tconst specialRights = new Set<CoordsKey>();\n\tconst variantOptions: VariantOptions = {\n\t\tfullMove: 1,\n\t\tposition,\n\t\tstate_global: { specialRights },\n\t\tgameRules: variant.getBareMinimumGameRules(),\n\t};\n\tconst currentEngine = 'engineCheckmatePractice' as const;\n\n\tconst options = {\n\t\tevent: 'Infinite chess checkmate practice',\n\t\ttimeControl: '-' as const,\n\t\tvariant: null,\n\t\tyouAreColor: p.WHITE,\n\t\tcurrentEngine,\n\t\tengineConfig: {\n\t\t\tcheckmateSelectedID: checkmateSelectedID,\n\t\t\tengineTimeLimitPerMoveMillis:\n\t\t\t\tengineDictionary[currentEngine].defaultTimeLimitPerMoveMillis,\n\t\t},\n\t\tvariantOptions,\n\t\tshowGameControlButtons: true as true,\n\t};\n\n\tgameloader.startEngineGame(options);\n}\n\nfunction onGameUnload(): void {\n\tcloseListeners();\n\tinCheckmatePractice = false;\n\tsetUndoingIsLegal(false);\n}\n\nfunction initListeners(): void {\n\tdocument.addEventListener('guigameinfo-undoMove', undoMove);\n\tdocument.addEventListener('guigameinfo-restart', restartGame);\n}\n\nfunction closeListeners(): void {\n\tdocument.removeEventListener('guigameinfo-undoMove', undoMove);\n\tdocument.removeEventListener('guigameinfo-restart', restartGame);\n}\n\n/**\n * This method generates a random starting position object for a given checkmate practice ID\n * @param checkmateID - a string containing the ID of the selected checkmate practice problem\n * @returns a starting position object corresponding to that ID\n */\nfunction generateCheckmateStartingPosition(checkmateID: string): Map<CoordsKey, number> {\n\t// error if user somehow submitted invalid checkmate ID\n\tif (!Object.values(validcheckmates.validCheckmates).flat().includes(checkmateID))\n\t\tthrow Error('User tried to play invalid checkmate practice.');\n\n\t// place the black king not so far away for specific variants\n\tconst blackroyalnearer: boolean = checkmatesWithBlackRoyalNearer.includes(checkmateID);\n\n\tconst position = new Map<CoordsKey, number>(); // the position to be generated\n\tlet blackpieceplaced: boolean = false; // monitors if a black piece has already been placed\n\tlet whitebishopparity: number = Math.floor(Math.random() * 2); // square color of first white bishop batch\n\n\t// read the elementID and convert it to a position\n\tconst piecelist: RegExpMatchArray | null = checkmateID.match(/[0-9]+[a-zA-Z]+/g);\n\tif (!piecelist) return position;\n\n\tfor (const entry of piecelist) {\n\t\tlet amount: number = parseInt(entry.match(/[0-9]+/)![0]); // number of pieces to be placed\n\t\tconst strpiece: string = entry.match(/[a-zA-Z]+/)![0]; // piecetype to be placed\n\t\tconst piece: number = icnconverter.getTypeFromAbbr(strpiece);\n\n\t\t// place amount many pieces of type piece\n\t\twhile (amount !== 0) {\n\t\t\tif (typeutil.getColorFromType(piece) === p.WHITE) {\n\t\t\t\tif (blackpieceplaced)\n\t\t\t\t\tthrow Error('Must place all white pieces before placing black pieces.');\n\n\t\t\t\t// randomly generate white piece coordinates in square around origin\n\t\t\t\tconst x: bigint =\n\t\t\t\t\tBigInt(Math.floor(Math.random() * (blackroyalnearer ? 7 : 11))) -\n\t\t\t\t\t(blackroyalnearer ? 3n : 5n);\n\t\t\t\tconst y: bigint =\n\t\t\t\t\tBigInt(Math.floor(Math.random() * (blackroyalnearer ? 7 : 11))) -\n\t\t\t\t\t(blackroyalnearer ? 3n : 5n);\n\t\t\t\tconst key: CoordsKey = coordutil.getKeyFromCoords([x, y]);\n\n\t\t\t\t// check if square is occupied and white bishop parity is fulfilled\n\t\t\t\tif (\n\t\t\t\t\t!position.has(key) &&\n\t\t\t\t\t!(piece === r.BISHOP + e.W && Number((x + y) % 2n) !== whitebishopparity)\n\t\t\t\t) {\n\t\t\t\t\tposition.set(key, piece);\n\t\t\t\t\tamount -= 1;\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// randomly generate black piece coordinates at a distance\n\t\t\t\tconst x: bigint =\n\t\t\t\t\tBigInt(Math.floor(Math.random() * 3)) + (blackroyalnearer ? 8n : 12n);\n\t\t\t\tconst y: bigint =\n\t\t\t\t\tBigInt(Math.floor(Math.random() * (blackroyalnearer ? 17 : 35))) -\n\t\t\t\t\t(blackroyalnearer ? 9n : 17n);\n\t\t\t\tconst key: CoordsKey = coordutil.getKeyFromCoords([x, y]);\n\t\t\t\t// check if square is occupied or potentially threatened\n\t\t\t\tif (!position.has(key) && squareNotInSight(key, position)) {\n\t\t\t\t\tposition.set(key, piece);\n\t\t\t\t\tamount -= 1;\n\t\t\t\t\tblackpieceplaced = true;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// flip white bishop parity\n\t\twhitebishopparity = 1 - whitebishopparity;\n\t}\n\n\treturn position;\n}\n\n/**\n * This method checks that the input square is not on the same row, column or diagonal as any key in the position Map\n * It also checks that it is not attacked by a knightrider\n * @param square - square of black piece\n * @param position - position Map containing all white pieces\n * @returns true or false, depending on if the square is in sight or not\n */\nfunction squareNotInSight(square: CoordsKey, position: Map<CoordsKey, number>): boolean {\n\tconst [sx, sy]: Coords = coordutil.getCoordsFromKey(square);\n\tfor (const [key, value] of position) {\n\t\tconst [x, y]: Coords = coordutil.getCoordsFromKey(key);\n\t\tif (x === sx || y === sy || bimath.abs(sx - x) === bimath.abs(sy - y)) return false;\n\t\tif (value === r.KNIGHTRIDER + e.W) {\n\t\t\tif (\n\t\t\t\tbimath.abs(sx - x) === 2n * bimath.abs(sy - y) ||\n\t\t\t\t2n * bimath.abs(sx - x) === bimath.abs(sy - y)\n\t\t\t) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\t}\n\treturn true;\n}\n\n/**\n * Only for dev testing\n * Erases checkmate practice progress in local storage\n * Call {@link checkmatepractice.eraseCheckmatePracticeProgressFromLocalStorage} in developer tools to use this\n */\nfunction eraseCheckmatePracticeProgressFromLocalStorage(): void {\n\tLocalStorage.deleteItem(nameOfCompletedCheckmatesInStorage);\n\tconsole.log('DELETED all checkmate practice progress.');\n\tif (!completedCheckmates) return; // Haven't open the checkmate practice menu yet, so it's not defined.\n\tcompletedCheckmates.length = 0;\n\tguipractice.updateCheckmatesBeaten(completedCheckmates); // Delete the 'beaten' class from all\n}\n\n/**\n * Updates completedCheckmates list and redraws the GUI by calling guipractice.updateCheckmatesBeaten()\n */\nfunction updateCompletedCheckmates(): void {\n\t// Update completedCheckmates according to checkmates_beaten cookie, if it exists, and if we are logged in\n\tconst cookieCheckmates: string | undefined = docutil.getCookieValue('checkmates_beaten');\n\tif (validatorama.areWeLoggedIn() && cookieCheckmates !== undefined) {\n\t\t// console.log(\"checkmates_beaten cookie was present!\");\n\t\tcompletedCheckmates = decodeURIComponent(cookieCheckmates).match(/[^,]+/g) || []; // match() returns null if no matches\n\t} else {\n\t\t// Else, use LocalStorage as a fallback\n\t\tcompletedCheckmates = LocalStorage.loadItem(nameOfCompletedCheckmatesInStorage) || [];\n\t}\n\tguipractice.updateCheckmatesBeaten(completedCheckmates);\n}\n\n/**\n * Updates the completedCheckmates variable with the beaten checkmatePracticeID,\n * and sends a message to the server if the player is logged in\n */\nasync function markCheckmateBeaten(checkmatePracticeID: string): Promise<void> {\n\tif (!completedCheckmates)\n\t\tthrow Error('Cannot mark checkmate beaten when it was never initialized!');\n\tif (!Object.values(validcheckmates.validCheckmates).flat().includes(checkmatePracticeID))\n\t\tthrow Error('User completed invalid checkmate practice.');\n\n\t// Add the checkmate ID to the beaten list\n\tif (!completedCheckmates.includes(checkmatePracticeID))\n\t\tcompletedCheckmates.push(checkmatePracticeID);\n\tconsole.log('Marked checkmate practice as completed!');\n\n\t// Update LocalStorage and exit, if we are not logged in\n\tif (!validatorama.areWeLoggedIn()) {\n\t\tLocalStorage.saveItem(\n\t\t\tnameOfCompletedCheckmatesInStorage,\n\t\t\tcompletedCheckmates,\n\t\t\texpiryOfCompletedCheckmatesMillis,\n\t\t);\n\t\treturn;\n\t}\n\n\t// We ARE logged in. Send a POST request to tell the server we have beaten a new checkmate!\n\n\t// Configure the POST request\n\tconst fetchInit: RequestInit = {\n\t\tmethod: 'POST',\n\t\theaders: {\n\t\t\t'Content-Type': 'application/json',\n\t\t\t'is-fetch-request': 'true', // Custom header\n\t\t},\n\t\tbody: JSON.stringify({ new_checkmate_beaten: checkmatePracticeID }),\n\t};\n\n\tconst token: string | undefined = await validatorama.getAccessToken();\n\tif (token) (fetchInit.headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;\n\n\tconst retryOptions: RetryFetchOptions = {\n\t\t// With these settings, the fifth attempt occurs 1m 15s after the first.\n\t\tmaxAttempts: 5,\n\t\tinitialDelayMs: 5000,\n\t\tbackoffFactor: 2,\n\t};\n\n\ttry {\n\t\t// Use retryFetch wrapper to try the same POST multiple times\n\t\t// until it succeeds. This is just in case of a server error,\n\t\t// or a server restart at the exact same time, thus making\n\t\t// them have to solve the same checkmate again.\n\t\tconst response: Response = await retryFetch(\n\t\t\t'/api/update-checkmatelist',\n\t\t\tfetchInit,\n\t\t\tretryOptions,\n\t\t);\n\n\t\tif (response.ok) {\n\t\t\tconsole.log('Server recorded checkmate completion successfully.');\n\t\t\t// Do this now, since the server will have updated the cookie containing the completed checkmates\n\t\t\tguipractice.updateCheckmatesBeaten(completedCheckmates);\n\t\t} else {\n\t\t\t// Handle unsuccessful response\n\t\t\t// This means retries were exhausted on a 500, or it was a non-retryable response (e.g., 400, 401)\n\t\t\t// that the retryFetch logic didn't retry.\n\t\t\tconst errorData = await response.json();\n\t\t\tconsole.error(\n\t\t\t\t`Failed to update checkmate list on the server (final status ${response.status}) after all attempts:`,\n\t\t\t\terrorData.message || errorData,\n\t\t\t);\n\t\t}\n\t} catch (error) {\n\t\t// This catch block handles cases where retries were exhausted on network errors.\n\t\tconsole.error(\n\t\t\t'Error sending checkmate list to the server after all attempts (network/unhandled error):',\n\t\t\terror,\n\t\t);\n\t}\n}\n\n/** Called when an engine game ends */\nfunction onEngineGameConclude(): void {\n\t// Were we doing checkmate practice\n\tif (!inCheckmatePractice) return; // Not in checkmate practice\n\n\tconst gameConclusion: GameConclusion | undefined =\n\t\tgameslot.getGamefile()!.basegame.gameConclusion;\n\tif (gameConclusion === undefined)\n\t\tthrow Error('Game conclusion is undefined, should not have called onEngineGameConclude()');\n\n\t// Did we win or lose?\n\tif (gameConclusion.victor === undefined)\n\t\tthrow Error('Victor should never be undefined when concluding an engine game.');\n\tif (!(enginegame.getOurColor() === gameConclusion.victor)) return; // Lost\n\n\t// WON!!! 🎉\n\n\t// Add the checkmate to the list of completed!\n\tconst checkmatePracticeID: string = guipractice.getCheckmateSelectedID();\n\tmarkCheckmateBeaten(checkmatePracticeID);\n}\n\n/**\n * This function gets called by enginegame.ts whenever a human player submitted a move\n */\nfunction registerHumanMove(): void {\n\tif (!inCheckmatePractice) return; // The engine game is not a checkmate practice game\n\n\tconst { basegame } = gameslot.getGamefile()!;\n\tif (!undoingIsLegal && gamefileutility.isGameOver(basegame) && basegame.moves.length > 0) {\n\t\t// allow player to undo move if it ended the game\n\t\tsetUndoingIsLegal(true);\n\t} else if (undoingIsLegal && !gamefileutility.isGameOver(basegame)) {\n\t\t// don't allow player to undo move while engine thinks\n\t\tsetUndoingIsLegal(false);\n\t}\n}\n\n/**\n * This function gets called by enginegame.ts whenever an engine player submitted a move\n */\nfunction registerEngineMove(): void {\n\tif (!inCheckmatePractice) return; // The engine game is not a checkmate practice game\n\n\tconst { basegame } = gameslot.getGamefile()!;\n\tif (!undoingIsLegal && basegame.moves.length > 1) {\n\t\t// allow player to undo move after engine has moved\n\t\tsetUndoingIsLegal(true);\n\t}\n}\n\nfunction undoMove(): void {\n\tif (!inCheckmatePractice)\n\t\treturn console.error('Undoing moves is currently not allowed for non-practice mode games');\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh()!;\n\tif (\n\t\tundoingIsLegal &&\n\t\t(enginegame.isItOurTurn() || gamefileutility.isGameOver(gamefile.basegame)) &&\n\t\tgamefile.basegame.moves.length > 0\n\t) {\n\t\t// > 0 catches scenarios where stalemate occurs on the first move\n\t\tsetUndoingIsLegal(false);\n\n\t\t// go to latest move before undoing moves\n\t\tmovesequence.viewFront(gamefile, mesh);\n\n\t\t// If it's their turn, only rewind one move.\n\t\tif (enginegame.isItOurTurn() && gamefile.basegame.moves.length > 1)\n\t\t\tmovesequence.rewindMove(gamefile, mesh);\n\t\tmovesequence.rewindMove(gamefile, mesh);\n\t\tselection.reselectPiece();\n\t}\n}\n\nfunction restartGame(): void {\n\tif (!inCheckmatePractice)\n\t\treturn console.error(\n\t\t\t'Restarting games is currently not supported for non-practice mode games',\n\t\t);\n\n\tgameloader.unloadGame(); // Unload current game\n\tstartCheckmatePractice(guipractice.getCheckmateSelectedID());\n}\n\n// Exports ------------------------------------------------------------------------------\n\nexport default {\n\tareInCheckmatePractice,\n\tstartCheckmatePractice,\n\tonGameUnload,\n\tupdateCompletedCheckmates,\n\teraseCheckmatePracticeProgressFromLocalStorage,\n\tonEngineGameConclude,\n\tregisterHumanMove,\n\tregisterEngineMove,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/clientmetadatautil.ts",
    "content": "// src/client/scripts/esm/game/chess/clientmetadatautil.ts\n\n/**\n * Client-side helpers for building and parsing ICN game metadata.\n */\n\nimport type { MetadataKey } from '../../../../../shared/chess/util/metadatautil.js';\nimport type { Condition, GameConclusion } from '../../../../../shared/chess/util/winconutil.js';\nimport type { MetaData, Rating, TimeControl } from '../../../../../shared/types.js';\n\nimport * as z from 'zod';\n\nimport timeutil from '../../../../../shared/util/timeutil.js';\nimport winconutil from '../../../../../shared/chess/util/winconutil.js';\nimport { players as p } from '../../../../../shared/chess/util/typeutil.js';\n\n// Constants -----------------------------------------------------------------------\n\n/**\n * The hardcoded English string used in ICN metadata to represent the human player\n * in engine and board-editor games. Metadata must always be in English.\n */\nconst YOU_NAME_ICN_METADATA = '(You)' as const;\n\n// Functions -----------------------------------------------------------------------\n\n/**\n * Resolves a timestamp (ms since epoch) from UTCDate and UTCTime metadata strings.\n * Falls back to the current time if UTCDate is not provided.\n * If UTCDate is provided but UTCTime is not, midnight (00:00:00) is assumed.\n */\nfunction resolveTimestampFromMetadata(UTCDate?: string, UTCTime?: string): number {\n\tif (UTCDate !== undefined) {\n\t\treturn timeutil.convertUTCDateUTCTimeToTimeStamp(UTCDate, UTCTime);\n\t}\n\treturn Date.now();\n}\n\n/**\n * Builds a {@link MetaData} object for client-side games (local, engine, board editor).\n * Automatically populates `Site`, `Round`, `UTCDate`, and `UTCTime`.\n * @param event - The `Event` string describing the game.\n * @param timeControl - The time control string (e.g. `\"600+5\"`), or `\"-\"` for untimed.\n * @param utcTimestamp - The epoch-ms timestamp used for the `UTCDate`/`UTCTime` fields.\n */\nfunction buildBaseGameMetadata(\n\tevent: string,\n\ttimeControl: TimeControl,\n\tutcTimestamp: number,\n): MetaData {\n\tconst { UTCDate, UTCTime } = timeutil.convertTimestampToUTCDateUTCTime(utcTimestamp);\n\treturn {\n\t\tEvent: event,\n\t\tSite: 'https://www.infinitechess.org/',\n\t\tRound: '-',\n\t\tTimeControl: timeControl,\n\t\tUTCDate,\n\t\tUTCTime,\n\t};\n}\n\n/**\n * Helper function that uses generics to link the metadata key to its value type.\n * Inside the function typescript doesn't error when we are transferring the property.\n */\nfunction copyMetadataField<K extends MetadataKey>(\n\ttarget: MetaData,\n\tsource: MetaData,\n\tkey: K,\n): void {\n\t// TS knows that target[key] and source[key] have the same type: MetaData[K]\n\ttarget[key] = source[key];\n}\n\n/** Calculates the game conclusion from the Result metadata and termination CODE. */\nfunction getGameConclusionFromResultAndTermination(\n\tresult: string,\n\ttermination: Condition,\n): GameConclusion {\n\t// prettier-ignore\n\tconst victor =\n\t\tresult === '1-0' ? p.WHITE :\n\t\tresult === '0-1' ? p.BLACK :\n\t\tresult === '1/2-1/2' ? null :\n\t\tresult === '*' ? undefined :\n\t\t((): never => { throw Error(`Unsupported result (${result})!`); })();\n\n\tconst gameConclusion: any = { condition: termination };\n\t// Only attach victor if it is defined\n\tif (victor !== undefined) gameConclusion.victor = victor;\n\n\t// Make sure it's type safe\n\tconst parseResult = winconutil.gameConclusionSchema.safeParse(gameConclusion);\n\tif (!parseResult.success)\n\t\tthrow new Error(\n\t\t\t`When parsing GameConclusion from metadata, condition \"${termination}\" and victor \"${victor}\" is an invalid combination. ZodError: ${z.prettifyError(parseResult.error)}`,\n\t\t);\n\treturn parseResult.data;\n}\n\n/**\n * Parses the elo and confidence from WhiteElo/BlackElo metadata.\n * ONLY HAS AS MUCH PRECISION as what's in the metadata.\n * DOES NOT KNOW whether their current rating is now confident, if thir WhiteElo/BlackElo was not confident.\n */\nfunction getRatingFromWhiteBlackElo(whiteBlackElo: string): Rating {\n\tconst [elo, emptyStr] = whiteBlackElo.split('?'); // emptyStr will be '' if the '?' is present, otherwise it will be undefined.\n\treturn {\n\t\tvalue: Number(elo),\n\t\tconfident: emptyStr === undefined,\n\t};\n}\n\n// Exports -----------------------------------------------------------------------\n\nexport default {\n\tYOU_NAME_ICN_METADATA,\n\tresolveTimestampFromMetadata,\n\tbuildBaseGameMetadata,\n\tcopyMetadataField,\n\tgetGameConclusionFromResultAndTermination,\n\tgetRatingFromWhiteBlackElo,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/copygame.ts",
    "content": "// src/client/scripts/esm/game/chess/copygame.ts\n\n/**\n * This script handles copying games\n */\n\nimport type { VariantCode } from '../../../../../shared/chess/variants/variantdictionary.js';\n\nimport icnconverter from '../../../../../shared/chess/logic/icn/icnconverter.js';\n\nimport toast from '../gui/toast.js';\nimport docutil from '../../util/docutil.js';\nimport drawrays from '../rendering/highlights/annotations/drawrays.js';\nimport drawsquares from '../rendering/highlights/annotations/drawsquares.js';\nimport boardeditor from '../boardeditor/boardeditor.js';\nimport gamecompressor from './gamecompressor.js';\nimport gameslot, { PresetAnnotes } from './gameslot.js';\n\nconst variantsTooBigToCopyPositionToICN: VariantCode[] = [\n\t'Omega_Squared',\n\t'Omega_Cubed',\n\t'Omega_Fourth',\n\t'5D_Chess',\n];\n\n/**\n * Copies the current game to the clipboard in ICN notation.\n * This callback is called when the \"Copy Game\" button is pressed.\n * @param copySinglePosition - If true, only copy the current position, not the entire game. It won't have the moves list.\n */\nfunction copyGame(copySinglePosition: boolean): void {\n\tif (boardeditor.areInBoardEditor()) return; // Editor has its own handler\n\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst variantCode = gamefile.boardsim.variant;\n\n\t// Add the preset annotation overrides from the previously pasted game, if present.\n\tconst preset_squares = drawsquares.getPresetOverrides();\n\tconst preset_rays = drawrays.getPresetOverrides();\n\tlet presetAnnotes: PresetAnnotes | undefined;\n\tif (preset_squares || preset_rays) {\n\t\tpresetAnnotes = {};\n\t\tif (preset_squares) presetAnnotes.squares = preset_squares;\n\t\tif (preset_rays) presetAnnotes.rays = preset_rays;\n\t}\n\n\tconst longformatIn = gamecompressor.compressGamefile(\n\t\tgamefile,\n\t\tcopySinglePosition,\n\t\tpresetAnnotes,\n\t);\n\n\tconst largeGame: boolean =\n\t\tvariantCode !== null && variantsTooBigToCopyPositionToICN.includes(variantCode);\n\t// Also specify the position if we're copying a single position, so the starting position will be different.\n\tconst skipPosition: boolean = largeGame && !copySinglePosition;\n\tconst shortformat: string = icnconverter.LongToShort_Format(longformatIn, {\n\t\tskipPosition,\n\t\tcompact: false,\n\t\tspaces: false,\n\t\tcomments: false,\n\t\tmake_new_lines: false,\n\t\tmove_numbers: false,\n\t});\n\n\tdocutil.copyToClipboard(shortformat);\n\ttoast.show(translations.copypaste.copied_game);\n}\n\nexport default {\n\tcopyGame,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/engines/engine.ts",
    "content": "// src/client/scripts/esm/game/chess/engines/engine.ts\n\n/*\n * This module contains the centralized data structure for all engines.\n * Add a new entry to engineDictionary when adding a new engine.\n */\n\nimport hydrochess_card from './enginecards/hydrochess_card.js';\n\n// Types ------------------------------------------------------------------------\n\n/** A single engine entry object in the engine dictionary. */\nexport interface Engine {\n\t/**\n\t * World border distance for this engine.\n\t * Engine games have a world border enabled so as to keep the position within safe floating point range.\n\t * If the variant's world border is smaller, that will be used instead.\n\t */\n\tworldBorder: bigint;\n\t/**\n\t * The number of milliseconds the engine thinks when Time Control is unlimited.\n\t * May vary from engine to engine because of different engine speeds and requirements.\n\t */\n\tdefaultTimeLimitPerMoveMillis: number;\n\t/** Display name shown in the UI for this engine. */\n\tdisplayName: string;\n\t/** The maximum strength level supported by this engine. */\n\tmaxStrengthLevel: number;\n}\n\n/** Union of all valid engine names, derived from the keys of engineDictionary. */\nexport type ValidEngine = keyof typeof engineDictionary;\n\n// Constants --------------------------------------------------------------------\n\n/**\n * Centralized data structure for all engine properties.\n * Add a new entry here when adding a new engine.\n */\nexport const engineDictionary = {\n\tengineCheckmatePractice: {\n\t\t// worldBorder: BigInt(Number.MAX_SAFE_INTEGER), // FREEZES practice checkmate engine if you move to the border\n\t\tworldBorder: BigInt(1e15), // 1 Quadrillion (~11% the distance of Number.MAX_SAFE_INTEGER)\n\t\tdefaultTimeLimitPerMoveMillis: 500,\n\t\tdisplayName: 'Practice Bot',\n\t\tmaxStrengthLevel: 1,\n\t},\n\thydrochess: {\n\t\tworldBorder: hydrochess_card.I64_MAX - 2000n,\n\t\tdefaultTimeLimitPerMoveMillis: 4000,\n\t\tdisplayName: 'HydroChess',\n\t\tmaxStrengthLevel: 3,\n\t},\n} satisfies { [key: string]: Engine };\n\n// Functions --------------------------------------------------------------------\n\n/**\n * Returns a formatted engine name string, optionally including its strength level.\n * If the provided strength level is the maximum for the engine, it is omitted.\n */\nexport function getFormattedEngineName(engineName: ValidEngine, strengthLevel?: number): string {\n\tconst name = engineDictionary[engineName].displayName;\n\tconst maxLevel = engineDictionary[engineName].maxStrengthLevel;\n\treturn strengthLevel !== undefined && strengthLevel !== maxLevel\n\t\t? `${name} (Level ${strengthLevel})`\n\t\t: name;\n}\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/engines/engineCheckmatePractice.ts",
    "content": "// src/client/scripts/esm/game/chess/engines/engineCheckmatePractice.ts\n\n/**\n * This script runs a chess engine for checkmate practice that computes the best move for the black royal piece.\n * It is called as a WebWorker from enginegame.js so that it can run asynchronously from the rest of the website.\n * You may specify a different engine to be used by specifying a different engine name in the gameOptions when initializing an engine game.\n *\n * @author Andreas Tsevas\n */\n\nimport type { Board, FullGame } from '../../../../../../shared/chess/logic/gamefile.js';\nimport type {\n\tCoords,\n\tCoordsKey,\n\tDoubleCoords,\n} from '../../../../../../shared/chess/util/coordutil.js';\n\nimport jsutil from '../../../../../../shared/util/jsutil.js';\nimport organizedpieces from '../../../../../../shared/chess/logic/organizedpieces.js';\nimport { primalityTest } from '../../../../../../shared/util/isprime.js';\nimport { detectInsufficientMaterial } from '../../../../../../shared/chess/logic/insufficientmaterial.js';\nimport icnconverter, { MoveCoords } from '../../../../../../shared/chess/logic/icn/icnconverter.js';\nimport {\n\trawTypes as r,\n\text as e,\n\tplayers as p,\n\tnumTypes,\n} from '../../../../../../shared/chess/util/typeutil.js';\n\n// If the Webworker during creation is not declared as a module, than type imports will have to be imported this way:\n// type gamefile = import(\"../../chess/logic/gamefile\").default;\n// type Coords = import(\"../../chess/util/coordutil\").Coords;\n\n/**\n * Let the main thread know that the Worker has finished fetching and\n * its code is now executing! We may now hide the spinny pawn loading animation.\n */\npostMessage('readyok');\n\n// Here, the engine webworker received messages from the outside\n\nself.onmessage = function (e: MessageEvent): void {\n\tconst message = e.data as {\n\t\tstringGamefile: string;\n\t\tengineConfig: { checkmateSelectedID: string; engineTimeLimitPerMoveMillis: number };\n\t\trequestGeneratedMoves: boolean;\n\t};\n\tif (message.requestGeneratedMoves) return; // ignore generated moves requests in this engine, this doesn't support sending them\n\tinput_gamefile = JSON.parse(message.stringGamefile, jsutil.parseReviver); // parse the gamefile (it's nested functions won't be included)\n\t// console.log(\"input_gamefile\", jsutil.deepCopyObject(input_gamefile));\n\tcheckmateSelectedID = message.engineConfig.checkmateSelectedID;\n\tengineTimeLimitPerMoveMillis = message.engineConfig.engineTimeLimitPerMoveMillis;\n\tgloballyBestScore = -Infinity;\n\tglobalSurvivalPlies = 0;\n\tgloballyBestVariation = {};\n\n\tif (!engineInitialized) initEvalWeightsAndSearchProperties(); // initialize the eval function weights and global search properties\n\n\tengineStartTime = Date.now();\n\tenginePositionCounter = 0;\n\trunEngine();\n};\n\n/** Seeded RNG function, will be initialized in runEngine() */\nlet rand: Function;\n\n/** Whether the engine has already been initialized for the current game */\nlet engineInitialized: boolean = false;\n\n/** Externally supplied gamefile */\nlet input_gamefile: FullGame;\n\n/** Start time of current engine calculation in millis */\nlet engineStartTime: number;\n/** The number of positions evaluated by this engine in total during current calculation */\nlet enginePositionCounter: number;\n/** Time limit for the engine to think in milliseconds */\nlet engineTimeLimitPerMoveMillis: number;\n\n// the ID of the currently selected checkmate\nlet checkmateSelectedID: string;\n\n// The informtion that is currently considered best by this engine\nlet globallyBestScore: number;\nlet globalSurvivalPlies: number;\nlet globallyBestVariation: { [key: number]: [number, DoubleCoords] };\n// e.g. { 0: [NaN, [1,0]], 1: [3,[2,4]], 2: [NaN, [-1,1]], 3: [2, [5,6]], ... } = { 0: black move, 1: white piece index & move, 2: black move, ... }\n\n// the real coordinates of the black royal piece in the gamefile\nlet gamefile_royal_coords: DoubleCoords;\n\n// Black royal piece properties. The black royal piece is always at square [0,0]\n// prettier-ignore\nconst king_moves: DoubleCoords[] = [\n\t[-1,  1], [0,  1], [1,  1],\n\t[-1,  0],          [1,  0],\n\t[-1, -1], [0, -1], [1, -1],\n];\n// prettier-ignore\nconst centaur_moves: DoubleCoords[] = [\n\t\t\t  [-1,  2],          [1,  2],\n\t[-2,  1], [-1,  1], [0,  1], [1,  1], [2,  1],\n\t\t\t  [-1,  0],          [1,  0],\n\t[-2, -1], [-1, -1], [0, -1], [1, -1], [2, -1],\n\t\t\t  [-1, -2],          [1, -2]\n];\n\nlet royal_moves: DoubleCoords[]; // king_moves or centaur_moves\nlet royal_type: 'k' | 'rc'; // \"k\" or \"rc\"\n\n// White pieces. Their coordinates are relative to the black royal\nlet start_piecelist: number[]; // list of white pieces in starting position, like [3,4,4,4,2, ... ]. Meaning of numbers given by pieceNameDictionary\nlet start_coordlist: DoubleCoords[]; // list of tuples, like [[2,3], [5,6], [6,7], ...], pieces are corresponding to ordering in start_piecelist\n\n// only used for parsing in the position\nconst pieceNameDictionary: { [pieceType: number]: number } = {\n\t// 0 corresponds to a captured piece\n\t[r.QUEEN + e.W]: 1,\n\t[r.ROOK + e.W]: 2,\n\t[r.BISHOP + e.W]: 3,\n\t[r.KNIGHT + e.W]: 4,\n\t[r.KING + e.W]: 5,\n\t[r.PAWN + e.W]: 6,\n\t[r.AMAZON + e.W]: 7,\n\t[r.HAWK + e.W]: 8,\n\t[r.CHANCELLOR + e.W]: 9,\n\t[r.ARCHBISHOP + e.W]: 10,\n\t[r.KNIGHTRIDER + e.W]: 11,\n\t[r.HUYGEN + e.W]: 12,\n};\n\nfunction invertPieceNameDictionary(json: { [key: string]: number }): { [key: number]: number } {\n\tconst inv: { [key: number]: number } = {};\n\tfor (const key in json) {\n\t\tinv[json[key]!] = Number(key);\n\t}\n\treturn inv;\n}\n\nconst invertedPieceNameDictionaty = invertPieceNameDictionary(pieceNameDictionary);\n\n// legal move storage for pieces in piecelist\n// prettier-ignore\nconst pieceTypeDictionary: { [key: number]: { rides?: DoubleCoords[], jumps?: DoubleCoords[], is_royal?: boolean, is_pawn?: boolean, is_huygen?: boolean } } = {\n\t0: {}, // 0 corresponds to a captured piece\n\t1: {rides: [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1], [1, -1], [-1, 1]]}, // queen\n\t2: {rides: [[1, 0], [0, 1], [-1, 0], [0, -1]]}, // rook\n\t3: {rides: [[1, 1], [-1, -1], [1, -1], [-1, 1]]}, // bishop\n\t4: {jumps: [[1, 2], [-1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, 1], [-2, -1]]}, // knight\n\t5: {jumps: [[-1, 1], [0, 1], [1, 1], [-1, 0], [1, 0], [-1, -1], [0, -1], [1, -1]], is_royal: true}, // king\n\t6: {jumps: [[0, 1]], is_pawn: true}, //pawn\n\t7: {rides: [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1], [1, -1], [-1, 1]],\n\t\tjumps: [[1, 2], [-1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, 1], [-2, -1]]}, // amazon\n\t8: {jumps: [[2, 0], [3, 0], [2, 2], [3, 3], [0, 2], [0, 3], [-2, 2], [-3, 3], [-2, 0], [-3, 0],\n\t\t[-2, -2], [-3, -3], [0, -2], [0, -3], [2, -2], [3, -3]]}, //hawk\n\t9: {rides: [[1, 0], [0, 1], [-1, 0], [0, -1]],\n\t\tjumps: [[1, 2], [-1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, 1], [-2, -1]]}, // chancellor\n\t10: {rides: [[1, 1], [-1, -1], [1, -1], [-1, 1]],\n\t\tjumps: [[1, 2], [-1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, 1], [-2, -1]]}, // archbishop\n\t11: {rides: [[1, 2], [-1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, 1], [-2, -1]]}, // knightrider\n\t12: {jumps: [[2, 0], [-2, 0], [0, 2], [0, -2]],\n\t\t rides: [[1, 0], [0, 1], [-1, 0], [0, -1]], is_huygen: true } // huygen\n};\n\n// define what \"short range\" means for each piece. Jump moves to at least as near as the values in this table are considered shortrange\nconst shortRangeJumpDictionary: { [key: number]: number } = {\n\t4: 5, // knight\n\t5: 4, // king - cannot be captured\n\t6: 4, // pawn\n\t7: 5, // amazon\n\t8: 8, // hawk\n\t9: 5, // chancellor\n\t10: 5, // archbishop\n\t12: 10, // huygen\n};\n\n// weights for the evaluation function\nlet pieceExistenceEvalDictionary: { [key: number]: number };\nlet distancesEvalDictionary: { [key: number]: [number, (_square: DoubleCoords) => number][] };\nlet legalMoveEvalDictionary: { [key: number]: { [key: number]: number } };\nlet centerOfMassEvalDictionary: {\n\t[key: string]: [number, number, number, (_square: DoubleCoords) => number][];\n};\n\n// number of candidate squares for white rider pieces to consider along a certain direction (2*wiggleroom + 1)\nlet wiggleroomDictionary: { [key: number]: number };\n\n// whether to consider white pawn moves as candidate moves\nlet ignorepawnmoves: boolean;\n\n// whether to consider white royal moves as candidate moves\nlet ignoreroyalmoves: boolean;\n\n// whether to enter \"trap flee mode\" whenever the black royal is surrounded by white pieces\nlet mayEnterTrapFleeMode: boolean;\nlet numOfPiecesForTrap: number;\nlet maxDistanceForTrap: number;\nlet maxDistanceForRoyal_Flee: number;\nlet trapFleeDictionary: { [key: string]: [number, number, number] };\n\n// whether to enter \"protected rider flee mode\" whenever the black royal is near the specified protected white rider\nlet mayEnterProtectedRiderFleeMode: boolean;\nlet riderTypeToFleeFrom: number;\nlet maxDistanceForRider: number;\nlet maxDistanceForProtector: number;\nlet protectedRiderFleeDictionary: { [key: string]: [number, number, number] };\n\n// bestMoveList stores the best black response for very specific positions in some variants\nlet bestMoveList: { bestMove: DoubleCoords; piecelist: number[]; coordlist: DoubleCoords[] }[] = [];\n\n/**\n * This method initializes the weights the evaluation function according to the checkmate ID provided, as well as global search properties\n */\nfunction initEvalWeightsAndSearchProperties(): void {\n\t// default\n\tignorepawnmoves = false;\n\n\t// default\n\tignoreroyalmoves = false;\n\n\t// default\n\tmayEnterTrapFleeMode = false;\n\n\t// default\n\tmayEnterProtectedRiderFleeMode = false;\n\n\t// weights for piece values of white pieces\n\tpieceExistenceEvalDictionary = {\n\t\t0: 0, // 0 corresponds to a captured piece\n\t\t1: -1_000_000, // queen\n\t\t2: -800_000, // rook\n\t\t3: -100_000, // bishop\n\t\t4: -800_000, // knight\n\t\t5: 0, // king - cannot be captured\n\t\t6: -100_000, // pawn\n\t\t7: -1_000_000, // amazon\n\t\t8: -800_000, // hawk\n\t\t9: -800_000, // chancellor\n\t\t10: -800_000, // archbishop\n\t\t11: -800_000, // knightrider\n\t\t12: -800_000, // huygen\n\t};\n\n\t// weights and distance functions for white piece distance to the black king\n\t// the first entry for each piece is for black to move, the second entry is for white to move\n\t// prettier-ignore\n\tdistancesEvalDictionary = {\n\t\t1: [[2, manhattanNorm], [2, manhattanNorm]], // queen\n\t\t2: [[2, manhattanNorm], [2, manhattanNorm]], // rook\n\t\t3: [[2, manhattanNorm], [2, manhattanNorm]], // bishop\n\t\t4: [[15, manhattanNorm], [15, manhattanNorm]], // knight\n\t\t5: [[30, manhattanNorm], [30, manhattanNorm]], // king\n\t\t6: [[200, pawnNorm], [200, pawnNorm]], // pawn\n\t\t7: [[14, manhattanNorm], [14, manhattanNorm]], // amazon\n\t\t8: [[7, manhattanNorm], [7, manhattanNorm]], // hawk\n\t\t9: [[2, manhattanNorm], [2, manhattanNorm]], // chancellor\n\t\t10: [[16, manhattanNorm], [16, manhattanNorm]], // archbishop\n\t\t11: [[16, manhattanNorm], [16, manhattanNorm]], // knightrider\n\t\t12: [[6, manhattanNorm], [6, manhattanNorm]], // huygen\n\t};\n\n\t// eval scores for number of legal moves of black royal\n\tif (royal_type === 'k') {\n\t\tlegalMoveEvalDictionary = {\n\t\t\t// in check\n\t\t\t0: {\n\t\t\t\t0: -Infinity, // checkmate\n\t\t\t\t1: -75,\n\t\t\t\t2: -50,\n\t\t\t\t3: -25,\n\t\t\t\t4: -12,\n\t\t\t\t5: -8,\n\t\t\t\t6: -4,\n\t\t\t\t7: -2,\n\t\t\t\t8: 0,\n\t\t\t},\n\t\t\t// not in check\n\t\t\t1: {\n\t\t\t\t0: Infinity, // stalemate\n\t\t\t\t1: -60,\n\t\t\t\t2: -45,\n\t\t\t\t3: -22,\n\t\t\t\t4: -10,\n\t\t\t\t5: -6,\n\t\t\t\t6: -3,\n\t\t\t\t7: -1,\n\t\t\t\t8: 0,\n\t\t\t},\n\t\t};\n\t} else {\n\t\tlegalMoveEvalDictionary = {\n\t\t\t// in check\n\t\t\t0: {\n\t\t\t\t0: -Infinity, // checkmate\n\t\t\t\t1: -100,\n\t\t\t\t2: -90,\n\t\t\t\t3: -80,\n\t\t\t\t4: -70,\n\t\t\t\t5: -50,\n\t\t\t\t6: -40,\n\t\t\t\t7: -30,\n\t\t\t\t8: -25,\n\t\t\t\t9: -20,\n\t\t\t\t10: -15,\n\t\t\t\t11: -12.5,\n\t\t\t\t12: -10,\n\t\t\t\t13: -7.5,\n\t\t\t\t14: -5,\n\t\t\t\t15: -2.5,\n\t\t\t\t16: 0,\n\t\t\t},\n\t\t\t// not in check\n\t\t\t1: {\n\t\t\t\t0: Infinity, // stalemate\n\t\t\t\t1: -100,\n\t\t\t\t2: -85,\n\t\t\t\t3: -75,\n\t\t\t\t4: -65,\n\t\t\t\t5: -45,\n\t\t\t\t6: -35,\n\t\t\t\t7: -25,\n\t\t\t\t8: -20,\n\t\t\t\t9: -15,\n\t\t\t\t10: -12.5,\n\t\t\t\t11: -10,\n\t\t\t\t12: -7.5,\n\t\t\t\t13: -5,\n\t\t\t\t14: -2,\n\t\t\t\t15: -1,\n\t\t\t\t16: 0,\n\t\t\t},\n\t\t};\n\n\t\tengineInitialized = true;\n\t}\n\n\t// number of candidate squares for white rider pieces to consider along a certain direction (2*wiggleroom + 1)\n\twiggleroomDictionary = {\n\t\t1: 2, // queen\n\t\t2: 2, // rook\n\t\t3: 2, // bishop\n\t\t7: 2, // amazon\n\t\t9: 2, // chancellor\n\t\t10: 1, // archbishop\n\t\t11: 1, // knightrider\n\t\t12: 5, // huygen\n\t};\n\n\t// variant-specific weights:\n\n\t// score for distance of black royal to center of mass of white pieces of given type near black king\n\t// piecetype, cutoff, weight, distancefunction\n\t// prettier-ignore\n\tcenterOfMassEvalDictionary = {\n\t\t\"1K1N2B1B-1k\": [[3, 14, 20, manhattanNorm], [3, 14, 20, manhattanNorm]], // bishop\n\t\t\"5HU-1k\": [[12, 20, 30, manhattanNorm], [12, 20, 30, manhattanNorm]], // huygen\n\t};\n\n\t// whether to enter \"trap flee mode\" whenever the black royal is surrounded by white pieces\n\t// numOfPiecesForTrap, maxDistanceForTrap, maxDistanceForRoyal_Flee\n\ttrapFleeDictionary = {\n\t\t'1K2HA1B-1k': [3, 8, 10],\n\t\t'1K3HA-1k': [3, 14, 10],\n\t};\n\n\tif (checkmateSelectedID in trapFleeDictionary) {\n\t\tmayEnterTrapFleeMode = true;\n\t\t[numOfPiecesForTrap, maxDistanceForTrap, maxDistanceForRoyal_Flee] =\n\t\t\ttrapFleeDictionary[checkmateSelectedID]!;\n\t}\n\n\t// whether to enter \"protected rider flee mode\" whenever the black royal is near the specified protected white rider\n\t// riderTypeToFleeFrom, maxDistanceForRider, maxDistanceForProtector\n\tprotectedRiderFleeDictionary = {\n\t\t'1K1R2N-1k': [2, Infinity, 10], // rook\n\t\t'1K1CH1N-1k': [9, Infinity, 10], // chancellor\n\t};\n\n\tif (checkmateSelectedID in protectedRiderFleeDictionary) {\n\t\tmayEnterProtectedRiderFleeMode = true;\n\t\t[riderTypeToFleeFrom, maxDistanceForRider, maxDistanceForProtector] =\n\t\t\tprotectedRiderFleeDictionary[checkmateSelectedID]!;\n\t}\n\n\t// prettier-ignore\n\tswitch (checkmateSelectedID) {\n\t\tcase '2Q-1k':\n\t\t\tlegalMoveEvalDictionary = {\n\t\t\t\t// in check\n\t\t\t\t0: {\n\t\t\t\t\t0: -Infinity, // checkmate\n\t\t\t\t\t1: -250,\n\t\t\t\t\t2: -220,\n\t\t\t\t\t3: -190,\n\t\t\t\t\t4: -160,\n\t\t\t\t\t5: -120,\n\t\t\t\t\t6: -90,\n\t\t\t\t\t7: -60,\n\t\t\t\t\t8: 0,\n\t\t\t\t},\n\t\t\t\t// not in check\n\t\t\t\t1: {\n\t\t\t\t\t0: Infinity, // stalemate\n\t\t\t\t\t1: -220,\n\t\t\t\t\t2: -190,\n\t\t\t\t\t3: -160,\n\t\t\t\t\t4: -130,\n\t\t\t\t\t5: -100,\n\t\t\t\t\t6: -70,\n\t\t\t\t\t7: -40,\n\t\t\t\t\t8: 0,\n\t\t\t\t},\n\t\t\t};\n\t\t\tbreak;\n\t\tcase '1K1AM-1k':\n\t\t\tignoreroyalmoves = true;\n\t\t\tlegalMoveEvalDictionary = {\n\t\t\t\t// in check\n\t\t\t\t0: {\n\t\t\t\t\t0: -Infinity, // checkmate\n\t\t\t\t\t1: 0,\n\t\t\t\t\t2: 0,\n\t\t\t\t\t3: 0,\n\t\t\t\t\t4: 0,\n\t\t\t\t\t5: 0,\n\t\t\t\t\t6: 0,\n\t\t\t\t\t7: 0,\n\t\t\t\t\t8: 0,\n\t\t\t\t},\n\t\t\t\t// not in check\n\t\t\t\t1: {\n\t\t\t\t\t0: Infinity, // stalemate\n\t\t\t\t\t1: 0,\n\t\t\t\t\t2: 0,\n\t\t\t\t\t3: 0,\n\t\t\t\t\t4: 0,\n\t\t\t\t\t5: 0,\n\t\t\t\t\t6: 0,\n\t\t\t\t\t7: 0,\n\t\t\t\t\t8: 0,\n\t\t\t\t},\n\t\t\t};\n\t\t\tbreak;\n\t\tcase \"1K2N1B1B-1k\":\n\t\t\tdistancesEvalDictionary[3] = [[12, manhattanNorm], [12, manhattanNorm]]; // bishop\n\t\t\tbreak;\n\t\tcase \"1K1R1B1B-1k\":\n\t\t\tdistancesEvalDictionary[5] = [[15, specialNorm], [15, specialNorm]]; // king\n\t\t\tbreak;\n\t\tcase \"1K1R1N1B-1k\":\n\t\t\tdistancesEvalDictionary[4] = [[8, specialNorm], [8, specialNorm]]; // knight\n\t\t\tbreak;\n\t\tcase \"2K1R-1k\":\n\t\t\tdistancesEvalDictionary[5] = [[40, specialNorm], [40, specialNorm]]; // king\n\t\t\tbreak;\n\t\tcase \"1K2AR-1k\":\n\t\t\tdistancesEvalDictionary[10] = [[15, vincinityNorm], [15, vincinityNorm]]; // archbishop\n\t\t\tdistancesEvalDictionary[5] = [[15, manhattanNorm], [15, manhattanNorm]]; // king\n\t\t\tbreak;\n\t\tcase '2R1N1P-1k':\n\t\t\tignorepawnmoves = true;\n\t\t\tbreak;\n\t\tcase \"1K2N6B-1k\":\n\t\t\tdistancesEvalDictionary[4] = [[30, vincinityNorm], [30, vincinityNorm]]; // knight\n\t\t\tlegalMoveEvalDictionary = {\n\t\t\t\t// in check\n\t\t\t\t0: {\n\t\t\t\t\t0: -Infinity, // checkmate\n\t\t\t\t\t1: -250,\n\t\t\t\t\t2: -220,\n\t\t\t\t\t3: -190,\n\t\t\t\t\t4: -160,\n\t\t\t\t\t5: -120,\n\t\t\t\t\t6: -90,\n\t\t\t\t\t7: -60,\n\t\t\t\t\t8: 0,\n\t\t\t\t},\n\t\t\t\t// not in check\n\t\t\t\t1: {\n\t\t\t\t\t0: Infinity, // stalemate\n\t\t\t\t\t1: -220,\n\t\t\t\t\t2: -190,\n\t\t\t\t\t3: -160,\n\t\t\t\t\t4: -130,\n\t\t\t\t\t5: -100,\n\t\t\t\t\t6: -70,\n\t\t\t\t\t7: -40,\n\t\t\t\t\t8: 0,\n\t\t\t\t},\n\t\t\t};\n\t\t\tbreak;\n\t\tcase \"1K1Q1P-1k\":\n\t\t\tdistancesEvalDictionary[1] = [[-5, manhattanNorm], [-5, manhattanNorm]]; // queen\n\t\t\tdistancesEvalDictionary[5] = [[0, () => 0], [0, () => 0]]; // king\n\t\t\tbestMoveList = [\n\t\t\t\t{bestMove: [1,-1], piecelist: [5, 6, 1], coordlist: [[0,2],[0,-3],[-2,-2]]},\n\t\t\t\t{bestMove: [-1,-1], piecelist: [5, 6, 1], coordlist: [[0,2],[0,-3],[2,-2]]},\n\t\t\t\t{bestMove: [1,-1], piecelist: [5, 6, 1], coordlist: [[0,2],[0,-4],[-2,-2]]},\n\t\t\t\t{bestMove: [-1,-1], piecelist: [5, 6, 1], coordlist: [[0,2],[0,-4],[2,-2]]},\n\t\t\t\t{bestMove: [1,-1], piecelist: [5, 1, 6], coordlist: [[0,2],[-2,-2],[0,-5]]},\n\t\t\t\t{bestMove: [-1,-1], piecelist: [5, 1, 6], coordlist: [[0,2],[2,-2],[0,-5]]},\n\n\t\t\t\t{bestMove: [1,0], piecelist: [6, 1, 5], coordlist: [[1,-2],[-2,-1],[1,2]]},\n\t\t\t\t{bestMove: [-1,0], piecelist: [6, 1, 5], coordlist: [[-1,-2],[2,-1],[-1,2]]},\n\n\t\t\t\t{bestMove: [1,-1], piecelist: [6, 5, 1], coordlist: [[1,-2],[1,2],[-1,-5]]},\n\t\t\t\t{bestMove: [-1,-1], piecelist: [6, 5, 1], coordlist: [[-1,-2],[-1,2],[1,-5]]},\n\n\t\t\t\t{bestMove: [0,-1], piecelist: [6, 5, 1], coordlist: [[0,-2],[0,2],[-3,-3]]},\n\t\t\t\t{bestMove: [0,-1], piecelist: [6, 5, 1], coordlist: [[0,-2],[0,2],[3,-3]]},\n\n\t\t\t\t{bestMove: [0,-1], piecelist: [6, 5, 1], coordlist: [[0,-2],[0,2],[-1,-3]]},\n\t\t\t\t{bestMove: [0,-1], piecelist: [6, 5, 1], coordlist: [[0,-2],[0,2],[1,-3]]},\n\n\t\t\t\t{bestMove: [1,-1], piecelist: [5, 6, 1], coordlist: [[0,2],[0,-3],[-4,-4]]},\n\t\t\t\t{bestMove: [-1,-1], piecelist: [5, 6, 1], coordlist: [[0,2],[0,-3],[4,-4]]},\n\n\t\t\t\t{bestMove: [0,-1], piecelist: [5, 1, 6], coordlist: [[1,2],[-1,-3],[1,-3]]},\n\t\t\t\t{bestMove: [0,-1], piecelist: [5, 6, 1], coordlist: [[-1,2],[-1,-3],[1,-3]]},\n\n\t\t\t\t{bestMove: [-1,1], piecelist: [1, 6, 5], coordlist: [[-2,-1],[2,-2],[1,3]]},\n\t\t\t\t{bestMove: [1,1], piecelist: [1, 6, 5], coordlist: [[2,-1],[-2,-2],[-1,3]]},\n\t\t\t];\n\t\t\tbreak;\n\t\tcase \"1K3NR-1k\":\n\t\t\tdistancesEvalDictionary[5] = [[20, manhattanNorm], [20, manhattanNorm]]; // king\n\t\t\tlegalMoveEvalDictionary = {\n\t\t\t\t// in check\n\t\t\t\t0: {\n\t\t\t\t\t0: -Infinity, // checkmate\n\t\t\t\t\t1: -25,\n\t\t\t\t\t2: -17,\n\t\t\t\t\t3: -8,\n\t\t\t\t\t4: -4,\n\t\t\t\t\t5: -3,\n\t\t\t\t\t6: -2,\n\t\t\t\t\t7: -1,\n\t\t\t\t\t8: 0,\n\t\t\t\t},\n\t\t\t\t// not in check\n\t\t\t\t1: {\n\t\t\t\t\t0: Infinity, // stalemate\n\t\t\t\t\t1: -20,\n\t\t\t\t\t2: -15,\n\t\t\t\t\t3: -6,\n\t\t\t\t\t4: -3,\n\t\t\t\t\t5: -2,\n\t\t\t\t\t6: -1,\n\t\t\t\t\t7: -1,\n\t\t\t\t\t8: 0,\n\t\t\t\t},\n\t\t\t};\n\t\t\tbreak;\n\t}\n}\n\n// computes the 2-norm of a square\nfunction diagonalNorm(square: DoubleCoords): number {\n\treturn Math.sqrt(square[0] ** 2 + square[1] ** 2);\n}\n\n// computes the squared 2-norm of a square\nfunction diagonalNormSquared(square: DoubleCoords): number {\n\treturn square[0] ** 2 + square[1] ** 2;\n}\n\n// computes the manhattan norm of a square\nfunction manhattanNorm(square: DoubleCoords): number {\n\treturn Math.abs(square[0]) + Math.abs(square[1]);\n}\n\n// computes the manhattan distance of two squares\nfunction manhattanDistance(square1: DoubleCoords, square2: DoubleCoords): number {\n\treturn Math.abs(square1[0] - square2[0]) + Math.abs(square1[1] - square2[1]);\n}\n\n// special norm = manhattan + diagonal\nfunction specialNorm(square: DoubleCoords): number {\n\treturn diagonalNorm(square) + manhattanNorm(square);\n}\n\n// pawn norm: gives slight malus for black king being near and above the pawn. Also gives malus for black king being above white pawn everywhere\nfunction pawnNorm(square: DoubleCoords): number {\n\tconst prefactor = square[1] < 0 && manhattanNorm(square) < 5 ? 1 : 6;\n\treturn prefactor * (0.5 * diagonalNorm(square) + 1.5 * manhattanNorm(square) + 0.5 * square[1]);\n}\n\n// special norm, which gives a massive malus to the piece being near the black king for black\nfunction vincinityNorm(square: DoubleCoords): number {\n\tconst diagnormsquared = diagonalNormSquared(square);\n\tconst penalty =\n\t\tdiagnormsquared < 3 ? -16 : diagnormsquared < 9 ? -8 : diagnormsquared < 19 ? -4 : 0;\n\treturn manhattanNorm(square) + penalty;\n}\n\n// center of mass of all white pieces near the black king\nfunction get_center_of_mass(\n\tpiece_type: number,\n\tcutoff: number,\n\tpiecelist: number[],\n\tcoordlist: DoubleCoords[],\n): DoubleCoords | false {\n\tlet numpieces: number = 0;\n\tlet center: DoubleCoords = [0, 0];\n\tfor (let i = 0; i < piecelist.length; i++) {\n\t\tif (piecelist[i] === piece_type && manhattanNorm(coordlist[i]!) <= cutoff) {\n\t\t\tcenter = add_move(center, coordlist[i]!);\n\t\t\tnumpieces++;\n\t\t}\n\t}\n\tif (numpieces === 0) return false;\n\telse return rescaleVector(1 / numpieces, center);\n}\n\n/**\n * Checks if v is a multiple of direction, and returns a boolean and the factor\n * @param v - vector like [10,20]\n * @param direction - vector like [1,2]\n * @returns like [boolean, scalar multiple factor]\n */\nfunction is_natural_multiple(v: DoubleCoords, direction: DoubleCoords): [boolean, number] {\n\tlet scalar: number;\n\tif (direction[0] !== 0) scalar = v[0] / direction[0];\n\telse scalar = v[1] / direction[1];\n\n\treturn [scalar > 0 && scalar * direction[0] === v[0] && scalar * direction[1] === v[1], scalar];\n}\n\n// checks if a rider on a given square threatens a given target square\n// exclude_white_piece_squares specifies whether to exclude occupied squares from being threatened\n// ignore_blockers specifies whether to completely ignore blocking pieces in piecelist&coordlist\n// threatening_own_square specifies whether a piece can threaten its own square\nfunction rider_threatens(\n\tdirection: DoubleCoords,\n\tpiece_square: DoubleCoords,\n\ttarget_square: DoubleCoords,\n\tis_huygen: boolean,\n\tpiecelist: number[],\n\tcoordlist: DoubleCoords[],\n\t{\n\t\texclude_white_piece_squares = false,\n\t\tignore_blockers = false,\n\t\tthreatening_own_square = false,\n\t} = {},\n): boolean {\n\tif (threatening_own_square && squares_are_equal(piece_square, target_square)) return true;\n\tconst [works, distance] = is_natural_multiple(\n\t\t[target_square[0] - piece_square[0], target_square[1] - piece_square[1]],\n\t\tdirection,\n\t);\n\tif (!works) return false;\n\tif (is_huygen && !primalityTest(distance)) return false;\n\tif (ignore_blockers) return true;\n\t// loop over all potential blockers\n\tfor (let i = 0; i < coordlist.length; i++) {\n\t\tif (piecelist[i] === 0) continue;\n\t\telse if (exclude_white_piece_squares && squares_are_equal(coordlist[i]!, target_square))\n\t\t\treturn false;\n\n\t\tconst [collinear, thispiecedistance] = is_natural_multiple(\n\t\t\t[coordlist[i]![0]! - piece_square[0]!, coordlist[i]![1]! - piece_square[1]!],\n\t\t\tdirection,\n\t\t);\n\t\tif (!collinear) continue;\n\t\telse if (is_huygen && !primalityTest(thispiecedistance)) continue;\n\t\telse if (thispiecedistance < distance) return false;\n\t}\n\treturn true;\n}\n\n// adds two squares\nfunction add_move(square: DoubleCoords, v: DoubleCoords): DoubleCoords {\n\treturn [square[0] + v[0], square[1] + v[1]];\n}\n\n// stretches vector by scalar\nfunction rescaleVector(scalar: number, v: DoubleCoords): DoubleCoords {\n\treturn [scalar * v[0], scalar * v[1]];\n}\n\n// computes the cross product of two vectors\nfunction crossProduct(v1: DoubleCoords, v2: DoubleCoords): number {\n\treturn v1[0] * v2[1] - v1[1] * v2[0];\n}\n\n// checks if two squares are equal\nfunction squares_are_equal(square_1: DoubleCoords, square_2: DoubleCoords): boolean {\n\treturn square_1[0] === square_2[0] && square_1[1] === square_2[1];\n}\n\n// checks if a list of squares contains a given square\nfunction tuplelist_contains_tuple(tuplelist: DoubleCoords[], tuple: DoubleCoords): boolean {\n\treturn tuplelist.some((entry) => squares_are_equal(entry, tuple));\n}\n\n// checks if a square is occupied by a white piece\nfunction square_is_occupied(\n\tsquare: DoubleCoords,\n\tpiecelist: number[],\n\tcoordlist: DoubleCoords[],\n): boolean {\n\treturn coordlist.some(\n\t\t(entry, index) => piecelist[index] !== 0 && squares_are_equal(entry, square),\n\t);\n}\n\n// checks if a white piece at index piece_index in the piecelist&coordlist threatens a given square\nfunction piece_threatens_square(\n\tpiece_index: number,\n\ttarget_square: DoubleCoords,\n\tpiecelist: number[],\n\tcoordlist: DoubleCoords[],\n): boolean {\n\tconst piece_type = piecelist[piece_index]!;\n\n\t// piece no longer exists\n\tif (piece_type === 0) return false;\n\n\tconst piece_properties = pieceTypeDictionary[piece_type]!;\n\tconst piece_square = coordlist[piece_index]!;\n\n\t// piece is already on square\n\tif (squares_are_equal(piece_square, target_square)) return false;\n\n\t// pawn threatening\n\tif (piece_properties.is_pawn) {\n\t\tif (\n\t\t\tsquares_are_equal(add_move(piece_square, [-1, 1]), target_square) ||\n\t\t\tsquares_are_equal(add_move(piece_square, [1, 1]), target_square)\n\t\t)\n\t\t\treturn true;\n\t\telse return false;\n\t}\n\n\t// jump move threatening\n\tif (piece_properties.jumps) {\n\t\tif (\n\t\t\ttuplelist_contains_tuple(piece_properties.jumps, [\n\t\t\t\ttarget_square[0] - piece_square[0],\n\t\t\t\ttarget_square[1] - piece_square[1],\n\t\t\t])\n\t\t)\n\t\t\treturn true;\n\t}\n\n\t// rider move threatening\n\tif (piece_properties.rides) {\n\t\tfor (const ride_directrion of piece_properties.rides) {\n\t\t\tconst is_huygen = piece_properties.is_huygen ? true : false;\n\t\t\tif (\n\t\t\t\trider_threatens(\n\t\t\t\t\tride_directrion,\n\t\t\t\t\tpiece_square,\n\t\t\t\t\ttarget_square,\n\t\t\t\t\tis_huygen,\n\t\t\t\t\tpiecelist,\n\t\t\t\t\tcoordlist,\n\t\t\t\t)\n\t\t\t)\n\t\t\t\treturn true;\n\t\t}\n\t}\n\n\treturn false;\n}\n\n// checks if any white piece threatens a given square\nfunction square_is_threatened(\n\ttarget_square: DoubleCoords,\n\tpiecelist: number[],\n\tcoordlist: DoubleCoords[],\n): boolean {\n\tfor (let index = 0; index < coordlist.length; index++) {\n\t\tif (piece_threatens_square(index, target_square, piecelist, coordlist)) return true;\n\t}\n\treturn false;\n}\n\n/**\n * Computes an array of all the squares that the black royal can legally move to in the given position\n */\nfunction get_black_legal_moves(\n\tinTrapFleeMode: boolean,\n\tpiecelist: number[],\n\tcoordlist: DoubleCoords[],\n): DoubleCoords[] {\n\t// If black is in flee mode, he cannot capture white pieces\n\treturn royal_moves.filter(\n\t\t(square) =>\n\t\t\t!square_is_threatened(square, piecelist, coordlist) &&\n\t\t\t!(inTrapFleeMode && square_is_occupied(square, piecelist, coordlist)),\n\t);\n}\n\n/**\n * Computes the number of squares that the black royal can legally move to in the given position\n */\nfunction get_black_legal_move_amount(\n\tinTrapFleeMode: boolean,\n\tpiecelist: number[],\n\tcoordlist: DoubleCoords[],\n): number {\n\treturn get_black_legal_moves(inTrapFleeMode, piecelist, coordlist).length;\n}\n\n// checks if the black royal is in check\nfunction is_check(piecelist: number[], coordlist: DoubleCoords[]): boolean {\n\treturn square_is_threatened([0, 0], piecelist, coordlist);\n}\n\n// Unused functions\n/*\n// checks if the black royal is mated\nfunction is_mate(inTrapFleeMode, piecelist, coordlist) {\n\tif (get_black_legal_move_amount(inTrapFleeMode, piecelist, coordlist) == 0 && square_is_threatened([0, 0], piecelist, coordlist)) return true;\n\telse return false;\n}\n\n// checks if the black royal is stalemated\nfunction is_stalemate(inTrapFleeMode, piecelist, coordlist) {\n\tif (get_black_legal_move_amount(inTrapFleeMode, piecelist, coordlist) == 0 && !square_is_threatened([0, 0], piecelist, coordlist)) return true;\n\telse return false;\n}\n*/\n\n// determine if black is surrounded by at least numOfPiecesForTrap nonroyal white pieces\nfunction isBlackInTrap(piecelist: number[], coordlist: DoubleCoords[]): boolean {\n\tlet nearbyNonroyalWhites = 0;\n\tfor (let i = 0; i < piecelist.length; i++) {\n\t\tif (piecelist[i]! !== 0 && manhattanNorm(coordlist[i]!) <= maxDistanceForTrap) {\n\t\t\tif (!pieceTypeDictionary[piecelist[i]!]!.is_royal) nearbyNonroyalWhites++;\n\t\t\t// black is not in trap if white royal is nearby\n\t\t\telse if (manhattanNorm(coordlist[i]!) <= maxDistanceForRoyal_Flee) return false;\n\t\t}\n\t}\n\t// black is surrounded by at least numOfPiecesForTrap nonroyal white pieces\n\treturn nearbyNonroyalWhites >= numOfPiecesForTrap;\n}\n\n// determine if black is near specified protected rider\nfunction isBlackNearProtectedRider(piecelist: number[], coordlist: DoubleCoords[]): boolean {\n\tfor (let i = 0; i < piecelist.length; i++) {\n\t\tif (piecelist[i] === riderTypeToFleeFrom) {\n\t\t\tif (manhattanNorm(coordlist[i]!) <= maxDistanceForRider) {\n\t\t\t\tfor (let j = 0; j < piecelist.length; j++) {\n\t\t\t\t\tif (\n\t\t\t\t\t\tj !== i &&\n\t\t\t\t\t\tpiecelist[j] !== 0 &&\n\t\t\t\t\t\tmanhattanDistance(coordlist[i]!, coordlist[j]!) <= maxDistanceForProtector\n\t\t\t\t\t) {\n\t\t\t\t\t\treturn true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t// single rider that matters is not protected or too far away\n\t\t\treturn false;\n\t\t}\n\t}\n\treturn false;\n}\n\n// calculate a list of interesting squares to move to for a white piece with a certain piece index\nfunction get_white_piece_candidate_squares(\n\tpiece_index: number,\n\tpiecelist: number[],\n\tcoordlist: DoubleCoords[],\n): DoubleCoords[] {\n\tconst candidate_squares: DoubleCoords[] = [];\n\n\tconst piece_type = piecelist[piece_index]!;\n\n\t// piece no longer exists\n\tif (piece_type === 0) return candidate_squares;\n\n\tconst piece_properties = pieceTypeDictionary[piece_type]!;\n\tconst piece_square = coordlist[piece_index]!;\n\n\tif (ignorepawnmoves && piece_properties.is_pawn) return candidate_squares;\n\tif (ignoreroyalmoves && piece_properties.is_royal) return candidate_squares;\n\n\t// jump moves\n\tif (piece_properties.jumps) {\n\t\tconst num_jumps = piece_properties.jumps.length;\n\t\tconst shortrangeLimit = shortRangeJumpDictionary[piece_type]!;\n\t\tlet best_target_square: DoubleCoords;\n\t\tlet bestmove_distance = Infinity;\n\t\tlet bestmove_diagSquaredNorm = Infinity;\n\t\tfor (let move_index = 0; move_index < num_jumps; move_index++) {\n\t\t\tconst target_square = add_move(piece_square, piece_properties.jumps[move_index]!);\n\t\t\t// do not jump onto an occupied square\n\t\t\tif (square_is_occupied(target_square, piecelist, coordlist)) continue;\n\t\t\t// do not move a royal piece onto a square controlled by black\n\t\t\tif (piece_properties.is_royal && tuplelist_contains_tuple(royal_moves, target_square))\n\t\t\t\tcontinue;\n\t\t\t// check if target_square is a royal move\n\t\t\tif (tuplelist_contains_tuple(royal_moves, target_square)) {\n\t\t\t\tlet blunders_piece = true;\n\t\t\t\t// create copy of piece list without piece at piece_index\n\t\t\t\tconst temp_piecelist = [...piecelist];\n\t\t\t\ttemp_piecelist[piece_index] = 0;\n\t\t\t\t// only consider target square if another piece defends it as well, else it will be captured\n\t\t\t\tfor (let index = 0; index < coordlist.length; index++) {\n\t\t\t\t\tif (\n\t\t\t\t\t\tindex !== piece_index &&\n\t\t\t\t\t\tpiece_threatens_square(index, target_square, temp_piecelist, coordlist)\n\t\t\t\t\t) {\n\t\t\t\t\t\tblunders_piece = false;\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (blunders_piece) continue;\n\t\t\t}\n\t\t\tconst target_distance = manhattanNorm(target_square);\n\t\t\tconst target_diagSquaredNorm = diagonalNormSquared(target_square); // tiebreaker\n\t\t\t// only add jump moves that are short range in relation to black king\n\t\t\tif (target_distance <= shortrangeLimit) {\n\t\t\t\tcandidate_squares.push(target_square);\n\t\t\t}\n\t\t\t// keep single jump move nearest to the black king in memory\n\t\t\telse if (\n\t\t\t\ttarget_distance < bestmove_distance ||\n\t\t\t\t(target_distance === bestmove_distance &&\n\t\t\t\t\ttarget_diagSquaredNorm < bestmove_diagSquaredNorm)\n\t\t\t) {\n\t\t\t\tbestmove_distance = target_distance;\n\t\t\t\tbestmove_diagSquaredNorm = target_diagSquaredNorm;\n\t\t\t\tbest_target_square = target_square;\n\t\t\t}\n\t\t}\n\t\t// if no jump move has been added and piece has no ride moves or is a huygens, add single best jump move as candidate\n\t\tif (\n\t\t\tcandidate_squares.length === 0 &&\n\t\t\tbest_target_square! !== undefined &&\n\t\t\t(!piece_properties.rides || piece_properties.is_huygen)\n\t\t)\n\t\t\tcandidate_squares.push(best_target_square!);\n\t}\n\n\t// ride moves\n\tif (piece_properties.rides) {\n\t\tconst num_directions = piece_properties.rides.length;\n\t\t// check each pair of rider directions v1 and v2.\n\t\t// Project them onto the square coordinates by solving c1*v1 + c2*v2 == - piece_square.\n\t\t// only works if movement directions are not collinear\n\t\t// See https://math.stackexchange.com/a/1307635/998803\n\t\tfor (let i1 = 0; i1 < num_directions; i1++) {\n\t\t\tconst v1 = piece_properties.rides[i1]!;\n\t\t\tfor (let i2 = i1 + 1; i2 < num_directions; i2++) {\n\t\t\t\tconst v2 = piece_properties.rides[i2]!;\n\t\t\t\tconst denominator = crossProduct(v1, v2);\n\t\t\t\tif (denominator === 0) continue;\n\t\t\t\tconst c1 = crossProduct(v2, piece_square) / denominator;\n\t\t\t\tconst c2 = -crossProduct(v1, piece_square) / denominator;\n\t\t\t\tif (c1 < 0 || c2 <= 0) continue;\n\t\t\t\t// suitable values for c1 and c2 were found, now compute min and max values for c1 and c2 to consider\n\t\t\t\tconst c1_min = Math.ceil(c1 - wiggleroomDictionary[piece_type]!);\n\t\t\t\tconst c1_max = Math.floor(c1 + wiggleroomDictionary[piece_type]!);\n\t\t\t\tconst c2_min = Math.ceil(c2 - wiggleroomDictionary[piece_type]!);\n\t\t\t\tconst c2_max = Math.floor(c2 + wiggleroomDictionary[piece_type]!);\n\n\t\t\t\t// adds suitable squares along v1 to the candidates list\n\t\t\t\t// prettier-ignore\n\t\t\t\tadd_suitable_squares_to_candidate_list(\n\t\t\t\t\tcandidate_squares, piece_index, piece_square, v1, v2,\n\t\t\t\t\tc1_min, c1_max, c2_min, c2_max, piecelist, coordlist\n\t\t\t\t);\n\n\t\t\t\t// adds suitable squares along v2 to the candidates list\n\t\t\t\t// prettier-ignore\n\t\t\t\tadd_suitable_squares_to_candidate_list(\n\t\t\t\t\tcandidate_squares, piece_index, piece_square, v2, v1,\n\t\t\t\t\tc2_min, c2_max, c1_min, c1_max, piecelist, coordlist\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn candidate_squares;\n}\n\n// adds suitable squares along v1 to the candidates list, using v2 as the attack vector towards the king\nfunction add_suitable_squares_to_candidate_list(\n\tcandidate_squares: DoubleCoords[],\n\tpiece_index: number,\n\tpiece_square: DoubleCoords,\n\tv1: DoubleCoords,\n\tv2: DoubleCoords,\n\tc1_min: number,\n\tc1_max: number,\n\tc2_min: number,\n\tc2_max: number,\n\tpiecelist: number[],\n\tcoordlist: DoubleCoords[],\n): void {\n\t// iterate through all candidate squares in v1 direction\n\tcandidates_loop: for (let rc1 = c1_min; rc1 <= c1_max; rc1++) {\n\t\tconst target_square = add_move(piece_square, rescaleVector(rc1, v1));\n\t\t// do not add square already in candidates list\n\t\tif (tuplelist_contains_tuple(candidate_squares, target_square)) continue candidates_loop;\n\n\t\t// if piece is huygens, discard all nonprime candidate squares or squares already covered by jump moves\n\t\tconst is_huygen = pieceTypeDictionary[piecelist[piece_index]!]!.is_huygen ? true : false;\n\t\tif (is_huygen) {\n\t\t\tconst distance = manhattanDistance(piece_square, target_square);\n\t\t\tif (!primalityTest(distance)) continue candidates_loop;\n\t\t}\n\n\t\tconst square_near_king_1 = add_move(target_square, rescaleVector(c2_min, v2));\n\t\tconst square_near_king_2 = add_move(target_square, rescaleVector(c2_max, v2));\n\n\t\t// ensure that piece threatens target square\n\t\tif (\n\t\t\t!rider_threatens(v1, piece_square, target_square, is_huygen, piecelist, coordlist, {\n\t\t\t\texclude_white_piece_squares: true,\n\t\t\t})\n\t\t)\n\t\t\tcontinue;\n\n\t\t// ensure that target square threatens square near black king\n\t\tif (\n\t\t\t!rider_threatens(v2, target_square, square_near_king_1, false, piecelist, coordlist, {\n\t\t\t\tthreatening_own_square: true,\n\t\t\t}) &&\n\t\t\t!rider_threatens(v2, target_square, square_near_king_2, false, piecelist, coordlist, {\n\t\t\t\tthreatening_own_square: true,\n\t\t\t})\n\t\t)\n\t\t\tcontinue;\n\n\t\t// check if target_square is a royal move\n\t\tif (tuplelist_contains_tuple(royal_moves, target_square)) {\n\t\t\t// create copy of piece list without piece at piece_index\n\t\t\tconst temp_piecelist = [...piecelist];\n\t\t\ttemp_piecelist[piece_index] = 0;\n\t\t\t// only add target square if another piece defends it as well, else it will be captured\n\t\t\tfor (let index = 0; index < coordlist.length; index++) {\n\t\t\t\tif (\n\t\t\t\t\tindex !== piece_index &&\n\t\t\t\t\tpiece_threatens_square(index, target_square, temp_piecelist, coordlist)\n\t\t\t\t) {\n\t\t\t\t\tcandidate_squares.push(target_square);\n\t\t\t\t\tcontinue candidates_loop;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// target square is not a royal move\n\t\telse {\n\t\t\t// loop over all accepted candidate squares to eliminate reduncancies with new square\n\t\t\tredundancy_loop: for (let i = 0; i < candidate_squares.length; i++) {\n\t\t\t\t// skip over accepted candidate square if it is a royal move\n\t\t\t\tif (tuplelist_contains_tuple(royal_moves, candidate_squares[i]!))\n\t\t\t\t\tcontinue redundancy_loop;\n\t\t\t\t// skip over accepted candidate square if its coords have a different sign from the current candidate square\n\t\t\t\telse if (Math.sign(target_square[0]!) !== Math.sign(candidate_squares[i]![0]!))\n\t\t\t\t\tcontinue redundancy_loop;\n\t\t\t\telse if (Math.sign(target_square[1]!) !== Math.sign(candidate_squares[i]![1]!))\n\t\t\t\t\tcontinue redundancy_loop;\n\t\t\t\t// eliminate current candidate square if it lies on the same line as accepted candidate square, but further away\n\t\t\t\telse if (\n\t\t\t\t\trider_threatens(\n\t\t\t\t\t\tv2,\n\t\t\t\t\t\ttarget_square,\n\t\t\t\t\t\tcandidate_squares[i]!,\n\t\t\t\t\t\tis_huygen,\n\t\t\t\t\t\tpiecelist,\n\t\t\t\t\t\tcoordlist,\n\t\t\t\t\t\t{ ignore_blockers: true },\n\t\t\t\t\t)\n\t\t\t\t)\n\t\t\t\t\tcontinue candidates_loop;\n\t\t\t\t// replace accepted candidate square with current candidate square if they lie on the same line as, but new square is nearer\n\t\t\t\telse if (\n\t\t\t\t\trider_threatens(\n\t\t\t\t\t\tv2,\n\t\t\t\t\t\tcandidate_squares[i]!,\n\t\t\t\t\t\ttarget_square,\n\t\t\t\t\t\tis_huygen,\n\t\t\t\t\t\tpiecelist,\n\t\t\t\t\t\tcoordlist,\n\t\t\t\t\t\t{ ignore_blockers: true },\n\t\t\t\t\t)\n\t\t\t\t) {\n\t\t\t\t\tcandidate_squares[i] = target_square;\n\t\t\t\t\tcontinue candidates_loop;\n\t\t\t\t}\n\t\t\t}\n\t\t\tcandidate_squares.push(target_square);\n\t\t}\n\t}\n}\n\n// calculate a list of interesting moves for the white pieces in the position given by piecelist&coordlist\n// if inProtectedRiderFleeMode, then moves by pieces with type riderTypeToFleeFrom are not considered\nfunction get_white_candidate_moves(\n\tinProtectedRiderFleeMode: boolean,\n\tpiecelist: number[],\n\tcoordlist: DoubleCoords[],\n): DoubleCoords[][] {\n\tconst candidate_moves: DoubleCoords[][] = [];\n\tfor (let piece_index = 0; piece_index < piecelist.length; piece_index++) {\n\t\tif (inProtectedRiderFleeMode && riderTypeToFleeFrom === piecelist[piece_index])\n\t\t\tcandidate_moves.push([]);\n\t\telse\n\t\t\tcandidate_moves.push(\n\t\t\t\tget_white_piece_candidate_squares(piece_index, piecelist, coordlist),\n\t\t\t);\n\t}\n\treturn candidate_moves;\n}\n\n/**\n * Updates the position by moving the piece given by piece_index to target_square\n */\nfunction make_white_move(\n\tpiece_index: number,\n\ttarget_square: DoubleCoords,\n\tpiecelist: number[],\n\tcoordlist: DoubleCoords[],\n): [number[], DoubleCoords[]] {\n\tconst new_piecelist = piecelist.map((a) => {\n\t\treturn a;\n\t});\n\tconst new_coordlist = coordlist.map((a) => {\n\t\treturn [...a];\n\t}) as DoubleCoords[];\n\tnew_coordlist[piece_index] = target_square;\n\n\treturn [new_piecelist, new_coordlist];\n}\n\n/**\n * Given a direction that the black royal moves to, this shifts all white pieces relative to [0,0] and returns an updated piecelist&coordlist\n */\nfunction make_black_move(\n\tmove: DoubleCoords,\n\tpiecelist: number[],\n\tcoordlist: DoubleCoords[],\n): [number[], DoubleCoords[]] {\n\tconst new_piecelist: number[] = [];\n\tconst new_coordlist: DoubleCoords[] = [];\n\tfor (let i = 0; i < piecelist.length; i++) {\n\t\tif (move[0]! === coordlist[i]![0]! && move[1]! === coordlist[i]![1]!) {\n\t\t\t// white piece is captured\n\t\t\tnew_piecelist.push(0);\n\t\t} else {\n\t\t\t// white piece is not captured\n\t\t\tnew_piecelist.push(piecelist[i]!);\n\t\t}\n\t\t// shift coordinates\n\t\tnew_coordlist.push(add_move(coordlist[i]!, [-move[0]!, -move[1]!]));\n\t}\n\n\treturn [new_piecelist, new_coordlist];\n}\n\n/**\n * Returns an evaluation score for a given position according to the evaluation dictionaries\n * TODO: cap distance function when white to move\n * @param {Array} piecelist\n * @param {Array} coordlist\n * @param {Boolean} black_to_move - false on white's turns, true on black's turns\n * @param {Boolean} inTrapFleeMode - whether black is in trap flee mode -> leads to lower scores, if true\n * @param {Boolean} inProtectedRiderFleeMode - whether black is in protected rider flee mode -> leads to higher scores, if true\n * @returns {Number}\n */\nfunction get_position_evaluation(\n\tpiecelist: number[],\n\tcoordlist: DoubleCoords[],\n\tblack_to_move: boolean,\n\tinTrapFleeMode: boolean,\n\tinProtectedRiderFleeMode: boolean,\n): number {\n\tlet score = 0;\n\n\t// add penalty based on number of legal moves of black royal\n\tconst incheck = is_check(piecelist, coordlist);\n\tscore +=\n\t\tlegalMoveEvalDictionary[incheck ? 0 : 1]![\n\t\t\tget_black_legal_move_amount(false, piecelist, coordlist)\n\t\t]!;\n\n\t// do not give stalemate Infinity reward if white to move or black in trap flee mode\n\tif (score === Infinity && (!black_to_move || inTrapFleeMode))\n\t\tscore = 1.5 * legalMoveEvalDictionary[0]![1]!;\n\n\tconst black_to_move_num = black_to_move ? 0 : 1;\n\tfor (let i = 0; i < piecelist.length; i++) {\n\t\t// add penalty based on existence of white pieces\n\t\tscore += pieceExistenceEvalDictionary[piecelist[i]!]!;\n\n\t\t// add score based on distance of black royal to white shortrange pieces\n\t\tif (piecelist[i]! in distancesEvalDictionary) {\n\t\t\tconst [weight, distancefunction] =\n\t\t\t\tdistancesEvalDictionary[piecelist[i]!]![black_to_move_num]!;\n\t\t\tif (inProtectedRiderFleeMode && riderTypeToFleeFrom === piecelist[i])\n\t\t\t\tscore += 50 * weight * distancefunction(coordlist[i]!);\n\t\t\telse score += weight * distancefunction(coordlist[i]!);\n\t\t}\n\t}\n\n\t// add score based on distance of black royal to center of mass of white pieces near black king\n\tif (checkmateSelectedID in centerOfMassEvalDictionary) {\n\t\tconst [piecetype, cutoff, weight, distancefunction] =\n\t\t\tcenterOfMassEvalDictionary[checkmateSelectedID]![black_to_move_num]!;\n\t\tconst center_of_mass = get_center_of_mass(\n\t\t\tpiecetype,\n\t\t\tcutoff,\n\t\t\tstart_piecelist,\n\t\t\tstart_coordlist,\n\t\t);\n\t\tif (center_of_mass) score += weight * distancefunction(center_of_mass);\n\t}\n\n\treturn score;\n}\n\n/**\n * Performs a standard search with alpha-beta pruning through the game tree and updates globallyBestVariation and the like\n * @param {Array} piecelist\n * @param {Array} coordlist\n * @param {Number} depth\n * @param {Number} start_depth - does not get changed at all during recursion\n * @param {Boolean} black_to_move\n * @param {Boolean} followingPrincipal - whether the function is still following the (initial) principal variation\n * @param {Boolean} inTrapFleeMode - whether one should neglect all white candidate moves in deeper search beyond the first white node\n * @param {Boolean} inProtectedRiderFleeMode - whether one should neglect all white candidate moves by rider in deeper search and reward distance from him\n * @param {DoubleCoords[]} black_killer_list - list of black killer moves that is being maintained when white to move\n * @param {Number[]} white_killer_list - list white killer pieces that is being maintained when black to move\n * @param {Number} alpha\n * @param {Number} beta\n * @param {Number} alphaPlies - alpha beta for remaining plies in the game: tiebreak in case of early game over: the more plies the game lasts the better for black\n * @param {Number} betaPlies\n * @returns {Object} with properties \"score\", \"move\" and \"termination_depth\"\n */\nfunction alphabeta(\n\tpiecelist: number[],\n\tcoordlist: DoubleCoords[],\n\tdepth: number,\n\tstart_depth: number,\n\tblack_to_move: boolean,\n\tfollowingPrincipal: boolean,\n\tinTrapFleeMode: boolean,\n\tinProtectedRiderFleeMode: boolean,\n\tblack_killer_list: DoubleCoords[],\n\twhite_killer_list: Number[],\n\talpha: number,\n\tbeta: number,\n\talphaPlies: number,\n\tbetaPlies: number,\n): {\n\tscore: number;\n\tbestVariation: { [key: number]: [number, DoubleCoords] };\n\tsurvivalPlies: number;\n\tblack_killer_move?: DoubleCoords;\n\twhite_killer_piece_index?: Number;\n\tterminate_now: boolean;\n} {\n\tenginePositionCounter++;\n\t// Empirically: The bot needs roughly 40ms to check 3000 positions, so check every 40ms if enough time has passed to terminate computation\n\tif (\n\t\tenginePositionCounter % 3000 === 0 &&\n\t\tDate.now() - engineStartTime >= engineTimeLimitPerMoveMillis\n\t) {\n\t\treturn { score: NaN, bestVariation: {}, survivalPlies: NaN, terminate_now: true };\n\t\t// If game over, return position evaluation\n\t} else if (black_to_move && get_black_legal_move_amount(false, piecelist, coordlist) === 0) {\n\t\treturn {\n\t\t\tscore: get_position_evaluation(\n\t\t\t\tpiecelist,\n\t\t\t\tcoordlist,\n\t\t\t\tblack_to_move,\n\t\t\t\tinTrapFleeMode && start_depth - depth > 1,\n\t\t\t\tinProtectedRiderFleeMode,\n\t\t\t),\n\t\t\tbestVariation: {},\n\t\t\tsurvivalPlies: start_depth - depth,\n\t\t\tterminate_now: false,\n\t\t};\n\t\t// At max depth, return position evaluation\n\t} else if (depth === 0) {\n\t\treturn {\n\t\t\tscore: get_position_evaluation(\n\t\t\t\tpiecelist,\n\t\t\t\tcoordlist,\n\t\t\t\tblack_to_move,\n\t\t\t\tinTrapFleeMode && start_depth - depth > 1,\n\t\t\t\tinProtectedRiderFleeMode,\n\t\t\t),\n\t\t\tbestVariation: {},\n\t\t\tsurvivalPlies: start_depth + 1,\n\t\t\tterminate_now: false,\n\t\t};\n\t}\n\n\tlet bestVariation: { [key: number]: [number, DoubleCoords] } = {};\n\n\t// Black to move\n\tif (black_to_move) {\n\t\tlet maxScore = -Infinity;\n\t\tlet maxPlies = -Infinity;\n\t\tlet black_killer_move: DoubleCoords | undefined = undefined;\n\t\tlet black_moves = get_black_legal_moves(\n\t\t\tinTrapFleeMode && start_depth - depth > 1,\n\t\t\tpiecelist,\n\t\t\tcoordlist,\n\t\t);\n\n\t\t// Black is in trap flee mode and considers no white candidate moves no piece captures from here on out:\n\t\tif (mayEnterTrapFleeMode && depth === start_depth && isBlackInTrap(piecelist, coordlist))\n\t\t\tinTrapFleeMode = true;\n\n\t\t// Black is in protected rider flee mode and considers no white rider candidate moves no piece captures from here on out:\n\t\tif (\n\t\t\tmayEnterProtectedRiderFleeMode &&\n\t\t\tdepth === start_depth &&\n\t\t\tisBlackNearProtectedRider(piecelist, coordlist)\n\t\t)\n\t\t\tinProtectedRiderFleeMode = true;\n\n\t\t// Order black moves by immediate evaluation function\n\t\tif (depth > 1 && black_moves.length > 1) {\n\t\t\tconst black_move_evals: number[] = [];\n\t\t\tfor (const move of black_moves) {\n\t\t\t\tconst [order_piecelist, order_coordlist] = make_black_move(\n\t\t\t\t\tmove,\n\t\t\t\t\tpiecelist,\n\t\t\t\t\tcoordlist,\n\t\t\t\t);\n\t\t\t\tconst order_score = get_position_evaluation(\n\t\t\t\t\torder_piecelist,\n\t\t\t\t\torder_coordlist,\n\t\t\t\t\tfalse,\n\t\t\t\t\tinTrapFleeMode && start_depth - depth > 1,\n\t\t\t\t\tinProtectedRiderFleeMode,\n\t\t\t\t);\n\t\t\t\tblack_move_evals.push(order_score);\n\t\t\t}\n\n\t\t\t// Get sorted indices\n\t\t\tconst order_indices = black_move_evals\n\t\t\t\t.map((_, i) => i)\n\t\t\t\t.sort((a, b) => black_move_evals[b]! - black_move_evals[a]!);\n\n\t\t\t// Reorder black_moves arrays based on sorted indices\n\t\t\tblack_moves = order_indices.map((i) => black_moves[i]!);\n\t\t}\n\n\t\t// Use killer move heuristic, i.e. put moves in black_killer_list in front\n\t\tif (black_killer_list.length > 0) {\n\t\t\tconst reordered_moves_killers: DoubleCoords[] = [];\n\t\t\tconst reordered_moves_nonkillers: DoubleCoords[] = [];\n\t\t\tfor (const move of black_moves) {\n\t\t\t\tif (tuplelist_contains_tuple(black_killer_list, move))\n\t\t\t\t\treordered_moves_killers.push(move); // Add killer moves to the first list\n\t\t\t\telse reordered_moves_nonkillers.push(move); // Add non-killer moves to second list\n\t\t\t}\n\t\t\tblack_moves.length = 0;\n\t\t\tblack_moves.push(...reordered_moves_killers, ...reordered_moves_nonkillers);\n\t\t}\n\n\t\t// If we are still in followingPrincipal mode, do principal variation ordering\n\t\tif (followingPrincipal && globallyBestVariation[start_depth - depth]) {\n\t\t\tfor (let index = 0; index < black_moves.length; index++) {\n\t\t\t\tif (\n\t\t\t\t\tsquares_are_equal(\n\t\t\t\t\t\tblack_moves[index]!,\n\t\t\t\t\t\tgloballyBestVariation[start_depth - depth]![1]!,\n\t\t\t\t\t)\n\t\t\t\t) {\n\t\t\t\t\t// Shuffe principal move to the front of black_moves\n\t\t\t\t\tconst optimal_move = black_moves.splice(index, 1)[0]!;\n\t\t\t\t\tblack_moves.unshift(optimal_move);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// We are too deep now, principal variation no longer applies\n\t\t\tfollowingPrincipal = false;\n\t\t}\n\n\t\t// loop over all possible black moves, do alpha beta pruning with (alpha, beta) (and (alphaPlies, betaPlies) as the tiebreaker)\n\t\tblackMoveLoop: for (const move of black_moves) {\n\t\t\tconst [new_piecelist, new_coordlist] = make_black_move(move, piecelist, coordlist);\n\t\t\tconst evaluation = alphabeta(\n\t\t\t\tnew_piecelist,\n\t\t\t\tnew_coordlist,\n\t\t\t\tdepth - 1,\n\t\t\t\tstart_depth,\n\t\t\t\tfalse,\n\t\t\t\tfollowingPrincipal,\n\t\t\t\tinTrapFleeMode,\n\t\t\t\tinProtectedRiderFleeMode,\n\t\t\t\t[],\n\t\t\t\twhite_killer_list,\n\t\t\t\talpha,\n\t\t\t\tbeta,\n\t\t\t\talphaPlies,\n\t\t\t\tbetaPlies,\n\t\t\t);\n\t\t\tif (evaluation.terminate_now)\n\t\t\t\treturn { score: NaN, bestVariation: {}, survivalPlies: NaN, terminate_now: true };\n\t\t\tfollowingPrincipal = false;\n\n\t\t\t// append white killer piece to running white_killer_list, if it caused a beta cutoff\n\t\t\tif (evaluation.white_killer_piece_index)\n\t\t\t\twhite_killer_list.push(evaluation.white_killer_piece_index);\n\n\t\t\tconst new_score = evaluation.score;\n\t\t\tconst survivalPlies = evaluation.survivalPlies;\n\t\t\tif (new_score >= maxScore) {\n\t\t\t\tif (\n\t\t\t\t\tnew_score > maxScore ||\n\t\t\t\t\tsurvivalPlies > maxPlies ||\n\t\t\t\t\t(survivalPlies === maxPlies && rand() < 0.5) ||\n\t\t\t\t\tObject.keys(bestVariation).length === 0\n\t\t\t\t) {\n\t\t\t\t\tbestVariation = evaluation.bestVariation;\n\t\t\t\t\tbestVariation[start_depth - depth] = [NaN, move];\n\t\t\t\t\tmaxScore = new_score;\n\t\t\t\t\tmaxPlies = survivalPlies;\n\t\t\t\t\talpha = Math.max(alpha, new_score);\n\t\t\t\t\talphaPlies = Math.max(alphaPlies, survivalPlies);\n\t\t\t\t\tif (\n\t\t\t\t\t\tdepth === start_depth &&\n\t\t\t\t\t\tnew_score >= globallyBestScore &&\n\t\t\t\t\t\tsurvivalPlies >= globalSurvivalPlies\n\t\t\t\t\t) {\n\t\t\t\t\t\tgloballyBestVariation = bestVariation;\n\t\t\t\t\t\tgloballyBestScore = new_score;\n\t\t\t\t\t\tglobalSurvivalPlies = survivalPlies;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (beta < alpha || (beta === alpha && betaPlies < alphaPlies)) {\n\t\t\t\tblack_killer_move = move;\n\t\t\t\tbreak blackMoveLoop;\n\t\t\t}\n\t\t}\n\t\treturn {\n\t\t\tscore: maxScore,\n\t\t\tbestVariation: bestVariation,\n\t\t\tsurvivalPlies: maxPlies,\n\t\t\tblack_killer_move: black_killer_move,\n\t\t\tterminate_now: false,\n\t\t};\n\n\t\t// White to move\n\t} else {\n\t\tlet minScore = Infinity;\n\t\tlet minPlies = Infinity;\n\t\tlet white_killer_piece_index: Number | undefined = undefined;\n\t\tlet candidate_moves: DoubleCoords[][];\n\n\t\tif (inTrapFleeMode && start_depth - depth > 1)\n\t\t\tcandidate_moves = [[coordlist[0]], ...Array(piecelist.length - 1).fill([])];\n\t\telse\n\t\t\tcandidate_moves = get_white_candidate_moves(\n\t\t\t\tinProtectedRiderFleeMode,\n\t\t\t\tpiecelist,\n\t\t\t\tcoordlist,\n\t\t\t);\n\n\t\t// go through pieces for in increasing order of what piece has how many candidate moves\n\t\tconst indices = [...Array(piecelist.length).keys()];\n\t\tindices.sort((a, b) => {\n\t\t\treturn candidate_moves[a]!.length - candidate_moves[b]!.length;\n\t\t});\n\n\t\t// Use killer move heuristic, i.e. put pieces in white_killer_list in front\n\t\tif (white_killer_list.length > 0) {\n\t\t\tconst reordered_indices_killers: number[] = [];\n\t\t\tconst reordered_indices_nonkillers: number[] = [];\n\t\t\tfor (const piece_index of indices) {\n\t\t\t\tif (piece_index in white_killer_list)\n\t\t\t\t\treordered_indices_killers.push(piece_index); // Add killer moves to the first list\n\t\t\t\telse reordered_indices_nonkillers.push(piece_index); // Add non-killer moves to second list\n\t\t\t}\n\t\t\tindices.length = 0;\n\t\t\tindices.push(...reordered_indices_killers, ...reordered_indices_nonkillers);\n\t\t}\n\n\t\t// If we are still in followingPrincipal mode, do principal variation ordering\n\t\tif (followingPrincipal && globallyBestVariation[start_depth - depth]) {\n\t\t\tfor (let p_index = 0; p_index < indices.length; p_index++) {\n\t\t\t\tif (indices[p_index] === globallyBestVariation[start_depth - depth]![0]!) {\n\t\t\t\t\t// Shuffe principal piece index to the front of indices\n\t\t\t\t\tconst optimal_index = indices.splice(p_index, 1)[0]!;\n\t\t\t\t\tindices.unshift(optimal_index);\n\t\t\t\t\t// Loop over candidate moves for principal piece\n\t\t\t\t\tfor (\n\t\t\t\t\t\tlet m_index = 0;\n\t\t\t\t\t\tm_index < candidate_moves[optimal_index]!.length;\n\t\t\t\t\t\tm_index++\n\t\t\t\t\t) {\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\tsquares_are_equal(\n\t\t\t\t\t\t\t\tcandidate_moves[optimal_index]![m_index]!,\n\t\t\t\t\t\t\t\tgloballyBestVariation[start_depth - depth]![1]!,\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\t// Shuffe principal move to the front of candidate_moves for that piece\n\t\t\t\t\t\t\tconst optimal_move = candidate_moves[optimal_index]!.splice(\n\t\t\t\t\t\t\t\tm_index,\n\t\t\t\t\t\t\t\t1,\n\t\t\t\t\t\t\t)[0]!;\n\t\t\t\t\t\t\tcandidate_moves[optimal_index]!.unshift(optimal_move);\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// We are too deep now, principal variation no longer applies\n\t\t\tfollowingPrincipal = false;\n\t\t}\n\n\t\t// loop over all possible white moves, do alpha beta pruning with (alpha, beta) (and (alphaPlies, betaPlies) as the tiebreaker)\n\t\twhiteMoveLoop: for (const piece_index of indices) {\n\t\t\tfor (const target_square of candidate_moves[piece_index]!) {\n\t\t\t\tconst [new_piecelist, new_coordlist] = make_white_move(\n\t\t\t\t\tpiece_index,\n\t\t\t\t\ttarget_square,\n\t\t\t\t\tpiecelist,\n\t\t\t\t\tcoordlist,\n\t\t\t\t);\n\t\t\t\tconst evaluation = alphabeta(\n\t\t\t\t\tnew_piecelist,\n\t\t\t\t\tnew_coordlist,\n\t\t\t\t\tdepth - 1,\n\t\t\t\t\tstart_depth,\n\t\t\t\t\ttrue,\n\t\t\t\t\tfollowingPrincipal,\n\t\t\t\t\tinTrapFleeMode,\n\t\t\t\t\tinProtectedRiderFleeMode,\n\t\t\t\t\tblack_killer_list,\n\t\t\t\t\t[],\n\t\t\t\t\talpha,\n\t\t\t\t\tbeta,\n\t\t\t\t\talphaPlies,\n\t\t\t\t\tbetaPlies,\n\t\t\t\t);\n\t\t\t\tif (evaluation.terminate_now)\n\t\t\t\t\treturn {\n\t\t\t\t\t\tscore: NaN,\n\t\t\t\t\t\tbestVariation: {},\n\t\t\t\t\t\tsurvivalPlies: NaN,\n\t\t\t\t\t\tterminate_now: true,\n\t\t\t\t\t};\n\t\t\t\tfollowingPrincipal = false;\n\n\t\t\t\t// append black killer move to running black_killer_list, if it caused a beta cutoff\n\t\t\t\tif (evaluation.black_killer_move)\n\t\t\t\t\tblack_killer_list.push(evaluation.black_killer_move);\n\n\t\t\t\tconst new_score = evaluation.score;\n\t\t\t\tconst survivalPlies = evaluation.survivalPlies;\n\t\t\t\tif (new_score <= minScore) {\n\t\t\t\t\tif (\n\t\t\t\t\t\tnew_score < minScore ||\n\t\t\t\t\t\tsurvivalPlies < minPlies ||\n\t\t\t\t\t\t(survivalPlies === minPlies && rand() < 0.5) ||\n\t\t\t\t\t\tObject.keys(bestVariation).length === 0\n\t\t\t\t\t) {\n\t\t\t\t\t\tbestVariation = evaluation.bestVariation;\n\t\t\t\t\t\tbestVariation[start_depth - depth] = [piece_index, target_square];\n\t\t\t\t\t\tminScore = new_score;\n\t\t\t\t\t\tminPlies = survivalPlies;\n\t\t\t\t\t\tbeta = Math.min(beta, new_score);\n\t\t\t\t\t\tbetaPlies = Math.min(betaPlies, survivalPlies);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (beta < alpha || (beta === alpha && betaPlies < alphaPlies)) {\n\t\t\t\t\twhite_killer_piece_index = piece_index;\n\t\t\t\t\tbreak whiteMoveLoop;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn {\n\t\t\tscore: minScore,\n\t\t\tbestVariation: bestVariation,\n\t\t\tsurvivalPlies: minPlies,\n\t\t\twhite_killer_piece_index: white_killer_piece_index,\n\t\t\tterminate_now: false,\n\t\t};\n\t}\n}\n\n/**\n * Performs a search with alpha-beta pruning through the game tree with iteratively greater depths\n */\nfunction runIterativeDeepening(\n\tpiecelist: number[],\n\tcoordlist: DoubleCoords[],\n\tmaxdepth: number,\n): void {\n\t// immediately initialize and set globallyBestVariation randomly, in case nothing better ever gets found\n\tconst black_moves = get_black_legal_moves(false, piecelist, coordlist);\n\tgloballyBestVariation[0] = [NaN, black_moves[Math.floor(rand() * black_moves.length)]!];\n\tconst [dummy_piecelist, dummy_coordlist] = make_black_move(\n\t\tgloballyBestVariation[0]![1]!,\n\t\tpiecelist,\n\t\tcoordlist,\n\t);\n\tgloballyBestScore = get_position_evaluation(\n\t\tdummy_piecelist,\n\t\tdummy_coordlist,\n\t\tfalse,\n\t\tfalse,\n\t\tfalse,\n\t);\n\tglobalSurvivalPlies = 1;\n\n\ttry {\n\t\t// iteratively deeper and deeper search\n\t\tfor (let depth = 1; depth <= maxdepth; depth = depth + 2) {\n\t\t\tconst evaluation = alphabeta(\n\t\t\t\tpiecelist,\n\t\t\t\tcoordlist,\n\t\t\t\tdepth,\n\t\t\t\tdepth,\n\t\t\t\ttrue,\n\t\t\t\ttrue,\n\t\t\t\tfalse,\n\t\t\t\tfalse,\n\t\t\t\t[],\n\t\t\t\t[],\n\t\t\t\t-Infinity,\n\t\t\t\tInfinity,\n\t\t\t\t0,\n\t\t\t\tInfinity,\n\t\t\t);\n\t\t\tif (evaluation.terminate_now) {\n\t\t\t\t// console.log(\"Search interrupted at depth \" + depth);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tgloballyBestVariation = evaluation.bestVariation;\n\t\t\tgloballyBestScore = evaluation.score;\n\t\t\tglobalSurvivalPlies = evaluation.survivalPlies;\n\t\t\t// console.log(`Depth ${depth}, Plies To Mate: ${globalSurvivalPlies}, Best score: ${globallyBestScore}, Best move by Black: ${globallyBestVariation[0]![1]!}.`);\n\n\t\t\t// early exit conditions\n\t\t\tif (depth === 1) {\n\t\t\t\tconst black_move = globallyBestVariation[0]![1]!;\n\t\t\t\tconst [new_piecelist, new_coordlist] = make_black_move(\n\t\t\t\t\tblack_move,\n\t\t\t\t\tpiecelist,\n\t\t\t\t\tcoordlist,\n\t\t\t\t);\n\n\t\t\t\t// If a piece is captured, immediately check for insuffmat\n\t\t\t\t// We do this by constructing the piecesOrganizedByKey property of a dummy gamefile\n\t\t\t\t// This works as long insufficientmaterial.js only cares about piecesOrganizedByKey\n\t\t\t\tif (\n\t\t\t\t\tnew_piecelist.filter((x) => x === 0).length >\n\t\t\t\t\tpiecelist.filter((x) => x === 0).length\n\t\t\t\t) {\n\t\t\t\t\tconst piecesOrganizedByKey = new Map<CoordsKey, number>();\n\t\t\t\t\tpiecesOrganizedByKey.set(\n\t\t\t\t\t\t'0,0',\n\t\t\t\t\t\troyal_type === 'k' ? r.KING + e.B : r.ROYALCENTAUR + e.B,\n\t\t\t\t\t);\n\t\t\t\t\tfor (let i = 0; i < piecelist.length; i++) {\n\t\t\t\t\t\tif (new_piecelist[i] !== 0) {\n\t\t\t\t\t\t\tpiecesOrganizedByKey.set(\n\t\t\t\t\t\t\t\tnew_coordlist[i]!.toString() as CoordsKey,\n\t\t\t\t\t\t\t\tinvertedPieceNameDictionaty[new_piecelist[i]!]!,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tconst emptyPieceMovesets = {}; // <--- Is this gonna be an issue?\n\t\t\t\t\tconst basegame = input_gamefile.basegame;\n\t\t\t\t\tconst dummy_board = {\n\t\t\t\t\t\tmoves: [],\n\t\t\t\t\t\t// pieceMovesets is the only required gamefile property that is lost when sending the gamefile to the engine.\n\t\t\t\t\t\t// This will cause the possible slides to be calculated incorrectly, and thus the `lines` property not entirely filled out.\n\t\t\t\t\t\t// I THINK we are safe though, because I saw nowhere in detectInsufficientMaterial() where it reads the lines.\n\t\t\t\t\t\tpieces: organizedpieces.processInitialPosition(\n\t\t\t\t\t\t\tpiecesOrganizedByKey,\n\t\t\t\t\t\t\temptyPieceMovesets,\n\t\t\t\t\t\t\tbasegame.gameRules.turnOrder,\n\t\t\t\t\t\t\tinput_gamefile.boardsim.editor,\n\t\t\t\t\t\t\tbasegame.gameRules.promotionsAllowed,\n\t\t\t\t\t\t).pieces,\n\t\t\t\t\t} as unknown as Board;\n\n\t\t\t\t\tif (detectInsufficientMaterial(basegame.gameRules, dummy_board)) break;\n\t\t\t\t}\n\n\t\t\t\t// special case for 3B3B-1k variant after piece capture\n\t\t\t\t// enforce parity constraint to never get checkmated: the king will always move to the square color with fewer bishops unless making a capture\n\t\t\t\tif (checkmateSelectedID === '3B3B-1k' && piecelist.length < 6) {\n\t\t\t\t\tconst parity =\n\t\t\t\t\t\tcoordlist.filter(([a, b]) => (a + b) % 2 === 0).length < 3 ? 0 : 1;\n\t\t\t\t\tconst optimal_move = black_moves.find(\n\t\t\t\t\t\t([a, b]) => Math.abs((a + b) % 2) === parity,\n\t\t\t\t\t);\n\t\t\t\t\tif (optimal_move !== undefined) {\n\t\t\t\t\t\tgloballyBestVariation[0] = [NaN, optimal_move];\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} catch (error) {\n\t\t// If engine suggests illegal move for black, choose it randomly, else abort with currently best move\n\t\tif (!tuplelist_contains_tuple(black_moves, globallyBestVariation[0]![1]!))\n\t\t\tgloballyBestVariation[0] = [NaN, black_moves[Math.floor(rand() * black_moves.length)]!];\n\t\tconsole.error(\n\t\t\t'Something went wrong with the iterative deepening calculation, aborting early...',\n\t\t);\n\t\tconsole.error(error);\n\t}\n}\n\n/**\n * Given some string, returns an array of four random seeds\n * Source: https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript\n */\nfunction cyrb128(str: string): number[] {\n\tlet h1 = 1779033703,\n\t\th2 = 3144134277,\n\t\th3 = 1013904242,\n\t\th4 = 2773480762;\n\tfor (let i = 0, k; i < str.length; i++) {\n\t\tk = str.charCodeAt(i);\n\t\th1 = h2 ^ Math.imul(h1 ^ k, 597399067);\n\t\th2 = h3 ^ Math.imul(h2 ^ k, 2869860233);\n\t\th3 = h4 ^ Math.imul(h3 ^ k, 951274213);\n\t\th4 = h1 ^ Math.imul(h4 ^ k, 2716044179);\n\t}\n\th1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);\n\th2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);\n\th3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);\n\th4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);\n\t((h1 ^= h2 ^ h3 ^ h4), (h2 ^= h1), (h3 ^= h1), (h4 ^= h1));\n\treturn [h1 >>> 0, h2 >>> 0, h3 >>> 0, h4 >>> 0];\n}\n\n/**\n * Given some number, returns a seeded function that draws uniformly random numbers between 0 and 1\n * Source: https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript\n */\nfunction mulberry32(a: number): () => number {\n\treturn function (): number {\n\t\tlet t = (a += 0x6d2b79f5);\n\t\tt = Math.imul(t ^ (t >>> 15), t | 1);\n\t\tt ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n\t\treturn ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n\t};\n}\n\n/**\n * Converts a target square for the black king to move to into a MoveCoords Object, taking into account gamefile_royal_coords\n */\nfunction move_to_gamefile_move(target_square: DoubleCoords): string {\n\tconst endCoords: DoubleCoords = [\n\t\tgamefile_royal_coords[0] + target_square[0],\n\t\tgamefile_royal_coords[1] + target_square[1],\n\t];\n\t// Convert the floating point numbers to BigInt coordinates before passing the move to the game\n\tconst moveCoords: MoveCoords = {\n\t\tstartCoords: [BigInt(gamefile_royal_coords[0]), BigInt(gamefile_royal_coords[1])],\n\t\tendCoords: [BigInt(endCoords[0]!), BigInt(endCoords[1]!)],\n\t};\n\t// Now convert to most compact string notation: \"x,y>x,y=Q\" that the engine API accepts.\n\treturn icnconverter.getCompactMoveFromDraft(moveCoords);\n}\n\nfunction doesTypeExist(boardsim: Board, type: number): boolean {\n\tconst range = boardsim.pieces.typeRanges.get(type);\n\n\tif (range === undefined) return false;\n\n\treturn range.end - range.start - range.undefineds.length > 0;\n}\n\nfunction getFirstOfType(boardsim: Board, type: number): DoubleCoords | undefined {\n\tconst range = boardsim.pieces.typeRanges.get(type);\n\n\tif (range === undefined) return;\n\tif (range.end - range.start - range.undefineds.length <= 0) return;\n\n\tlet undefinedidx = 0;\n\tfor (let idx = range.start; idx < range.end; idx++) {\n\t\tif (idx === range.undefineds[undefinedidx]) {\n\t\t\t// Is our next undefined piece entry, skip.\n\t\t\tundefinedidx++;\n\t\t\tcontinue;\n\t\t}\n\t\tconst bigintCoords: Coords = [\n\t\t\tboardsim.pieces.XPositions[idx]!,\n\t\t\tboardsim.pieces.YPositions[idx]!,\n\t\t];\n\t\t// Convert the bigint coordinates to floating point coordinates that the engine works with.\n\t\treturn convertBigIntCoordsToFloating(bigintCoords);\n\t}\n\treturn;\n}\n\n/**\n * Converts bigint coords to floating point coords that the engine works with.\n * We can do this since the game gaurantees all moves are within safe limits.\n */\nfunction convertBigIntCoordsToFloating(coords: Coords): DoubleCoords {\n\treturn [Number(coords[0]!), Number(coords[1]!)];\n}\n\n/**\n * This function is called from outside and initializes the engine calculation given the provided gamefile\n */\nasync function runEngine(): Promise<void> {\n\ttry {\n\t\tconst board = input_gamefile.boardsim;\n\t\t// get real coordinates and parse type of black royal piece\n\t\tif (doesTypeExist(board, r.KING + e.B)) {\n\t\t\tgamefile_royal_coords = getFirstOfType(board, r.KING + e.B)!;\n\t\t\troyal_moves = king_moves;\n\t\t\troyal_type = 'k';\n\t\t} else if (doesTypeExist(board, r.ROYALCENTAUR + e.B)) {\n\t\t\tgamefile_royal_coords = getFirstOfType(board, r.ROYALCENTAUR + e.B)!;\n\t\t\troyal_moves = centaur_moves;\n\t\t\troyal_type = 'rc';\n\t\t} else {\n\t\t\treturn console.error('No black king or royal centaur found in game');\n\t\t}\n\n\t\t// create list of types and coords of white pieces, in order to initialize start_piecelist and start_coordlist\n\t\tstart_piecelist = [];\n\t\tstart_coordlist = [];\n\t\tfor (const [type, range] of board.pieces.typeRanges) {\n\t\t\tlet undefinedidx = 0;\n\t\t\tfor (let idx = range.start; idx < range.end; idx++) {\n\t\t\t\tif (idx === range.undefineds[undefinedidx]) {\n\t\t\t\t\t// Is our next undefined piece entry, skip.\n\t\t\t\t\tundefinedidx++;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (Math.floor(type / numTypes) !== p.WHITE) continue;\n\t\t\t\tconst bigintCoords: Coords = [\n\t\t\t\t\tboard.pieces.XPositions[idx]!,\n\t\t\t\t\tboard.pieces.YPositions[idx]!,\n\t\t\t\t];\n\t\t\t\t// Convert the bigint coordinates to floating point coordinates that the engine works with.\n\t\t\t\tconst coords = convertBigIntCoordsToFloating(bigintCoords);\n\t\t\t\tstart_piecelist.push(pieceNameDictionary[type]!);\n\t\t\t\t// shift all white pieces, so that the black royal is at [0,0]\n\t\t\t\tstart_coordlist.push([\n\t\t\t\t\tcoords[0] - gamefile_royal_coords[0],\n\t\t\t\t\tcoords[1] - gamefile_royal_coords[1],\n\t\t\t\t]);\n\t\t\t}\n\t\t}\n\n\t\t// reorder white piecelist and coordlist so that RNG is always initialized in the same way\n\t\tconst sort_indices = start_coordlist\n\t\t\t.map((coord, index) => ({ coord: coord, index: index })) // Store index and coord in an object\n\t\t\t.sort((a, b) => {\n\t\t\t\t// Sort chosen objects by the stored coords\n\t\t\t\tconst normA = manhattanNorm(a.coord);\n\t\t\t\tconst normB = manhattanNorm(b.coord);\n\t\t\t\tif (normA !== normB) return normA - normB;\n\t\t\t\telse if (a.coord[1] !== b.coord[1]) return a.coord[1] - b.coord[1];\n\t\t\t\telse return a.coord[0] - b.coord[0];\n\t\t\t})\n\t\t\t.map((object) => object.index); // Extract the new order of indices\n\t\tstart_piecelist = sort_indices.map((i) => start_piecelist[i]!); // Reorder start_piecelist based on sort_indices\n\t\tstart_coordlist = sort_indices.map((i) => start_coordlist[i]!); // Reorder start_coordlist based on sort_indices\n\n\t\t// Initialize seeded RNG function based on starting position\n\t\tconst seedString = `${start_piecelist.toString()}|${start_coordlist.toString()}`;\n\t\tconst seedArray = cyrb128(seedString);\n\t\trand = mulberry32(seedArray[0]!);\n\n\t\t// If current position is recorded in bestMoveList, then don't do search but just do bestMove\n\t\tlet positionInBestMoveList: boolean = false;\n\t\tfor (const entry of bestMoveList) {\n\t\t\tif (JSON.stringify(start_piecelist) === JSON.stringify(entry.piecelist)) {\n\t\t\t\tif (JSON.stringify(start_coordlist) === JSON.stringify(entry.coordlist)) {\n\t\t\t\t\tgloballyBestVariation[0] = [NaN, entry.bestMove];\n\t\t\t\t\tpositionInBestMoveList = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// run iteratively deepened move search\n\t\tif (!positionInBestMoveList)\n\t\t\trunIterativeDeepening(start_piecelist, start_coordlist, Infinity);\n\n\t\t// console.log(isBlackInTrap(start_piecelist, start_coordlist));\n\t\t// console.log(get_white_candidate_moves(false, start_piecelist, start_coordlist));\n\t\t// console.log(globalSurvivalPlies);\n\t\t// console.log(globallyBestVariation);\n\t\t// console.log(enginePositionCounter);\n\n\t\t// submit engine move after enough time has passed\n\t\tconst time_now = Date.now();\n\t\tif (time_now - engineStartTime < engineTimeLimitPerMoveMillis) {\n\t\t\tawait new Promise((r) =>\n\t\t\t\tsetTimeout(r, engineTimeLimitPerMoveMillis - (time_now - engineStartTime)),\n\t\t\t);\n\t\t}\n\t\tpostMessage({ type: 'move', data: move_to_gamefile_move(globallyBestVariation[0]![1]!) });\n\t} catch (e) {\n\t\tconsole.error('An error occured in the engine computation of the checkmate practice');\n\t\tconsole.error(e);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/engines/enginecards/hydrochess_card.ts",
    "content": "// src/client/scripts/esm/game/chess/engines/enginecards/hydrochess_card.ts\n\nimport type { VariantOptions } from '../../../../../../../shared/chess/logic/initvariant';\n\nimport bimath from '../../../../../../../shared/util/math/bimath';\nimport bounds from '../../../../../../../shared/util/math/bounds';\nimport coordutil from '../../../../../../../shared/chess/util/coordutil';\nimport typeutil, {\n\tRawType,\n\trawTypes as r,\n\tplayers as p,\n} from '../../../../../../../shared/chess/util/typeutil';\n\ntype SupportedResult = { supported: true } | { supported: false; reason: string };\n\n// Constants -------------------------------------------------------------\n\n/** Maximum signed 64-bit integer value (2^63 - 1). Used in Rust. */\nconst I64_MAX = 2n ** 63n - 1n;\n\n/** The maximum world border distance the engine can handle. */\nconst BORDER_CAP = I64_MAX - 1000n; // Small cushion\n\nconst SUPPORTED_VARIANTS = new Set([\n\t'Classical',\n\t'Confined_Classical',\n\t'Classical_Plus',\n\t'Core',\n\t'CoaIP',\n\t'CoaIP_HO',\n\t'CoaIP_RO',\n\t'CoaIP_NO',\n\t'Palace',\n\t'Pawndard',\n\t'Standarch',\n\t'Space_Classic',\n\t'Space',\n\t'Abundance',\n\t'Pawn_Horde',\n\t'Knightline',\n\t'Obstocean',\n\t'Chess',\n\t'Omega',\n]);\n\n// Functions -------------------------------------------------------------\n\n/**\n * Determines whether the given position is supported by the engine.\n * If it's not, and we play a game with it anyway, the engine may crash.\n */\nfunction isPositionSupported(variantOptions: VariantOptions): SupportedResult {\n\t// 1. Any win condition that is not checkmate, royalcapture, allroyalscaptured, or allpiecescaptured is unsupported.\n\tconst supportedWinConditions = [\n\t\t'checkmate',\n\t\t'royalcapture',\n\t\t'allroyalscaptured',\n\t\t'allpiecescaptured',\n\t];\n\tconst usedWinConditions: string[] = Object.values(\n\t\tvariantOptions.gameRules.winConditions,\n\t).flat();\n\tfor (const winCondition of usedWinConditions) {\n\t\tif (!supportedWinConditions.includes(winCondition))\n\t\t\treturn { supported: false, reason: `Unsupported win condition: ${winCondition}.` };\n\t}\n\n\t// 2. World border larger than i64 is unsupported.\n\tif (\n\t\t!variantOptions.gameRules.worldBorder ||\n\t\tObject.values(variantOptions.gameRules.worldBorder).some(\n\t\t\t(dist) => dist === null || bimath.abs(dist) > BORDER_CAP,\n\t\t)\n\t) {\n\t\treturn {\n\t\t\tsupported: false,\n\t\t\treason: `World border exceeds limit.`,\n\t\t};\n\t}\n\n\t// 3. Boundary of all pieces is entirely contained within world border (no piece out of bounds)\n\tif (variantOptions.gameRules.worldBorder) {\n\t\tconst allCoords = [...variantOptions.position.keys()].map((coordsKey) =>\n\t\t\tcoordutil.getCoordsFromKey(coordsKey),\n\t\t);\n\t\tconst piecesBox = bounds.getBoxFromCoordsList(allCoords);\n\n\t\tif (!bounds.boxContainsBox(variantOptions.gameRules.worldBorder, piecesBox))\n\t\t\treturn {\n\t\t\t\tsupported: false,\n\t\t\t\treason: `Pieces are out of bounds.`,\n\t\t\t};\n\t}\n\n\t// 4. Maximum of one promotion line per player.\n\tif (variantOptions.gameRules.promotionRanks) {\n\t\tfor (const playerRanks of Object.values(variantOptions.gameRules.promotionRanks)) {\n\t\t\tif (playerRanks.length > 1) {\n\t\t\t\treturn {\n\t\t\t\t\tsupported: false,\n\t\t\t\t\treason: `Multiple promotion lines per player.`,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\t}\n\n\t// 5. Not too many pieces in total, excluding neutral pieces (voids/obstacles).\n\tconst maxPieces = 200;\n\tlet nonNeutralCount = 0;\n\tfor (const type of variantOptions.position.values()) {\n\t\tconst color = typeutil.getColorFromType(type);\n\t\tif (color !== p.NEUTRAL) nonNeutralCount++;\n\t}\n\tif (nonNeutralCount > maxPieces) {\n\t\treturn {\n\t\t\tsupported: false,\n\t\t\treason: `Too many pieces: ${nonNeutralCount} (max ${maxPieces}).`,\n\t\t};\n\t}\n\n\t// 6. Only suppported pieces may be present.\n\tconst supportedPieces: RawType[] = [\n\t\tr.VOID,\n\t\tr.OBSTACLE,\n\t\tr.KING,\n\t\tr.GIRAFFE,\n\t\tr.CAMEL,\n\t\tr.ZEBRA,\n\t\tr.KNIGHTRIDER,\n\t\tr.AMAZON,\n\t\tr.QUEEN,\n\t\t// rawTypes.ROYALQUEEN, // Not extensively tested\n\t\tr.HAWK,\n\t\tr.CHANCELLOR,\n\t\tr.ARCHBISHOP,\n\t\tr.CENTAUR,\n\t\tr.ROYALCENTAUR,\n\t\tr.ROSE,\n\t\tr.KNIGHT,\n\t\tr.GUARD,\n\t\tr.HUYGEN,\n\t\tr.ROOK,\n\t\tr.BISHOP,\n\t\tr.PAWN,\n\t];\n\tfor (const type of variantOptions.position.values()) {\n\t\tconst rawType = typeutil.getRawType(type);\n\t\tif (!supportedPieces.includes(rawType)) {\n\t\t\treturn {\n\t\t\t\tsupported: false,\n\t\t\t\treason: `Unsupported piece type: ${typeutil.getRawTypeStr(rawType)}.`,\n\t\t\t};\n\t\t}\n\t}\n\n\treturn { supported: true };\n}\n\nexport default {\n\t// Constants\n\tI64_MAX,\n\tBORDER_CAP,\n\tSUPPORTED_VARIANTS,\n\t// Functions\n\tisPositionSupported,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/engines/hydrochess.ts",
    "content": "// src/client/scripts/esm/game/chess/engines/hydrochess.ts\n\n/**\n * HydroChess Engine\n * A JavaScript wrapper for the WASM implementation of HydroChess\n *\n * @author FirePlank\n */\n\nimport icnconverter, {\n\tLongFormatIn,\n} from '../../../../../../shared/chess/logic/icn/icnconverter.js';\n\n// @ts-ignore without this, the type check job fails\nimport wasmUrl from '../../../../../pkg/hydrochess/pkg/hydrochess_wasm_bg.wasm';\n// @ts-ignore without this, the type check job fails\nimport init, * as wasmBindings from '../../../../../pkg/hydrochess/pkg/hydrochess_wasm.js';\n\nconst wasm = wasmBindings as typeof wasmBindings;\nlet wasmInitialized = false;\nlet wasmInitPromise: Promise<boolean> | null = null;\n\ninterface EngineConfig {\n\tengineTimeLimitPerMoveMillis?: number;\n\tstrengthLevel?: number;\n}\n\ninterface EngineWorkerMessage {\n\tstringGamefile: string;\n\tlf: LongFormatIn;\n\tengineConfig?: EngineConfig;\n\tyouAreColor: number;\n\twtime?: number;\n\tbtime?: number;\n\twinc?: number;\n\tbinc?: number;\n\trequestGeneratedMoves?: boolean;\n}\n\ninterface WasmBestMoveResult {\n\tfrom: string;\n\tto: string;\n\tpromotion?: string | null;\n}\n\n// Initializes the WASM module.\n// @returns Promise that resolves when the WASM module is initialized\nasync function initWasm(): Promise<boolean> {\n\tif (!wasmInitPromise) {\n\t\tconsole.debug('[Engine] Initializing HydroChess WASM module');\n\t\twasmInitPromise = init({ module_or_path: wasmUrl })\n\t\t\t.then(async () => {\n\t\t\t\tconsole.debug('[Engine] HydroChess WASM module initialized');\n\t\t\t\twasmInitialized = true;\n\n\t\t\t\tpostMessage('readyok');\n\t\t\t\treturn true;\n\t\t\t})\n\t\t\t.catch((err: unknown) => {\n\t\t\t\tconsole.error('[Engine] Failed to initialize HydroChess WASM module', err);\n\t\t\t\twasmInitialized = false;\n\t\t\t\treturn false;\n\t\t\t});\n\t}\n\treturn wasmInitPromise!;\n}\n\n// Initialize WASM when the module is loaded\nvoid initWasm();\n\n// Main entry point for the engine\nself.onmessage = async function (e: MessageEvent<EngineWorkerMessage>): Promise<void> {\n\tconst data = e.data;\n\n\t// Ensure WASM is initialized before processing commands\n\tif (!wasmInitialized) {\n\t\tconst initialized = await initWasm();\n\t\tif (!initialized) {\n\t\t\tconsole.error('[Engine] WASM module failed to initialize');\n\t\t\tpostMessage({ type: 'move', data: null });\n\t\t\treturn;\n\t\t}\n\t}\n\n\ttry {\n\t\tconst engineColor = data.youAreColor;\n\n\t\t// Convert compressed gamefile (lf) to ICN string\n\t\tconst icnString = icnconverter.LongToShort_Format(data.lf, {\n\t\t\tcompact: true,\n\t\t\tskipPosition: false,\n\t\t\tspaces: false,\n\t\t\tcomments: false,\n\t\t\tmake_new_lines: false,\n\t\t\tmove_numbers: false,\n\t\t});\n\n\t\t// Initialize engine configuration\n\t\tconst engineConfig = {\n\t\t\tstrength_level: data.engineConfig?.strengthLevel ?? 3,\n\t\t\twtime: data.wtime ?? 0,\n\t\t\tbtime: data.btime ?? 0,\n\t\t\twinc: data.winc ?? 0,\n\t\t\tbinc: data.binc ?? 0,\n\t\t};\n\n\t\tlet engine;\n\t\ttry {\n\t\t\tengine = wasm.Engine.from_icn(icnString, engineConfig);\n\t\t} catch (e) {\n\t\t\tconsole.error('[Engine] Failed to start engine from ICN:', e);\n\t\t\tpostMessage({ type: 'move', data: null });\n\t\t\treturn;\n\t\t}\n\n\t\t// Send generated moves for debugging if requested\n\t\tif (data.requestGeneratedMoves === true) {\n\t\t\tconst legalMoves: WasmBestMoveResult[] = engine.get_legal_moves_js();\n\t\t\tconst formattedMoves: string[] = legalMoves.map((m) => `${m.from}>${m.to}`);\n\t\t\t// Send the generated moves back to the main thread for rendering\n\t\t\tpostMessage({ type: 'generatedMoves', data: formattedMoves });\n\t\t\tengine.free();\n\t\t\treturn;\n\t\t}\n\n\t\tconst timeLimit = data.engineConfig?.engineTimeLimitPerMoveMillis ?? 0;\n\t\tconst bestMoveResult = engine.get_best_move_with_time(timeLimit, true);\n\t\tengine.free();\n\n\t\tif (!bestMoveResult) {\n\t\t\tconsole.error('[Engine] No best move result returned from WASM');\n\t\t\tpostMessage({ type: 'move', data: null });\n\t\t\treturn;\n\t\t}\n\n\t\t// Format: \"x,y>x,y\" or \"x,y>x,y=Q\" (promotion)\n\t\tconst from = bestMoveResult.from;\n\t\tconst to = bestMoveResult.to;\n\t\tlet moveString = `${from}>${to}`;\n\t\tif (bestMoveResult.promotion) {\n\t\t\tconst promoAbbr = mapRustPromotionToSiteAbbr(bestMoveResult.promotion, engineColor);\n\t\t\tmoveString += `=${promoAbbr}`;\n\t\t}\n\n\t\tpostMessage({ type: 'move', data: moveString });\n\t} catch (error) {\n\t\tconsole.error(`[Engine] Error finding best move:`, error);\n\t\tpostMessage({ type: 'move', data: null });\n\t}\n};\n\nfunction mapRustPromotionToSiteAbbr(\n\tpromotion: string | null | undefined,\n\tengineColor: number,\n): string {\n\tconst code = String(promotion ?? '').toLowerCase();\n\tconst isWhite = engineColor === 1;\n\tconst map: Record<string, { w: string; b: string }> = {\n\t\tq: { w: 'Q', b: 'q' },\n\t\tr: { w: 'R', b: 'r' },\n\t\tb: { w: 'B', b: 'b' },\n\t\tn: { w: 'N', b: 'n' },\n\t\tm: { w: 'AM', b: 'am' },\n\t\th: { w: 'HA', b: 'ha' },\n\t\tc: { w: 'CH', b: 'ch' },\n\t\ta: { w: 'AR', b: 'ar' },\n\t\te: { w: 'CE', b: 'ce' },\n\t\tg: { w: 'GU', b: 'gu' },\n\t\tl: { w: 'CA', b: 'ca' },\n\t\ti: { w: 'GI', b: 'gi' },\n\t\tz: { w: 'ZE', b: 'ze' },\n\t\ty: { w: 'RQ', b: 'rq' },\n\t\td: { w: 'RC', b: 'rc' },\n\t\ts: { w: 'NR', b: 'nr' },\n\t\tu: { w: 'HU', b: 'hu' },\n\t\to: { w: 'RO', b: 'ro' },\n\t\tk: { w: 'K', b: 'k' },\n\t\tp: { w: 'P', b: 'p' },\n\t};\n\tconst entry = map[code];\n\tif (!entry) return isWhite ? 'Q' : 'q';\n\treturn isWhite ? entry.w : entry.b;\n}\n\nexport {};\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/game.ts",
    "content": "// src/client/scripts/esm/game/chess/game.ts\n\n/**\n * This script prepares our game.\n *\n * And contains our main update() and render() methods\n */\n\nimport type { Mesh } from '../rendering/piecemodels.js';\nimport type { Color } from '../../../../../shared/util/math/math.js';\nimport type { FullGame } from '../../../../../shared/chess/logic/gamefile.js';\n\nimport clock from '../../../../../shared/chess/logic/clock.js';\nimport bimath from '../../../../../shared/util/math/bimath.js';\nimport gamefileutility from '../../../../../shared/chess/util/gamefileutility.js';\n\nimport gui from '../gui/gui.js';\nimport mouse from '../../util/mouse.js';\nimport pieces from '../rendering/pieces.js';\nimport arrows from '../rendering/arrows/arrows.js';\nimport border from '../rendering/border.js';\nimport camera from '../rendering/camera.js';\nimport invites from '../misc/invites.js';\nimport gameslot from './gameslot.js';\nimport guititle from '../gui/guititle.js';\nimport boardpos from '../rendering/boardpos.js';\nimport controls from '../misc/controls.js';\nimport snapping from '../rendering/highlights/snapping.js';\nimport guiclock from '../gui/guiclock.js';\nimport premoves from './premoves.js';\nimport keybinds from '../misc/keybinds.js';\nimport animation from '../rendering/animation.js';\nimport selection from './selection.js';\nimport boarddrag from '../rendering/boarddrag.js';\nimport starfield from '../rendering/starfield.js';\nimport gameloader from './gameloader.js';\nimport highlights from '../rendering/highlights/highlights.js';\nimport droparrows from '../rendering/dragging/droparrows.js';\nimport dragarrows from '../rendering/dragging/dragarrows.js';\nimport onlinegame from '../misc/onlinegame/onlinegame.js';\nimport boardtiles from '../rendering/boardtiles.js';\nimport Transition from '../rendering/transitions/Transition.js';\nimport primitives from '../rendering/primitives.js';\nimport maskedDraw from '../../webgl/maskedDraw.js';\nimport arrowshifts from '../rendering/arrows/arrowshifts.js';\nimport annotations from '../rendering/highlights/annotations/annotations.js';\nimport boardeditor from '../boardeditor/boardeditor.js';\nimport perspective from '../rendering/perspective.js';\nimport piecemodels from '../rendering/piecemodels.js';\nimport screenshake from '../rendering/screenshake.js';\nimport { GameBus } from '../GameBus.js';\nimport coordinates from '../rendering/coordinates.js';\nimport frametracker from '../rendering/frametracker.js';\nimport WaterRipples from '../rendering/WaterRipples.js';\nimport guinavigation from '../gui/guinavigation.js';\nimport draganimation from '../rendering/dragging/draganimation.js';\nimport webgl, { gl } from '../rendering/webgl.js';\nimport promotionlines from '../rendering/promotionlines.js';\nimport arrowsgraphics from '../rendering/arrows/arrowsgraphics.js';\nimport { ProgramManager } from '../../webgl/ProgramManager.js';\nimport { EffectZoneManager } from '../rendering/effect_zone/EffectZoneManager.js';\nimport arrowlegalmovehighlights from '../rendering/arrows/arrowlegalmovehighlights.js';\nimport selectedpiecehighlightline from '../rendering/highlights/selectedpiecehighlightline.js';\nimport buffermodel, { createRenderable } from '../../webgl/Renderable.js';\nimport { CreateInputListener, InputListener } from '../input.js';\nimport {\n\tPostProcessingPipeline,\n\tPostProcessPass,\n} from '../../webgl/post_processing/PostProcessingPipeline.js';\n\n// Variables -------------------------------------------------------------------------------\n\nconst element_overlay: HTMLElement = document.getElementById('overlay')!;\n/** The input listener for the overlay element */\nlet listener_overlay: InputListener;\n/** The input listener for the document element */\nlet listener_document: InputListener;\n\n/** Manager of our Shaders */\nlet programManager: ProgramManager;\n/** Manager of Post Processing Effects */\nlet pipeline: PostProcessingPipeline;\n/** Manager of Effect Zones */\nlet effectZoneManager: EffectZoneManager | undefined;\n\n// /**\n//  * Replaces the starfield with a gradient color flow inside void.\n//  * Used for creating video footage.\n//  */\n// let colorFlowRenderer: ColorFlowRenderer;\n\n// Functions -------------------------------------------------------------------------------\n\nfunction init(): void {\n\tprogramManager = new ProgramManager(gl);\n\tbuffermodel.init(gl, programManager);\n\tmaskedDraw.init(programManager);\n\n\tpipeline = new PostProcessingPipeline(gl, programManager);\n\teffectZoneManager = new EffectZoneManager(gl, programManager);\n\t// colorFlowRenderer = new ColorFlowRenderer(gl);\n\tWaterRipples.init(programManager, gl.canvas.width, gl.canvas.height);\n\tboardtiles.init();\n\n\tlistener_overlay = CreateInputListener(element_overlay, { keyboard: false });\n\tlistener_document = CreateInputListener(document);\n\n\tgui.prepareForOpen();\n\n\tguititle.open();\n\n\t// Update the pipeline on canvas resize\n\tdocument.addEventListener('canvas_resize', (event) => {\n\t\tconst { width, height } = event.detail;\n\t\tpipeline.resize(width, height);\n\t});\n}\n\n// Update the game every single frame\nfunction update(): void {\n\tscreenshake.update();\n\tcontrols.testOutGameToggles();\n\tinvites.update();\n\t// Any input should trigger the next frame to render.\n\tif (listener_document.atleastOneInput() || listener_overlay.atleastOneInput())\n\t\tframetracker.onVisualChange();\n\tif (gameloader.areWeLoadingGame()) return; // If the game isn't totally finished loading, nothing is visible, only the loading animation.\n\n\tconst gamefile = gameslot.getGamefile();\n\tconst mesh = gameslot.getMesh();\n\tif (!gamefile) {\n\t\t// Only do title screen updates\n\t\tboardpos.update();\n\t\tboardtiles.recalcVariables();\n\t\t// Update the effect zone manager.\n\t\teffectZoneManager!.update(getFurthestTileVisible());\n\t\treturn;\n\t}\n\n\t// There is a gamefile, update everything board-related...\n\n\tstarfield.update(); // Update the star field animation, if needed.\n\n\tcontrols.testInGameToggles(gamefile, mesh);\n\n\tperspective.update(); // Update perspective camera according to mouse movement\n\n\tconst timeWinner = clock.update(gamefile.basegame);\n\tif (timeWinner && !onlinegame.areInOnlineGame()) {\n\t\t// undefined if no clock has ran out\n\t\tgamefileutility.setConclusion(gamefile.basegame, { victor: timeWinner, condition: 'time' });\n\t\tgameslot.concludeGame();\n\t}\n\tguiclock.update(gamefile.basegame);\n\n\tcontrols.updateNavControls(); // Update board dragging, and WASD to move, scroll to zoom\n\tboardpos.update(); // Updates the board's position and scale according to its velocity\n\n\tboarddrag.dragBoard(); // Calculate new board position if it's being dragged. After updateNavControls(), executeArrowShifts(), boardpos.update\n\t// BEFORE board.recalcVariables(), as that needs to be called after the board position is updated.\n\tTransition.update();\n\t// AFTER boarddrag.dragBoard() or picking up the board has a spring back effect to it\n\t// AFTER:transition.update() since that updates the board position\n\tboardtiles.recalcVariables();\n\n\t// Update the effect zone manager (after board variables are recalculated).\n\teffectZoneManager!.update(getFurthestTileVisible());\n\n\t// Check if the board needs to be pinched (will not single-pointer grab)\n\t// This needs to be high up, as pinching the board has priority over the pointer than a lot of things.\n\tboarddrag.checkIfBoardPinched();\n\n\t// NEEDS TO BE BEFORE selection.update() and boarddrag.checkIfBoardSingleGrabbed()\n\t// because the drawing tools of the boad editor might take precedence and claim the left mouse click\n\tboardeditor.update();\n\n\t// NEEDS TO BE AFTER animation.update() because this updates droparrows.ts and that needs to overwrite animations.\n\t// BEFORE arrows.update(), since this may forward to front, which changes all arrows visible.\n\tselection.update();\n\t// NEEDS TO BE AFTER guinavigation.update(), because otherwise arrows.js may think we are hovering\n\t// over a piece from before forwarding/rewinding a move, causing a crash.\n\tarrows.update();\n\t// NEEDS TO BE AFTER arrows.update() !!! Because this modifies the arrow indicator list.\n\t// NEEDS TO BE BEFORE boarddrag.checkIfBoardSingleGrabbed() because that shift arrows needs to overwrite this.\n\tanimation.update();\n\tdraganimation.updateDragLocation(); // BEFORE droparrows.shiftArrows() so that can overwrite this.\n\tdroparrows.shiftArrows(); // Shift the arrows of the dragged piece AFTER selection.update() makes any moves made!\n\tdragarrows.update(); // AFTER droparrows.shiftArrows(), BEFORE executeArrowShifts().\n\tarrowshifts.executeArrowShifts(); // Execute any arrow modifications made by animation.js or arrowsdrop.js. Before arrowlegalmovehighlights.update(), dragBoard()\n\tdroparrows.updateLegalCaptureArrows(); // AFTER executeArrowShifts(), so rebuilt arrow lines don't reset pulsating opacities.\n\n\tarrowlegalmovehighlights.update(); // After executeArrowShifts()\n\n\t// BEFORE annotations.update() since adding new highlights snaps to what mini image is being hovered over.\n\t// NEEDS TO BE BEFORE checkIfBoardDragged(), because clicks should prioritize teleporting to miniimages over dragging the board!\n\t// AFTER: boardpos.dragBoard(), because whether the miniimage are visible or not depends on our updated board position and scale.\n\tsnapping.teleportToEntitiesIfClicked(); // AFTER snapping.updateEntitiesHovered()\n\tsnapping.teleportToSnapIfClicked();\n\tpremoves.update(gamefile, mesh); // BEFORE annotations update(), since if right click cancels premoves, we don't want to draw arrows.\n\t// AFTER snapping.updateEntitiesHovered(), since adding/removing depends on current hovered entities.\n\tannotations.update();\n\n\t// AFTER snapping.updateSnapping(), since clicking on a highlight line should claim the click that would other wise collapse all annotations.\n\ttestIfEmptyBoardRegionClicked(gamefile, mesh); // If we clicked an empty region of the board, collapse annotations and cancel premoves.\n\t// Now we can check if the board needs to be single-pointer grabbed,\n\t// as other scripts may have claimed the pointer first.\n\t// AFTER: selection.update(), animation.update() because shift arrows needs to overwrite that.\n\t// After entities.updateEntitiesHovered() because clicks prioritize those.\n\tboarddrag.checkIfBoardSingleGrabbed();\n\n\tgameloader.update(); // Updates whatever game is currently loaded.\n\n\tguinavigation.updateElement_Coords(); // Update the division on the screen displaying your current coordinates\n\n\t// preferences.update(); // ONLY USED for temporarily micro adjusting theme properties & colors\n}\n\n/**\n * Tests if by clicking an empty region of the board,\n * we need to clear premoves and collapse annotations.\n */\nfunction testIfEmptyBoardRegionClicked(gamefile: FullGame, mesh: Mesh | undefined): void {\n\tconst mouseKeybind = keybinds.getCollapseMouseButton();\n\tif (mouseKeybind === undefined) return; // No button is assigned to collaping annotes / cancelling premoves currently\n\n\tif (mouse.isMouseClicked(mouseKeybind)) {\n\t\tmouse.claimMouseClick(mouseKeybind);\n\n\t\tpremoves.cancelPremoves(gamefile, mesh);\n\t\tannotations.Collapse();\n\t}\n}\n\n/**\n * Renders everthing in-game, and applies post processing effects to the final image.\n */\nfunction render(): void {\n\t// First gather all post processing effects this frame\n\tconst passes: PostProcessPass[] = [];\n\t// Append water ripples of really far moves!\n\tpasses.push(...WaterRipples.getPass());\n\t// Add the current effect zone passes\n\tpasses.push(...effectZoneManager!.getActivePostProcessPasses());\n\t// Set them in the pipeline\n\tpipeline.setPasses(passes);\n\n\t// Only use the pipeline if there are any current effects,\n\t// as a completely empty pipeline still increases gpu usage by roughly 33%\n\n\t// Tell the pipeline to begin. All subsequent rendering will go to a texture.\n\tif (passes.length > 0) pipeline.begin();\n\n\t// Render the game scene\n\trenderScene();\n\n\t// Tell the pipeline we are finished drawing the scene.\n\t// It will handle drawing the result to the screen.\n\tif (passes.length > 0) pipeline.end();\n}\n\n/** Renders all in our scene. */\nfunction renderScene(): void {\n\tif (gameloader.areWeLoadingGame()) return; // If the game isn't totally finished loading, nothing is visible, only the loading animation.\n\n\tconst gamefile = gameslot.getGamefile();\n\tconst mesh = gameslot.getMesh();\n\t// if (!gamefile) return boardtiles.render(); // No gamefile, on the selection menu. Only render the checkerboard and nothing else.\n\tif (!gamefile) {\n\t\teffectZoneManager!.renderBoard();\n\t\treturn;\n\t}\n\n\t// Star Field Animation: Appears in border & voids\n\tmaskedDraw.execute(\n\t\t() => piecemodels.renderVoids(mesh), // INCLUSION MASK is our voids\n\t\t() => border.drawPlayableRegionMask(gamefile.basegame.gameRules.worldBorder), // EXCLUSION MASK is our playable region\n\t\t() => starfield.render(), // MAIN SCENE\n\t\t// () => colorFlowRenderer.render(loadbalancer.getDeltaTime()), // Replaces starfield with a gradient color flow\n\t\t'or', // Intersection Mode: Draw in both the inclusion and inversion of exclusion regions.\n\t);\n\t// Board Tiles & Voids: Mask the playable region so the tiles\n\t// don't render outside the world border or where voids should be\n\tmaskedDraw.execute(\n\t\t() => border.drawPlayableRegionMask(gamefile.basegame.gameRules.worldBorder), // INCLUSION MASK containing playable region\n\t\t() => piecemodels.renderVoids(mesh), // EXCLUSION MASK (voids)\n\t\t() => renderTilesAndPromoteLines(), // MAIN SCENE\n\t\t'and', // Intersection Mode: Draw where the inclusion and inversion of exclusion regions intersect.\n\t);\n\n\tif (camera.getDebug() && !perspective.getEnabled()) renderOutlineofScreenBox();\n\n\t/**\n\t * What is the order of rendering?\n\t *\n\t * Board tiles\n\t * Highlights\n\t * Pieces\n\t * Arrows\n\t * Crosshair\n\t */\n\n\t// Using depth function \"ALWAYS\" means we don't have to render with a tiny z offset\n\twebgl.executeWithDepthFunc_ALWAYS(() => {\n\t\tcoordinates.render();\n\t\tselectedpiecehighlightline.render();\n\t\thighlights.render(gamefile.boardsim);\n\t\tGameBus.dispatch('render-below-pieces');\n\t\tsnapping.render(); // Renders ghost image or glow dot over snapped point on highlight lines.\n\t\tanimation.renderTransparentSquares(); // Required to hide the piece currently being animated\n\t\tdraganimation.renderTransparentSquare(); // Required to hide the piece currently being animated\n\t});\n\n\t// The rendering of the pieces needs to use the normal depth function, because the\n\t// rendering of currently-animated pieces needs to be blocked by animations.\n\tpieces.renderPiecesInGame(gamefile.boardsim, mesh);\n\n\t// Using depth function \"ALWAYS\" means we don't have to render with a tiny z offset\n\twebgl.executeWithDepthFunc_ALWAYS(() => {\n\t\tanimation.renderAnimations();\n\t\tselection.renderGhostPiece(); // If not after pieces.renderPiecesInGame(), wont render on top of existing pieces\n\t\tdraganimation.renderPiece();\n\t\tdragarrows.render();\n\t\tarrowsgraphics.render();\n\t\tboardeditor.render();\n\t\tannotations.render_abovePieces();\n\t\tGameBus.dispatch('render-above-pieces');\n\t\tperspective.renderCrosshair();\n\t});\n}\n\n/** Renders items that need to be able to be masked by the world border. */\nfunction renderTilesAndPromoteLines(): void {\n\teffectZoneManager!.renderBoard();\n\tpromotionlines.render();\n}\n\n/**\n * [DEBUG] Renders an outline of the viewing screen bounding box.\n * Will only be visible if camera debug mode is on.\n */\nfunction renderOutlineofScreenBox(): void {\n\tconst { left, right, bottom, top } = camera.getScreenBoundingBox(false);\n\n\t// const color: Color = [0.65,0.15,0, 1]; // Maroon (matches light brown wood theme)\n\tconst color: Color = [0, 0, 0, 0.5]; // Transparent Black\n\tconst data = primitives.Rect(left, bottom, right, top, color);\n\n\tcreateRenderable(data, 2, 'LINE_LOOP', 'color', true).render();\n}\n\n/** Returns the absolute value of the furthest tile from the origin on our screen. */\nfunction getFurthestTileVisible(): bigint {\n\tconst tileBox = boardtiles.gboundingBox(false);\n\tlet furthest: bigint = 0n;\n\tif (bimath.abs(tileBox.left) > furthest) furthest = bimath.abs(tileBox.left);\n\tif (bimath.abs(tileBox.right) > furthest) furthest = bimath.abs(tileBox.right);\n\tif (bimath.abs(tileBox.top) > furthest) furthest = bimath.abs(tileBox.top);\n\tif (bimath.abs(tileBox.bottom) > furthest) furthest = bimath.abs(tileBox.bottom);\n\treturn furthest;\n}\n\n/** Returns the overlay element covering the entire canvas. */\nfunction getOverlay(): HTMLElement {\n\treturn element_overlay;\n}\n\nexport default {\n\tinit,\n\tupdate,\n\trender,\n\tgetOverlay,\n};\n\nexport { listener_overlay, listener_document };\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/gamecompressor.ts",
    "content": "// src/client/scripts/esm/game/chess/gamecompressor.ts\n\n/**\n * This script handles the compression of a gamefile into a more simple json format,\n * suitable for the icnconverter to turn it into ICN (Infinite Chess Notation).\n */\n\nimport type { MoveFull } from '../../../../../shared/chess/logic/movepiece.js';\nimport type { FullGame } from '../../../../../shared/chess/logic/gamefile.js';\nimport type { CoordsKey } from '../../../../../shared/chess/util/coordutil.js';\nimport type { EnPassant } from '../../../../../shared/chess/logic/state.js';\nimport type { GameRules } from '../../../../../shared/chess/util/gamerules.js';\n\nimport state from '../../../../../shared/chess/logic/state.js';\nimport jsutil from '../../../../../shared/util/jsutil.js';\nimport boardchanges from '../../../../../shared/chess/logic/boardchanges.js';\nimport {\n\tMovePreprint,\n\tLongFormatIn,\n\tPresetAnnotes,\n} from '../../../../../shared/chess/logic/icn/icnconverter.js';\n\n/**\n * This is the bare minimum gamefile you need to keep track of STATE,\n * or, properties of a gamefile that may change from making moves,\n * and you don't record the moves list so second-handedly keep track\n * of states like whosTurn and fullMove number.\n *\n * This is used in {@link GameToPosition} when converting a gamefile to a single position.\n */\ninterface SimplifiedGameState {\n\t// The pieces\n\tposition: Map<CoordsKey, number>;\n\t// The turnOrder rotating essentially keeps track of whos turn it is in the position.\n\tturnOrder: GameRules['turnOrder'];\n\t// The fullMove number increments with every turn cycle\n\tfullMove: number;\n\t// For state.ts, the 3 global game states\n\tstate_global: {\n\t\tspecialRights: Set<CoordsKey>;\n\t\tenpassant?: EnPassant;\n\t\tmoveRuleState?: number;\n\t};\n}\n\n/**\n * Primes the provided gamefile to for the icnconverter to turn it into an ICN\n * @param gamefile - The gamefile\n * @param copySinglePosition - If true, only copy the current position, not the entire game. It won't have the moves list.\n * @param presetAnnotes - Should be specified if we have overrides for the variant's preset annotations.\n * @returns The primed gamefile for converting into ICN format\n */\nfunction compressGamefile(\n\t{ basegame, boardsim }: FullGame,\n\tcopySinglePosition?: boolean,\n\tpresetAnnotes?: PresetAnnotes,\n): LongFormatIn {\n\t// console.log(\"Compressing gamefile for ICN conversion...\");\n\t// console.log(\"Basegame:\", jsutil.deepCopyObject(basegame));\n\t// console.log(\"Boardsim:\", jsutil.deepCopyObject(boardsim));\n\n\t/*\n\t * We need to calculate the game state so that, if desired,\n\t * we can convert the gamefile to a single position.\n\t */\n\tconst gameRulesCopy = jsutil.deepCopyObject(basegame.gameRules);\n\tlet gamestate: SimplifiedGameState = {\n\t\tposition: jsutil.deepCopyObject(boardsim.startSnapshot.position),\n\t\tturnOrder: gameRulesCopy.turnOrder,\n\t\tfullMove: boardsim.startSnapshot.fullMove,\n\t\tstate_global: jsutil.deepCopyObject(boardsim.startSnapshot.state_global),\n\t};\n\n\t// Modify the state if we're applying moves to match a single position\n\tif (copySinglePosition)\n\t\tgamestate = GameToPosition(gamestate, boardsim.moves, boardsim.state.local.moveIndex + 1); // Convert -1 based to 0 based\n\n\t// Start constructing the abridged gamefile\n\tconst long_format_in: LongFormatIn = {\n\t\tmetadata: jsutil.deepCopyObject(basegame.metadata),\n\t\tposition: gamestate.position,\n\t\tgameRules: gameRulesCopy,\n\t\tfullMove: gamestate.fullMove,\n\t\tstate_global: gamestate.state_global,\n\t\tmoves: copySinglePosition ? [] : convertMovesToICNConverterInMove(boardsim.moves),\n\t};\n\n\t// Add the preset annotation overrides from the previously pasted game, if present.\n\tif (presetAnnotes) long_format_in.presetAnnotes = presetAnnotes;\n\n\t// console.log(\"Constructed LongFormatIn:\", jsutil.deepCopyObject(long_format_in));\n\n\treturn long_format_in;\n}\n\nfunction convertMovesToICNConverterInMove(moves: MoveFull[]): MovePreprint[] {\n\tconst mappedMoves = moves.map((move: MoveFull) => {\n\t\tconst movePreprint: MovePreprint = {\n\t\t\ttype: move.type,\n\t\t\tstartCoords: move.startCoords,\n\t\t\tendCoords: move.endCoords,\n\t\t\ttoken: move.token,\n\t\t\tflags: move.flags,\n\t\t};\n\t\t// Optionals\n\t\tif (move.promotion !== undefined) movePreprint.promotion = move.promotion;\n\t\tif (move.comment) movePreprint.comment = move.comment;\n\t\tif (move.clockStamp !== undefined) movePreprint.clockStamp = move.clockStamp;\n\n\t\treturn movePreprint;\n\t});\n\treturn jsutil.deepCopyObject(mappedMoves);\n}\n\n// Converting a Game to Single Position ---------------------------------------------------------------------------------\n\n/**\n * Takes a simple game state and applies the desired moves to it, modifying it.\n * @param longform\n * @param moves - The moves of the original gamefile to apply to the state\n * @param [halfmoves] - Number of halfmoves from starting position to apply to the state (Infinity: final position of game)\n */\nfunction GameToPosition(\n\tlongform: SimplifiedGameState,\n\tmoves: MoveFull[],\n\thalfmoves: number = 0,\n): SimplifiedGameState {\n\tif (halfmoves === Infinity) halfmoves = moves.length; // If we want the final position, set halfmoves to the length of the moves array\n\tif (moves.length < halfmoves)\n\t\tthrow Error(\n\t\t\t`Cannot convert game to position. Moves length (${moves.length}) is less than desired halfmoves (${halfmoves}).`,\n\t\t);\n\tif (halfmoves === 0) return longform; // No changes needed\n\n\t// console.log('Before converting gamestate to single position:', jsutil.deepCopyObject(longform));\n\n\t// First update the fullMove number. Increment one for each full turn cycle applied to the state.\n\tlongform.fullMove += Math.floor(halfmoves / longform.turnOrder.length);\n\n\t// Iterate through each move, progressively applying their game state changes,\n\t// until we reach the desired halfmove.\n\tfor (let i = 0; i < halfmoves; i++) {\n\t\tconst move = moves[i]!;\n\n\t\t// Apply the move's state changes.\n\t\t// state.applyMove(longform, move.state, true, { globalChange: true }); // Apply the State of the move\n\t\tstate.applyGlobalStateChanges(longform.state_global, move.state.global, true);\n\t\t// Next apply the logical (piece) changes.\n\t\tboardchanges.runChanges_Position(longform.position, move.changes);\n\n\t\t// Rotate the turn order, moving the first player to the back\n\t\tlongform.turnOrder.push(longform.turnOrder.shift()!);\n\t}\n\n\t// console.log('After converting gamestate to single position:', jsutil.deepCopyObject(longform));\n\n\treturn longform;\n}\n\n// Exports --------------------------------------------------------------------------------------------------------------\n\nexport default {\n\tcompressGamefile,\n\tGameToPosition,\n};\n\nexport type { SimplifiedGameState };\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/gamecompressor.unit.test.ts",
    "content": "// src/client/scripts/esm/game/chess/gamecompressor.unit.test.ts\n\nimport type { FullGame } from '../../../../../shared/chess/logic/gamefile.js';\nimport type { SimplifiedGameState } from './gamecompressor.js';\n\nimport { describe, it, expect } from 'vitest';\n\nimport { players as p } from '../../../../../shared/chess/util/typeutil.js';\n\nimport gamecompressor from './gamecompressor.js';\n\ndescribe('gamecompressor', () => {\n\tdescribe('compressGamefile', () => {\n\t\tit('should compress a basic gamefile correctly', () => {\n\t\t\tconst mockMetaData = {\n\t\t\t\tEvent: 'Boston Infinite Chess Party',\n\t\t\t\tSite: 'https://www.infinitechess.org/',\n\t\t\t\tTimeControl: '-',\n\t\t\t\tRound: '-',\n\t\t\t\tUTCDate: '1987.06.27',\n\t\t\t\tUTCTime: '12:00:00',\n\t\t\t\tVariant: 'standard',\n\t\t\t\tWhite: 'Rick',\n\t\t\t\tBlack: 'Waterman',\n\t\t\t} as const;\n\n\t\t\tconst mockGame: FullGame = {\n\t\t\t\tbasegame: {\n\t\t\t\t\tmetadata: mockMetaData,\n\t\t\t\t\tdateTimestamp: Date.now(),\n\t\t\t\t\t// The game rules are essential for the compressor to know the turn order\n\t\t\t\t\tgameRules: {\n\t\t\t\t\t\tturnOrder: [p.WHITE, p.BLACK],\n\t\t\t\t\t} as any,\n\t\t\t\t\tmoves: [],\n\t\t\t\t\twhosTurn: p.WHITE,\n\t\t\t\t\tuntimed: true,\n\t\t\t\t\tclocks: undefined,\n\t\t\t\t},\n\t\t\t\tboardsim: {\n\t\t\t\t\tstartSnapshot: {\n\t\t\t\t\t\tposition: new Map(),\n\t\t\t\t\t\tfullMove: 1,\n\t\t\t\t\t\tstate_global: {\n\t\t\t\t\t\t\tspecialRights: new Set(),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tmoves: [],\n\t\t\t\t\tstate: {\n\t\t\t\t\t\tlocal: {\n\t\t\t\t\t\t\tmoveIndex: -1,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t} as any,\n\t\t\t};\n\n\t\t\tconst result = gamecompressor.compressGamefile(mockGame);\n\n\t\t\texpect(result.metadata).toEqual(mockGame.basegame.metadata);\n\t\t\texpect(result.fullMove).toBe(1);\n\t\t\texpect(result.moves).toEqual([]);\n\t\t});\n\t});\n\n\tdescribe('GameToPosition', () => {\n\t\tit('should return the same state if halfmoves is 0', () => {\n\t\t\tconst initialState: SimplifiedGameState = {\n\t\t\t\tposition: new Map(),\n\t\t\t\tturnOrder: [p.WHITE, p.BLACK],\n\t\t\t\tfullMove: 1,\n\t\t\t\tstate_global: {\n\t\t\t\t\tspecialRights: new Set(),\n\t\t\t\t},\n\t\t\t};\n\n\t\t\tconst result = gamecompressor.GameToPosition(initialState, [], 0);\n\t\t\texpect(result).toBe(initialState);\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/gameformulator.ts",
    "content": "// src/client/scripts/esm/game/chess/gameformulator.ts\n\n/**\n * This script takes an ICN, or a compressed abridged gamefile, and constructs a full gamefile from them.\n */\n\nimport type { FullGame } from '../../../../../shared/chess/logic/gamefile.js';\nimport type { MovePacket } from '../../../../../shared/types.js';\nimport type { VariantOptions } from '../../../../../shared/chess/logic/initvariant.js';\nimport type {\n\tMovePreprint,\n\tLongFormatIn,\n} from '../../../../../shared/chess/logic/icn/icnconverter.js';\n\nimport variant from '../../../../../shared/chess/variants/variant.js';\nimport gamefile from '../../../../../shared/chess/logic/gamefile.js';\n\nimport clientmetadatautil from './clientmetadatautil.js';\n\n/**\n * Formulates a whole gamefile from a smaller simpler abridged one.\n * @param longformIn - The return value of gamecompressor.compressGamefile()\n * @param validateMoves - Optional flag to validate move legality during formulation, throwing an error if any move is illegal.\n */\nfunction formulateGame(longformIn: LongFormatIn, validateMoves?: true): FullGame {\n\tif (longformIn.position === undefined || longformIn.state_global.specialRights === undefined) {\n\t\tthrow Error(\n\t\t\t'Invalid longformIn when formulating game: Missing position or special rights.',\n\t\t);\n\t}\n\n\t/** String array of the moves in their most compact notation (e.g. \"4,7>4,8Q\") */\n\tconst moves: MovePacket[] =\n\t\tlongformIn.moves?.map((m: MovePreprint) => {\n\t\t\tconst move: MovePacket = { token: m.token };\n\t\t\tif (m.clockStamp !== undefined) move.clockStamp = m.clockStamp;\n\t\t\treturn move;\n\t\t}) ?? [];\n\n\tconst variantOptions: VariantOptions = {\n\t\tfullMove: longformIn.fullMove,\n\t\tgameRules: longformIn.gameRules,\n\t\tposition: longformIn.position!,\n\t\tstate_global: {\n\t\t\tspecialRights: longformIn.state_global.specialRights,\n\t\t\tenpassant: longformIn.state_global.enpassant,\n\t\t\tmoveRuleState: longformIn.state_global.moveRuleState,\n\t\t},\n\t};\n\n\tconst resolvedTimestamp = clientmetadatautil.resolveTimestampFromMetadata(\n\t\tlongformIn.metadata.UTCDate,\n\t\tlongformIn.metadata.UTCTime,\n\t);\n\tconst resolvedVariant = variant.resolveVariantCode(longformIn.metadata.Variant);\n\n\treturn gamefile.initFullGame(\n\t\tlongformIn.metadata,\n\t\tresolvedTimestamp,\n\t\tresolvedVariant,\n\t\t{ variantOptions, moves },\n\t\tvalidateMoves,\n\t);\n}\n\nexport default {\n\tformulateGame,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/gameloader.ts",
    "content": "// src/client/scripts/esm/game/chess/gameloader.ts\n\n/**\n * This script contains the logic for loading any kind of game onto our game board:\n * * Local\n * * Online\n * * Analysis Board (in the future)\n * * Board Editor (in the future)\n *\n * It not only handles the logic of the gamefile,\n * but also prepares and opens the UI elements for that type of game.\n */\n\nimport type { Player } from '../../../../../shared/chess/util/typeutil.js';\nimport type { Additional } from '../../../../../shared/chess/logic/gamefile.js';\nimport type { ValidEngine } from './engines/engine.js';\nimport type { VariantCode } from '../../../../../shared/chess/variants/variantdictionary.js';\nimport type { EngineConfig } from '../misc/enginegame.js';\nimport type { PresetAnnotes } from '../../../../../shared/chess/logic/icn/icnconverter.js';\nimport type { VariantOptions } from '../../../../../shared/chess/logic/initvariant.js';\nimport type { ServerGameInfo } from '../websocket/socketschemas.js';\nimport type { GameConclusion } from '../../../../../shared/chess/util/winconutil.js';\nimport type {\n\tClockValues,\n\tMetaData,\n\tMovePacket,\n\tParticipantState,\n\tTimeControl,\n} from '../../../../../shared/types.js';\n\nimport jsutil from '../../../../../shared/util/jsutil.js';\nimport variant from '../../../../../shared/chess/variants/variant.js';\nimport gamefileutility from '../../../../../shared/chess/util/gamefileutility.js';\nimport { players as p } from '../../../../../shared/chess/util/typeutil.js';\n\nimport gui from '../gui/gui.js';\nimport gameslot from './gameslot.js';\nimport boardpos from '../rendering/boardpos.js';\nimport guiclock from '../gui/guiclock.js';\nimport IndexedDB from '../../util/IndexedDB.js';\nimport Transition from '../rendering/transitions/Transition.js';\nimport onlinegame from '../misc/onlinegame/onlinegame.js';\nimport enginegame from '../misc/enginegame.js';\nimport guipalette from '../gui/boardeditor/guipalette.js';\nimport perspective from '../rendering/perspective.js';\nimport guigameinfo from '../gui/guigameinfo.js';\nimport boardeditor from '../boardeditor/boardeditor.js';\nimport loadingscreen from '../gui/loadingscreen.js';\nimport guinavigation from '../gui/guinavigation.js';\nimport guiboardeditor from '../gui/boardeditor/guiboardeditor.js';\nimport clientmetadatautil from './clientmetadatautil.js';\nimport { engineDictionary, getFormattedEngineName } from './engines/engine.js';\n\n// Variables --------------------------------------------------------------------\n\n/** The type of game we are in, whether local or online, if we are in a game. */\nlet typeOfGameWeAreIn: undefined | 'local' | 'online' | 'engine' | 'editor';\n\n/**\n * True when the gamefile is currently loading either the graphical\n * (such as the SVG requests and spritesheet generation) or engine script.\n *\n * If so, the spinny pawn loading animation will be open.\n */\nlet gameLoading: boolean = false;\n\n// Getters --------------------------------------------------------------------\n\n/**\n * Returns true if we are in ANY type of game, whether local, online, engine, analysis, or editor.\n *\n * If we're on the title screen or the lobby, this will be false.\n */\nfunction areInAGame(): boolean {\n\treturn typeOfGameWeAreIn !== undefined;\n}\n\n/** Returns the type of game we are in. */\nfunction getTypeOfGameWeIn(): typeof typeOfGameWeAreIn {\n\treturn typeOfGameWeAreIn;\n}\n\nfunction areInLocalGame(): boolean {\n\treturn typeOfGameWeAreIn === 'local';\n}\n\nfunction isItOurTurn(): boolean {\n\tif (typeOfGameWeAreIn === undefined)\n\t\tthrow Error(\"Can't tell if it's our turn when we're not in a game!\");\n\tif (typeOfGameWeAreIn === 'online') return onlinegame.isItOurTurn();\n\telse if (typeOfGameWeAreIn === 'engine') return enginegame.isItOurTurn();\n\telse if (typeOfGameWeAreIn === 'editor') return true;\n\telse if (typeOfGameWeAreIn === 'local')\n\t\treturn true; // Always our turn in this case\n\telse\n\t\tthrow Error(\n\t\t\t\"Don't know how to tell if it's our turn in this type of game: \" + typeOfGameWeAreIn,\n\t\t);\n}\n\nfunction getOurColor(): Player | undefined {\n\tif (typeOfGameWeAreIn === undefined)\n\t\tthrow Error(\"Can't get our color when we're not in a game!\");\n\tif (typeOfGameWeAreIn === 'online') return onlinegame.getOurColor();\n\telse if (typeOfGameWeAreIn === 'engine') return enginegame.getOurColor();\n\tthrow Error(\"Can't get our color in this type of game: \" + typeOfGameWeAreIn);\n}\n\n/**\n * Returns true if either the graphics (spritesheet generating),\n * or engine script, of the gamefile are currently being loaded.\n *\n * If so, the spinny pawn loading animation will be open.\n */\nfunction areWeLoadingGame(): boolean {\n\treturn gameLoading;\n}\n\n/**\n * Updates whatever game is currently loaded, for what needs to be updated.\n */\nfunction update(): void {\n\tif (typeOfGameWeAreIn === 'online') onlinegame.update();\n}\n\n// Start Game --------------------------------------------------------------------\n\n/** Starts a local game according to the options provided. */\nasync function startLocalGame(options: {\n\tvariant: VariantCode;\n\ttimeControl: TimeControl;\n}): Promise<void> {\n\ttypeOfGameWeAreIn = 'local';\n\tgameLoading = true;\n\n\t// Has to be awaited to give the document a chance to repaint.\n\tawait loadingscreen.open();\n\n\tconst variantName = variant.getVariantName(options.variant);\n\tconst dateTimestamp = Date.now();\n\tconst metadata = clientmetadatautil.buildBaseGameMetadata(\n\t\t`Casual local ${variantName} infinite chess game`,\n\t\toptions.timeControl,\n\t\tdateTimestamp,\n\t);\n\tmetadata.Variant = variantName;\n\n\tgameslot\n\t\t.loadGamefile({\n\t\t\tmetadata,\n\t\t\tvariant: options.variant,\n\t\t\tdateTimestamp,\n\t\t\tviewWhitePerspective: true,\n\t\t\tallowEditCoords: true,\n\t\t})\n\t\t.then((_result: any) => onFinishedLoading())\n\t\t.catch((err: Error) => onCatchLoadingError(err));\n\n\t// Open the gui stuff AFTER initiating the logical stuff,\n\t// because the gui DEPENDS on the other stuff.\n\n\topenGameinfoBarAndConcludeGameIfOver(metadata, false);\n}\n\n/** Starts an online game according to the options provided by the server. */\nasync function startOnlineGame(options: {\n\tgameInfo: ServerGameInfo;\n\t/** The metadata of the game, including the TimeControl, player names, date, etc.. */\n\tmetadata: MetaData;\n\tgameConclusion?: GameConclusion;\n\t/** Existing moves, if any, to forward to the front of the game. Should be specified if reconnecting to an online. Each move should be in the most compact notation, e.g., `['1,2>3,4','10,7>10,8Q']`. */\n\tmoves: MovePacket[];\n\tclockValues?: ClockValues;\n\tyouAreColor?: Player;\n\tparticipantState?: ParticipantState;\n}): Promise<void> {\n\t// console.log(\"Starting online game with invite options:\");\n\t// console.log(jsutil.deepCopyObject(options));\n\n\ttypeOfGameWeAreIn = 'online';\n\tgameLoading = true;\n\n\t// Has to be awaited to give the document a chance to repaint.\n\tawait loadingscreen.open();\n\n\tconst storageKey = onlinegame.getKeyForOnlineGameVariantOptions(options.gameInfo.id);\n\tconst additional: Additional = {\n\t\tmoves: options.moves,\n\t\tvariantOptions: await IndexedDB.loadItem<VariantOptions>(storageKey),\n\t\tgameConclusion: options.gameConclusion,\n\t\t// If the clock values are provided, adjust the timer of whos turn it is depending on ping.\n\t\tclockValues: options.clockValues,\n\t};\n\n\tconst resolvedVariant = variant.resolveVariantCode(options.metadata.Variant);\n\tconst resolvedTimestamp = clientmetadatautil.resolveTimestampFromMetadata(\n\t\toptions.metadata.UTCDate,\n\t\toptions.metadata.UTCTime,\n\t);\n\n\tgameslot\n\t\t.loadGamefile({\n\t\t\tmetadata: options.metadata,\n\t\t\tvariant: resolvedVariant,\n\t\t\tdateTimestamp: resolvedTimestamp,\n\t\t\tviewWhitePerspective: options.youAreColor === p.WHITE,\n\t\t\tallowEditCoords: false,\n\t\t\tadditional,\n\t\t})\n\t\t.then((_result: any) => onFinishedLoading())\n\t\t.catch((err: Error) => onCatchLoadingError(err));\n\n\tonlinegame.initOnlineGame({\n\t\tgameInfo: options.gameInfo,\n\t\tyouAreColor: options.youAreColor,\n\t\tparticipantState: options.participantState,\n\t});\n\n\t// We need this here because otherwise if we reconnect to the page after refreshing, the sound effects don't play.\n\t// IF THIS DOES NOT COME AFTER onlinegame.initOnlineGame(), then guiclock inaccurately thinks it's a local game,\n\t// THUS playing the drum sound effect for our opponent.\n\tconst basegame = gameslot.getGamefile()!.basegame;\n\tif (!basegame.untimed) guiclock.rescheduleSoundEffects(basegame.clocks);\n\n\t// Open the gui stuff AFTER initiating the logical stuff,\n\t// because the gui DEPENDS on the other stuff.\n\n\topenGameinfoBarAndConcludeGameIfOver(options.metadata, false);\n}\n\n/** Starts an engine game according to the options provided. */\nasync function startEngineGame(options: {\n\t/** The 'Event' string of the game's metadata. */\n\tevent: string;\n\t/** Time control string for the game (e.g. `'600+5'`), or `'-'` for untimed. */\n\ttimeControl: TimeControl;\n\t/** If it's not a practice checkmate, this is the variant code.\n\t * MUTUALLY EXCLUSIVE with variantOptions. */\n\tvariant: VariantCode | null;\n\t/** MUTUALLY EXCLUSIVE with Variant. */\n\tvariantOptions?: VariantOptions;\n\tyouAreColor: Player;\n\tcurrentEngine: ValidEngine;\n\tengineConfig: EngineConfig;\n\t/** Whether to show the Undo and Restart buttons on the gameinfo bar. For checkmate practice games. */\n\tshowGameControlButtons?: true;\n}): Promise<void> {\n\tif (options.variant && options.variantOptions)\n\t\tthrow Error(\n\t\t\t\"Can't provide both Variant and variantOptions at the same time when starting an engine game. They are mutually exclusive.\",\n\t\t);\n\tif (!options.variant && !options.variantOptions)\n\t\tthrow Error('Must provide either Variant or variantOptions when starting an engine game.');\n\n\ttypeOfGameWeAreIn = 'engine';\n\tgameLoading = true;\n\n\t// Has to be awaited to give the document a chance to repaint.\n\tawait loadingscreen.open();\n\n\tconst formattedEngineName = getFormattedEngineName(\n\t\toptions.currentEngine,\n\t\toptions.engineConfig.strengthLevel,\n\t);\n\tconst dateTimestamp = Date.now();\n\tconst metadata = clientmetadatautil.buildBaseGameMetadata(\n\t\toptions.event,\n\t\toptions.timeControl,\n\t\tdateTimestamp,\n\t);\n\tif (options.variant) metadata.Variant = variant.getVariantName(options.variant);\n\tmetadata.White =\n\t\toptions.youAreColor === p.WHITE\n\t\t\t? clientmetadatautil.YOU_NAME_ICN_METADATA\n\t\t\t: formattedEngineName;\n\tmetadata.Black =\n\t\toptions.youAreColor === p.BLACK\n\t\t\t? clientmetadatautil.YOU_NAME_ICN_METADATA\n\t\t\t: formattedEngineName;\n\n\t/** A promise that resolves when the GRAPHICAL (spritesheet) part of the game has finished loading. */\n\tconst graphicalPromise: Promise<void> = gameslot.loadGamefile({\n\t\tmetadata,\n\t\tvariant: options.variant,\n\t\tdateTimestamp,\n\t\tviewWhitePerspective: options.youAreColor === p.WHITE,\n\t\tallowEditCoords: false,\n\t\tadditional: {\n\t\t\tvariantOptions: options.variantOptions,\n\t\t\tworldBorderDist: engineDictionary[options.currentEngine].worldBorder,\n\t\t},\n\t});\n\n\t/** A promise that resolves when the engine script has been fetched. */\n\tconst enginePromise: Promise<void> = enginegame\n\t\t.initEngineGame(options)\n\t\t.then(() => enginegame.onMovePlayed()); // Without this, the engine won't start calculating moves if it's first to move.\n\n\t/**\n\t * This resolves when BOTH the graphical and engine promises resolve,\n\t * OR rejects immediately when one of them rejects!\n\t */\n\tPromise.all([graphicalPromise, enginePromise])\n\t\t.then((_results: any[]) => onFinishedLoading())\n\t\t.catch((err: Error) => onCatchLoadingError(err));\n\n\topenGameinfoBarAndConcludeGameIfOver(metadata, options.showGameControlButtons);\n}\n\n/** Initializes the board editor. */\nasync function startBoardEditor(): Promise<void> {\n\ttypeOfGameWeAreIn = 'editor';\n\tgameLoading = true;\n\n\tawait loadingscreen.open();\n\n\tconst dateTimestamp = Date.now();\n\tconst metadata = clientmetadatautil.buildBaseGameMetadata(\n\t\t'Position created using ingame board editor',\n\t\t'-',\n\t\tdateTimestamp,\n\t);\n\tconst variantCode: VariantCode = 'Classical';\n\tmetadata.Variant = variant.getVariantName(variantCode);\n\n\tgameslot\n\t\t.loadGamefile({\n\t\t\tmetadata,\n\t\t\tvariant: variantCode,\n\t\t\tdateTimestamp,\n\t\t\tviewWhitePerspective: true,\n\t\t\tallowEditCoords: true,\n\t\t\t/**\n\t\t\t * Enable to tell the gamefile to include large amounts of undefined slots for every single piece type in the game.\n\t\t\t * This lets us board edit without worry of regenerating the mesh every time we add a piece.\n\t\t\t *\n\t\t\t * This flag triggers the gamefile to add images for EVERY single piece in the spritesheet!\n\t\t\t * If that also includes all COLORS, then loading a game can take a few seconds...\n\t\t\t */\n\t\t\tadditional: { editor: true },\n\t\t})\n\t\t.then((_result: any) => onFinishedLoading())\n\t\t.catch((err: Error) => onCatchLoadingError(err));\n\n\tawait guipalette.initUI();\n\tboardeditor.initBoardEditor(true); // Dirty position since its a new unsaved position being loaded\n}\n\n/** Initializes a local game from a custom position. */\nasync function startCustomLocalGame(options: {\n\tadditional: {\n\t\tmoves?: MovePacket[];\n\t\tvariantOptions: VariantOptions;\n\t};\n\tpresetAnnotes?: PresetAnnotes;\n}): Promise<void> {\n\ttypeOfGameWeAreIn = 'local';\n\tgameLoading = true;\n\n\t// Has to be awaited to give the document a chance to repaint.\n\tawait loadingscreen.open();\n\n\tconst dateTimestamp = Date.now();\n\tconst metadata = clientmetadatautil.buildBaseGameMetadata(\n\t\t'Casual local custom infinite chess game',\n\t\t'-',\n\t\tdateTimestamp,\n\t);\n\n\tgameslot\n\t\t.loadGamefile({\n\t\t\t...options,\n\t\t\tmetadata,\n\t\t\tdateTimestamp,\n\t\t\tvariant: null, // Not specified for custom position\n\t\t\tviewWhitePerspective: true,\n\t\t\tallowEditCoords: true,\n\t\t})\n\t\t.then((_result: any) => onFinishedLoading())\n\t\t.catch((err: Error) => onCatchLoadingError(err));\n\n\t// Open the gui stuff AFTER initiating the logical stuff,\n\t// because the gui DEPENDS on the other stuff.\n\n\topenGameinfoBarAndConcludeGameIfOver(metadata, false);\n}\n\n/** Starts an engine game from a custom position. */\nasync function startCustomEngineGame(options: {\n\ttimeControl: TimeControl;\n\tadditional: {\n\t\tmoves?: MovePacket[];\n\t\tvariantOptions: VariantOptions;\n\t};\n\tpresetAnnotes?: PresetAnnotes;\n\tyouAreColor: Player;\n\tcurrentEngine: ValidEngine;\n\tengineConfig: EngineConfig;\n\t/** Whether to show the Undo and Restart buttons on the gameinfo bar. For checkmate practice games. */\n\tshowGameControlButtons?: true;\n}): Promise<void> {\n\ttypeOfGameWeAreIn = 'engine';\n\tgameLoading = true;\n\n\t// Has to be awaited to give the document a chance to repaint.\n\tawait loadingscreen.open();\n\n\tconst formattedEngineName = getFormattedEngineName(\n\t\toptions.currentEngine,\n\t\toptions.engineConfig.strengthLevel,\n\t);\n\tconst dateTimestamp = Date.now();\n\tconst metadata = clientmetadatautil.buildBaseGameMetadata(\n\t\t'Casual computer custom infinite chess game',\n\t\toptions.timeControl,\n\t\tdateTimestamp,\n\t);\n\tmetadata.White =\n\t\toptions.youAreColor === p.WHITE\n\t\t\t? clientmetadatautil.YOU_NAME_ICN_METADATA\n\t\t\t: formattedEngineName;\n\tmetadata.Black =\n\t\toptions.youAreColor === p.BLACK\n\t\t\t? clientmetadatautil.YOU_NAME_ICN_METADATA\n\t\t\t: formattedEngineName;\n\n\t/** A promise that resolves when the GRAPHICAL (spritesheet) part of the game has finished loading. */\n\tconst graphicalPromise: Promise<void> = gameslot.loadGamefile({\n\t\tmetadata,\n\t\tvariant: null, // Not specified for custom position\n\t\tdateTimestamp,\n\t\tviewWhitePerspective: options.youAreColor === p.WHITE,\n\t\tallowEditCoords: false,\n\t\tadditional: {\n\t\t\tvariantOptions: options.additional.variantOptions,\n\t\t\tworldBorderDist: engineDictionary[options.currentEngine].worldBorder,\n\t\t},\n\t});\n\n\t/** A promise that resolves when the engine script has been fetched. */\n\tconst enginePromise: Promise<void> = enginegame\n\t\t.initEngineGame(options)\n\t\t.then(() => enginegame.onMovePlayed()); // Without this, the engine won't start calculating moves if it's first to move.\n\n\t/**\n\t * This resolves when BOTH the graphical and engine promises resolve,\n\t * OR rejects immediately when one of them rejects!\n\t */\n\tPromise.all([graphicalPromise, enginePromise])\n\t\t.then((_results: any[]) => onFinishedLoading())\n\t\t.catch((err: Error) => onCatchLoadingError(err));\n\n\topenGameinfoBarAndConcludeGameIfOver(metadata, options.showGameControlButtons);\n}\n\n/** Initializes the board editor from a custom position. */\nasync function startBoardEditorFromCustomPosition(\n\toptions: {\n\t\tadditional: {\n\t\t\tmoves?: MovePacket[];\n\t\t\tvariantOptions: VariantOptions;\n\t\t};\n\t\tpresetAnnotes?: PresetAnnotes;\n\t},\n\t/** Whether the position has unsaved changes. Defaults to true (dirty). */\n\tdirty: boolean,\n\t/** Whether the pawnDoublePush flag should be set for the position in the editor game rules */\n\tpawnDoublePush?: boolean,\n\t/** Whether the castling flag should be set for the position in the editor game rules */\n\tcastling?: boolean,\n): Promise<void> {\n\ttypeOfGameWeAreIn = 'editor';\n\tgameLoading = true;\n\n\t// Has to be awaited to give the document a chance to repaint.\n\tawait loadingscreen.open();\n\n\tconst dateTimestamp = Date.now();\n\tconst metadata = clientmetadatautil.buildBaseGameMetadata(\n\t\t'Position created using ingame board editor',\n\t\t'-',\n\t\tdateTimestamp,\n\t);\n\n\t// Variant options are copied before the gamefile is loaded and this potentially manipualtes them\n\tconst variantOptionsCopy = jsutil.deepCopyObject(options.additional.variantOptions);\n\n\tgameslot\n\t\t.loadGamefile({\n\t\t\tmetadata,\n\t\t\tvariant: null, // Not specified for custom position\n\t\t\tdateTimestamp,\n\t\t\tviewWhitePerspective: true,\n\t\t\tallowEditCoords: true,\n\t\t\t// See comment in startBoardEditor for why \"editor: true\" is needed\n\t\t\tadditional: { ...options.additional, editor: true },\n\t\t\tpresetAnnotes: options.presetAnnotes,\n\t\t})\n\t\t.then((_result: any) => onFinishedLoading())\n\t\t.catch((err: Error) => onCatchLoadingError(err));\n\n\t// Open the gui stuff AFTER initiating the logical stuff,\n\t// because the gui DEPENDS on the other stuff.\n\n\tawait guipalette.initUI();\n\tboardeditor.initBoardEditor(dirty, variantOptionsCopy, pawnDoublePush, castling);\n}\n\n/**\n * Reloads the current local or online game from the provided metadata, existing moves, and variant options.\n */\nasync function pasteGame(options: {\n\tmetadata: MetaData;\n\tvariant: VariantCode | null;\n\tdateTimestamp: number;\n\tadditional: Additional;\n\tpresetAnnotes?: PresetAnnotes;\n}): Promise<void> {\n\tif (typeOfGameWeAreIn !== 'local' && typeOfGameWeAreIn !== 'online')\n\t\tthrow Error(\"Can't paste a game when we're not in a local or online game.\");\n\n\tgameLoading = true;\n\n\t// Has to be awaited to give the document a chance to repaint.\n\tawait loadingscreen.open();\n\n\tconst viewWhitePerspective = gameslot.isLoadedGameViewingWhitePerspective(); // Retain the same perspective as the current loaded game.\n\n\tgameslot.unloadGame();\n\n\tgameslot\n\t\t.loadGamefile({\n\t\t\tmetadata: options.metadata,\n\t\t\tvariant: options.variant,\n\t\t\tdateTimestamp: options.dateTimestamp,\n\t\t\tviewWhitePerspective,\n\t\t\tallowEditCoords: guinavigation.areCoordsAllowedToBeEdited(),\n\t\t\tpresetAnnotes: options.presetAnnotes,\n\t\t\tadditional: options.additional,\n\t\t})\n\t\t.then((_result: any) => onFinishedLoading())\n\t\t.catch((err: Error) => onCatchLoadingError(err));\n\n\t// Open the gui stuff AFTER initiating the logical stuff,\n\t// because the gui DEPENDS on the other stuff.\n\n\topenGameinfoBarAndConcludeGameIfOver(options.metadata, false);\n}\n\n/**\n * A function that is executed when a game is FULLY loaded (graphical, spritesheet, engine, etc.)\n * This hides the spinny pawn loading animation that covers the board.\n */\nfunction onFinishedLoading(): void {\n\t// console.log('COMPLETELY finished loading game!');\n\tgameLoading = false;\n\n\t// We can now close the loading screen.\n\n\t// I don't think this one has to be awaited since we're pretty much\n\t// done with loading, there's not gonna be another lag spike..\n\tloadingscreen.close();\n\tgameslot.startStartingTransition(); // Play the zoom-in animation at the start of games.\n}\n\n/**\n * Replaces the loading animation with the words\n * \"ERROR. One or more resources failed to load. Please refresh.\"\n */\nfunction onCatchLoadingError(err: Error): void {\n\tconsole.error(err);\n\tloadingscreen.onError();\n}\n\n/**\n * These items must be done after the logical parts of the gamefile are fully loaded\n * @param metadata - The metadata of the gamefile\n * @param showGameControlButtons - Whether to show the practice game control buttons \"Undo Move\" and \"Retry\"\n */\nfunction openGameinfoBarAndConcludeGameIfOver(\n\tmetadata: MetaData,\n\tshowGameControlButtons: boolean = false,\n): void {\n\tguigameinfo.open(metadata, showGameControlButtons);\n\tif (gamefileutility.isGameOver(gameslot.getGamefile()!.basegame)) gameslot.concludeGame();\n}\n\nfunction unloadLogicalAndRendering(): void {\n\tgameslot.unloadGame();\n\tperspective.disable();\n\tboardpos.eraseMomentum();\n\tTransition.terminate();\n}\n\nfunction unloadGame(): void {\n\t// console.log(\"Game loader unloading game...\");\n\n\tif (typeOfGameWeAreIn === 'online') onlinegame.closeOnlineGame();\n\telse if (typeOfGameWeAreIn === 'engine') enginegame.closeEngineGame();\n\telse if (typeOfGameWeAreIn === 'editor') boardeditor.closeBoardEditor();\n\n\tguinavigation.close();\n\tguigameinfo.close();\n\tguigameinfo.clearUsernameContainers();\n\tguiboardeditor.close();\n\tunloadLogicalAndRendering();\n\ttypeOfGameWeAreIn = undefined;\n\n\tgui.prepareForOpen();\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\tareInAGame,\n\tareInLocalGame,\n\tisItOurTurn,\n\tgetOurColor,\n\tareWeLoadingGame,\n\tgetTypeOfGameWeIn,\n\tupdate,\n\tstartLocalGame,\n\tstartOnlineGame,\n\tstartEngineGame,\n\tstartBoardEditor,\n\tstartCustomLocalGame,\n\tstartCustomEngineGame,\n\tstartBoardEditorFromCustomPosition,\n\tpasteGame,\n\topenGameinfoBarAndConcludeGameIfOver,\n\tunloadLogicalAndRendering,\n\tunloadGame,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/gameslot.ts",
    "content": "// src/client/scripts/esm/game/chess/gameslot.ts\n\n/**\n * Whether we're in a local game, online game, analysis board, or board editor,\n * what they ALL have in common is a gamefile! This script stores THAT gamefile!\n *\n * It also has the loader and unloader methods for the gamefile.\n */\n\nimport type { Mesh } from '../rendering/piecemodels.js';\nimport type { Player } from '../../../../../shared/chess/util/typeutil.js';\nimport type { MetaData } from '../../../../../shared/types.js';\nimport type { VariantCode } from '../../../../../shared/chess/variants/variantdictionary.js';\nimport type { PresetAnnotes } from '../../../../../shared/chess/logic/icn/icnconverter.js';\nimport type { Additional, FullGame } from '../../../../../shared/chess/logic/gamefile.js';\n\nimport bd from '@naviary/bigdecimal';\n\nimport clock from '../../../../../shared/chess/logic/clock.js';\nimport moveutil from '../../../../../shared/chess/util/moveutil.js';\nimport gamefile from '../../../../../shared/chess/logic/gamefile.js';\nimport movepiece from '../../../../../shared/chess/logic/movepiece.js';\nimport boardutil from '../../../../../shared/chess/util/boardutil.js';\nimport gamefileutility from '../../../../../shared/chess/util/gamefileutility.js';\nimport { players as p } from '../../../../../shared/chess/util/typeutil.js';\n\nimport area from '../rendering/area.js';\nimport board from '../rendering/boardtiles.js';\nimport arrows from '../rendering/arrows/arrows.js';\nimport meshes from '../rendering/meshes.js';\nimport { gl } from '../rendering/webgl.js';\nimport boardpos from '../rendering/boardpos.js';\nimport guiclock from '../gui/guiclock.js';\nimport drawrays from '../rendering/highlights/annotations/drawrays.js';\nimport copygame from './copygame.js';\nimport miniimage from '../rendering/miniimage.js';\nimport pastegame from './pastegame.js';\nimport gamesound from '../misc/gamesound.js';\nimport starfield from '../rendering/starfield.js';\nimport imagecache from '../../chess/rendering/imagecache.js';\nimport Transition from '../rendering/transitions/Transition.js';\nimport gameloader from './gameloader.js';\nimport piecemodels from '../rendering/piecemodels.js';\nimport guigameinfo from '../gui/guigameinfo.js';\nimport drawsquares from '../rendering/highlights/annotations/drawsquares.js';\nimport perspective from '../rendering/perspective.js';\nimport { GameBus } from '../GameBus.js';\nimport preferences from '../../components/header/preferences.js';\nimport guipromotion from '../gui/guipromotion.js';\nimport movesequence from './movesequence.js';\nimport texturecache from '../../chess/rendering/texturecache.js';\nimport guinavigation from '../gui/guinavigation.js';\nimport { animateMove } from './graphicalchanges.js';\n\n// Types ---------------------------------------------------------------------\n\n/** Options for loading a game. */\ninterface LoadOptions {\n\t/** The metadata of the game */\n\tmetadata: MetaData;\n\t/** The variant code. Pass null for custom/unknown positions. */\n\tvariant: VariantCode | null;\n\t/** The game's start timestamp in milliseconds since epoch. */\n\tdateTimestamp: number;\n\t/** True if we should be viewing the game from white's perspective, false for black's perspective. */\n\tviewWhitePerspective: boolean;\n\t/** Whether the coordinate field box should be editable. */\n\tallowEditCoords: boolean;\n\t/** Preset ray overrides for the variant's rays. */\n\tpresetAnnotes?: PresetAnnotes;\n\tadditional?: Additional;\n}\n\n// Variables ---------------------------------------------------------------\n\n/** The currently loaded game. */\nlet loadedGamefile: FullGame | undefined;\n\n/** The mesh of the gamefile, if it is loaded. */\nlet mesh: Mesh | undefined;\n\n/** The player color we are viewing the perspective of in the current loaded game. */\nlet youAreColor: Player;\n\n/**\n * The timeout id of the timer that animates the latest-played\n * move when rejoining a game, after a short delay\n */\nlet animateLastMoveTimeoutID: ReturnType<typeof setTimeout> | undefined;\n/**\n * The delay, in millis, until the latest-played\n * move is animated, after rejoining a game.\n */\nconst delayOfLatestMoveAnimationOnRejoinMillis = 150;\n\n// Functions ---------------------------------------------------------------\n\n/**  Returns the gamefile currently loaded */\nfunction getGamefile(): FullGame | undefined {\n\treturn loadedGamefile;\n}\n\n/** Returns the mesh of the gamefile currently loaded */\nfunction getMesh(): Mesh | undefined {\n\treturn mesh;\n}\n\nfunction areInGame(): boolean {\n\treturn loadedGamefile !== undefined;\n}\n\nfunction isLoadedGameViewingWhitePerspective(): boolean {\n\tif (!loadedGamefile)\n\t\tthrow Error(\n\t\t\t\"Cannot ask if loaded game is from white's perspective when there isn't a loaded game.\",\n\t\t);\n\treturn youAreColor === p.WHITE;\n}\n\n/**\n * Loads a gamefile onto the board.\n *\n * This loads the logical stuff first, then returns a PROMISE that resolves\n * when the GRAPHICAL stuff is finished loading (such as piece textures).\n */\nfunction loadGamefile(loadOptions: LoadOptions): Promise<void> {\n\tif (loadedGamefile) throw new Error('Must unloadGame() before loading a new one.');\n\t// console.log(\"Loading gamefile...\");\n\n\t// console.log('Started loading game...');\n\n\t// The game should be considered loaded once the LOGICAL stuff is finished,\n\t// but the loading animation should only be closed when\n\t// both the LOGICAL and GRAPHICAL stuff are finished.\n\n\t// First load the LOGICAL stuff...\n\tloadLogical(loadOptions);\n\t// console.log('Finished loading LOGICAL game stuff.');\n\n\t// Play the start game sound once LOGICAL stuff is finished loading,\n\t// so that the sound will still play in chrome, with the tab hidden, and\n\t// someone accepts your invite. (In that scenario, the graphical loading is blocked)\n\tgamesound.playGamestart();\n\n\t/**\n\t * Next start loading the GRAPHICAL stuff...\n\t *\n\t * This returns a promise that resolves when it's fully loaded,\n\t * since the graphics loading is asynchronious.\n\t */\n\treturn loadGraphical(loadOptions);\n}\n\n/** Loads all of the logical components of a game */\nfunction loadLogical(loadOptions: LoadOptions): void {\n\tloadedGamefile = gamefile.initFullGame(\n\t\tloadOptions.metadata,\n\t\tloadOptions.dateTimestamp,\n\t\tloadOptions.variant,\n\t\tloadOptions.additional,\n\t);\n\n\tyouAreColor = loadOptions.viewWhitePerspective ? p.WHITE : p.BLACK;\n\n\tconst pieceCount = boardutil.getPieceCountOfGame(loadedGamefile.boardsim.pieces);\n\t// Disable miniimages if there's too many pieces\n\tif (pieceCount > miniimage.pieceCountToDisableMiniImages) miniimage.disable();\n\t// Disable arrows if there's too many pieces or lines in the game\n\tif (\n\t\tpieceCount > arrows.MAX_PIECES ||\n\t\tloadedGamefile.boardsim.pieces.slides.length > arrows.MAX_LINES\n\t)\n\t\tarrows.setMode(0);\n\n\tinitCopyPastGameListeners();\n\n\t// If custom preset rays are specified, initiate them in drawrays.ts\n\tif (loadOptions.presetAnnotes?.squares)\n\t\tdrawsquares.setPresetOverrides(loadOptions.presetAnnotes.squares);\n\tif (loadOptions.presetAnnotes?.rays)\n\t\tdrawrays.setPresetOverrides(loadOptions.presetAnnotes.rays);\n\n\tGameBus.dispatch('game-loaded');\n}\n\n/** Loads all of the graphical components of a game */\nasync function loadGraphical(loadOptions: LoadOptions): Promise<void> {\n\t// Opening the guinavigation needs to be done in gameslot.ts instead of gameloader.ts so pasting games still opens it\n\tguinavigation.open({ allowEditCoords: loadOptions.allowEditCoords }); // Editing your coords allowed in local games\n\tguiclock.set(loadedGamefile!.basegame);\n\tperspective.resetRotations(loadOptions.viewWhitePerspective);\n\n\tawait imagecache.initImagesForGame(loadedGamefile!.boardsim);\n\ttexturecache.initTexturesForGame(gl, loadedGamefile!.boardsim);\n\n\t// MUST BE AFTER imagecache.initImagesForGame(), as we need SVGs fetched before then.\n\tguipromotion.initUI(loadedGamefile!.basegame.gameRules.promotionsAllowed);\n\n\t// Rewind one move so that we can, after a short delay, animate the most recently played move.\n\tconst lastmove = moveutil.getLastMove(loadedGamefile!.boardsim.moves);\n\tif (lastmove !== undefined) movepiece.applyMove(loadedGamefile!, lastmove, false); // Rewind one move\n\n\t// Initialize the mesh empty\n\tmesh = {\n\t\toffset: [0n, 0n],\n\t\tinverted: false,\n\t\ttypes: {},\n\t};\n\n\t// Generate the mesh of every piece type\n\tpiecemodels.regenAll(loadedGamefile!.boardsim, mesh);\n\n\t// NEEDS TO BE AFTER generating the mesh, since this makes a graphical change.\n\tif (lastmove !== undefined)\n\t\tanimateLastMoveTimeoutID = setTimeout(() => {\n\t\t\t// A small delay to animate the most recently played move.\n\t\t\tif (moveutil.areWeViewingLatestMove(loadedGamefile!.boardsim)) return; // Already viewing the lastest move\n\t\t\tmovesequence.viewFront(loadedGamefile!, mesh!); // Updates to front even when they view different moves\n\t\t\tanimateMove(lastmove.changes, true);\n\t\t}, delayOfLatestMoveAnimationOnRejoinMillis);\n\n\t// Init the star field void animation\n\tstarfield.init();\n}\n\n/** The canvas will no longer render the current game */\nfunction unloadGame(): void {\n\tif (!loadedGamefile)\n\t\tthrow Error('Should not be calling to unload game when there is no game loaded.');\n\t// console.error(\"Unloading gamefile...\");\n\n\tloadedGamefile = undefined;\n\tmesh = undefined;\n\n\tremoveCopyPasteGameListeners();\n\n\t// Stop the timer that (animates the latest-played move when rejoining a game after a short delay)\n\tclearTimeout(animateLastMoveTimeoutID);\n\tanimateLastMoveTimeoutID = undefined;\n\n\tGameBus.dispatch('game-unloaded');\n}\n\n/**\n * Sets the camera to the recentered position, plus a little zoomed in.\n * THEN transitions to normal zoom.\n */\nfunction startStartingTransition(): void {\n\tconst boxFloating = meshes.expandTileBoundingBoxToEncompassWholeSquare(\n\t\tloadedGamefile!.boardsim.startSnapshot.box,\n\t);\n\tconst centerArea = area.calculateFromUnpaddedBox(boxFloating);\n\tboardpos.setBoardPos(centerArea.coords);\n\tconst INITIAL_ZOOM_MULTIPLIER = preferences.getFastTransitionsMode() ? 1.4 : 1.75; // We start 1.75x zoomed in then normal, then transition into 1x\n\tconst startScale = bd.multiply(centerArea.scale, bd.fromNumber(INITIAL_ZOOM_MULTIPLIER));\n\tboardpos.setBoardScale(startScale);\n\tguinavigation.recenter();\n\tTransition.eraseTelHist();\n}\n\n/** Called when a game is loaded, loads the event listeners for when we are in a game. */\nfunction initCopyPastGameListeners(): void {\n\tdocument.addEventListener('copy', callbackCopy);\n\tdocument.addEventListener('paste', pastegame.callbackPaste);\n\tdocument.addEventListener('copy-game', callbackCopy);\n\tdocument.addEventListener('paste-game', pastegame.callbackPaste);\n}\n\n/** Called when a game is unloaded, closes the event listeners for being in a game. */\nfunction removeCopyPasteGameListeners(): void {\n\tdocument.removeEventListener('copy', callbackCopy);\n\tdocument.removeEventListener('paste', pastegame.callbackPaste);\n\tdocument.removeEventListener('copy-game', callbackCopy);\n\tdocument.removeEventListener('paste-game', pastegame.callbackPaste);\n}\n\nfunction callbackCopy(_event: Event): void {\n\tif (document.activeElement instanceof HTMLInputElement) return; // Don't copy if the user is typing in an input field\n\tif (window.getSelection()?.toString()) return; // Don't copy if the user has text selected in the UI\n\tcopygame.copyGame(false);\n}\n\n/**\n * Ends the game. Call this when the game is over by the used win condition.\n * Stops the clocks, darkens the board, displays who won, plays a sound effect.\n */\nfunction concludeGame(): void {\n\tif (!loadedGamefile) throw Error(\"Cannot conclude game when there isn't one loaded\");\n\tconst basegame = loadedGamefile.basegame;\n\tif (basegame.gameConclusion === undefined)\n\t\tthrow Error(\"Cannot conclude game when the game hasn't ended.\");\n\n\tclock.endGame(basegame);\n\tguiclock.stopClocks(basegame);\n\tguigameinfo.gameEnd(basegame.gameConclusion);\n\n\tGameBus.dispatch('game-concluded');\n\n\tconst victor = basegame.gameConclusion.victor; // undefined if aborted, null if draw\n\tconst delayToPlayConcludeSoundSecs = 0.65;\n\tif (gameloader.areInLocalGame()) {\n\t\tif (victor !== null && victor !== undefined) {\n\t\t\tgamesound.playWin(delayToPlayConcludeSoundSecs);\n\t\t} else {\n\t\t\tgamesound.playDraw(delayToPlayConcludeSoundSecs);\n\t\t}\n\t} else {\n\t\t// In online game or engine game\n\t\tconst ourRole = gameloader.getOurColor()!;\n\t\tif (victor === ourRole) gamesound.playWin(delayToPlayConcludeSoundSecs);\n\t\telse if (victor === null || victor === undefined)\n\t\t\tgamesound.playDraw(delayToPlayConcludeSoundSecs);\n\t\telse gamesound.playLoss(delayToPlayConcludeSoundSecs);\n\t}\n}\n\n/** Undoes the conclusion of the game. */\nfunction unConcludeGame(): void {\n\tgamefileutility.setConclusion(loadedGamefile!.basegame, undefined);\n\tboard.resetColor();\n}\n\nexport default {\n\tgetGamefile,\n\tgetMesh,\n\tareInGame,\n\tisLoadedGameViewingWhitePerspective,\n\tloadGamefile,\n\tunloadGame,\n\tstartStartingTransition,\n\tconcludeGame,\n\tunConcludeGame,\n};\n\nexport type { PresetAnnotes, Additional };\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/graphicalchanges.ts",
    "content": "// src/client/scripts/esm/game/chess/graphicalchanges.ts\n\n/**\n * This script contains the functions that know what mesh changes to make,\n * and what animations to make, according to each action of a move's actions list.\n */\n\nimport type { Mesh } from '../rendering/piecemodels.js';\nimport type { Piece } from '../../../../../shared/chess/util/boardutil.js';\nimport type { Coords } from '../../../../../shared/chess/util/coordutil.js';\nimport type {\n\tChangeApplication,\n\tChange,\n\tgenericChangeFunc,\n} from '../../../../../shared/chess/logic/boardchanges.js';\n\nimport animation from '../rendering/animation.js';\nimport piecemodels from '../rendering/piecemodels.js';\nimport preferences from '../../components/header/preferences.js';\n\n// Types ----------------------------------------------------------------------------------------------------\n\n/**\n * An object mapping move changes to a function that performs the graphical mesh change for that action.\n */\nconst meshChanges: ChangeApplication<genericChangeFunc<Mesh>> = {\n\tforward: {\n\t\tadd: addMeshPiece,\n\t\tdelete: deleteMeshPiece,\n\t\tmove: moveMeshPiece,\n\t\tcapture: deleteMeshPiece,\n\t},\n\tbackward: {\n\t\tdelete: addMeshPiece,\n\t\tadd: deleteMeshPiece,\n\t\tmove: returnMeshPiece,\n\t\tcapture: addMeshPiece,\n\t},\n};\n\n// Mesh Changes -----------------------------------------------------------------------------------------\n\nfunction addMeshPiece(mesh: Mesh, change: Change): void {\n\tpiecemodels.overwritebufferdata(mesh, change.piece);\n}\n\nfunction deleteMeshPiece(mesh: Mesh, change: Change): void {\n\tpiecemodels.deletebufferdata(mesh, change.piece);\n}\n\nfunction moveMeshPiece(mesh: Mesh, change: Change): void {\n\tif (change.action !== 'move')\n\t\tthrow Error(`moveMeshPiece called with non-move action: ${change.action}`);\n\tpiecemodels.overwritebufferdata(mesh, {\n\t\ttype: change.piece.type,\n\t\tcoords: change.endCoords,\n\t\tindex: change.piece.index,\n\t});\n}\n\nfunction returnMeshPiece(mesh: Mesh, change: Change): void {\n\tpiecemodels.overwritebufferdata(mesh, change.piece);\n}\n\n// Animate -----------------------------------------------------------------------------------------\n\n/**\n * Animates a given set of changes to the board.\n * We don't use boardchanges because a custom compositor is needed.\n * @param moveChanges - the changes to animate\n * @param forward - whether this is a forward or back animation\n * @param animateMain - Whether the main piece targeted by the move should be animated. All secondary pieces are guaranteed animated. If this is false, the main piece animation will be instantanious, only playing the SOUND.\n * @param premove - Whether this animation is for a premove.\n * @param force_instant - Whether to FORCE instant animation, EVEN secondary pieces won't be animated. Enable when you are playing a premove in the game.\n */\nfunction animateMove(\n\tmoveChanges: Change[],\n\tforward = true,\n\tanimateMain = true,\n\tpremove = false,\n\tforce_instant = false,\n): void {\n\tlet clearanimations = true; // The first animation of a turn should clear prev turns animation\n\n\t// Helper function for pushing an item to an array in a map, creating the array if it does not exist.\n\tfunction pushToArrayMap<K, V>(map: Map<K, V[]>, key: K, apple: V): void {\n\t\tlet t = map.get(key);\n\t\tif (!t) {\n\t\t\tt = [];\n\t\t\tmap.set(key, t);\n\t\t}\n\t\tt.push(apple);\n\t}\n\n\tlet showKeyframes: Map<number, Piece[]> = new Map();\n\tlet hideKeyframes: Map<number, Piece[]> = new Map();\n\tfor (const change of moveChanges) {\n\t\tif (change.action === 'capture') {\n\t\t\t// Queue all captures to be associated with the next move\n\t\t\tpushToArrayMap(showKeyframes, change.order, change.piece);\n\t\t} else if (change.action === 'move') {\n\t\t\tconst instant =\n\t\t\t\t(change.main && !animateMain) || !preferences.getAnimationsMode() || force_instant; // Whether the animation should be instantanious, only playing the SOUND.\n\t\t\tlet waypoints = change.path ?? [change.piece.coords, change.endCoords];\n\n\t\t\t// Put all pieces captured last in the last keyframe\n\t\t\tconst last = waypoints.length - 1;\n\t\t\tconst lastDef = showKeyframes.get(last);\n\t\t\tconst assumeLast = showKeyframes.get(-1);\n\t\t\tshowKeyframes.delete(-1);\n\t\t\tif ((lastDef === undefined) !== (assumeLast === undefined)) {\n\t\t\t\tshowKeyframes.set(last, (lastDef ?? assumeLast)!); // Only one is defined\n\t\t\t} else if (lastDef !== undefined) {\n\t\t\t\tshowKeyframes.set(last, [...lastDef, ...assumeLast!]);\n\t\t\t} // Don't need to do anything\n\n\t\t\t// Flip those being hidden and those being shown if it is a reverse move\n\t\t\tif (!forward) {\n\t\t\t\twaypoints = waypoints.slice().reverse();\n\t\t\t\t// Helper that inverts orders at the start of the path to the end, and vice versa.\n\t\t\t\t// x remains the same, but y is set to the inverted x.\n\t\t\t\tfunction invert<V>(x: Map<number, V>, y: Map<number, V>): void {\n\t\t\t\t\ty.clear();\n\t\t\t\t\tx.forEach((v, k) => {\n\t\t\t\t\t\ty.set(last - k, v);\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tconst t = new Map<number, Piece[]>();\n\t\t\t\tinvert(showKeyframes, t);\n\t\t\t\tinvert(hideKeyframes, showKeyframes);\n\t\t\t\thideKeyframes = t;\n\t\t\t}\n\n\t\t\t// Prune those that will never be seen\n\t\t\thideKeyframes.delete(0);\n\t\t\tshowKeyframes.delete(0);\n\n\t\t\t// Convert hideKeyframes to a Coords[] array, as the animation function expects this.\n\t\t\tconst newHideFrames: Map<number, Coords[]> = new Map();\n\t\t\tfor (const [k, v] of hideKeyframes)\n\t\t\t\tnewHideFrames.set(\n\t\t\t\t\tk,\n\t\t\t\t\tv.map((p) => p.coords),\n\t\t\t\t); // Mutate to remove unnessacary info\n\n\t\t\t// Hide where the moved piece is actually\n\t\t\tpushToArrayMap(newHideFrames, last, waypoints[last]);\n\n\t\t\tanimation.animatePiece(\n\t\t\t\tchange.piece.type,\n\t\t\t\twaypoints,\n\t\t\t\tshowKeyframes,\n\t\t\t\tnewHideFrames,\n\t\t\t\tinstant,\n\t\t\t\tclearanimations,\n\t\t\t\tpremove,\n\t\t\t);\n\n\t\t\tshowKeyframes = new Map();\n\t\t\thideKeyframes.clear();\n\t\t\tclearanimations = false;\n\t\t}\n\t}\n}\n\nexport { animateMove, meshChanges };\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/movesequence.ts",
    "content": "// src/client/scripts/esm/game/chess/movesequence.ts\n\n/**\n * This is a client-side script that executes global and local moves,\n * making both the logical, and graphical changes.\n *\n * We also have the animate move method here.\n */\n\nimport type { FullGame } from '../../../../../shared/chess/logic/gamefile.js';\nimport type { Edit, MoveFull, MoveTagged } from '../../../../../shared/chess/logic/movepiece.js';\n\nimport clock from '../../../../../shared/chess/logic/clock.js';\nimport moveutil from '../../../../../shared/chess/util/moveutil.js';\nimport movepiece from '../../../../../shared/chess/logic/movepiece.js';\nimport boardchanges from '../../../../../shared/chess/logic/boardchanges.js';\nimport gamefileutility from '../../../../../shared/chess/util/gamefileutility.js';\n\nimport stats from '../gui/stats.js';\nimport gameslot from './gameslot.js';\nimport guiclock from '../gui/guiclock.js';\nimport { Mesh } from '../rendering/piecemodels.js';\nimport premoves from './premoves.js';\nimport animation from '../rendering/animation.js';\nimport onlinegame from '../misc/onlinegame/onlinegame.js';\nimport enginegame from '../misc/enginegame.js';\nimport piecemodels from '../rendering/piecemodels.js';\nimport guigameinfo from '../gui/guigameinfo.js';\nimport { GameBus } from '../GameBus.js';\nimport frametracker from '../rendering/frametracker.js';\nimport guinavigation from '../gui/guinavigation.js';\nimport { animateMove, meshChanges } from './graphicalchanges.js';\n\n// Global Moving ----------------------------------------------------------------------------------------------------------\n\n/**\n * Makes a global forward move in the game.\n *\n * This returns the constructed MoveFull object so that we have the option to animate it if we so choose.\n */\nfunction makeMove(\n\tgamefile: FullGame,\n\tmesh: Mesh | undefined,\n\tmoveTagged: MoveTagged,\n\t{ doGameOverChecks = true } = {},\n): MoveFull {\n\tconst { basegame, boardsim } = gamefile;\n\tconst move = movepiece.generateMove(gamefile, moveTagged);\n\n\tmovepiece.makeMove(gamefile, move); // Logical changes\n\n\tif (mesh) runMeshChanges(boardsim, mesh, move, true);\n\n\t// GUI changes\n\tupdateGui(false);\n\n\tif (!onlinegame.areInOnlineGame() && !gamefile.basegame.untimed) {\n\t\tconst clockStamp_ = clock.push(basegame, basegame.clocks!);\n\t\tguiclock.push(basegame.clocks!);\n\t\t// Add the clock stamp to the move\n\t\tif (clockStamp_ !== undefined) move.clockStamp = clockStamp_;\n\t}\n\n\tif (doGameOverChecks) {\n\t\tgamefileutility.doGameOverChecks(gamefile);\n\t\t// Only conclude the game if it's not an online game (in that scenario, server is boss)\n\t\tif (gamefileutility.isGameOver(basegame) && !onlinegame.areInOnlineGame())\n\t\t\tgameslot.concludeGame();\n\t}\n\n\tGameBus.dispatch('physical-move');\n\n\treturn move;\n}\n\n/** Convenience wrapper: Makes a global forward move then animates it if the mesh exists. */\nfunction makeMoveAndAnimate(\n\tgamefile: FullGame,\n\tmesh: Mesh | undefined,\n\tmoveTagged: MoveTagged,\n\t{ doGameOverChecks = true } = {},\n): MoveFull {\n\tconst move = makeMove(gamefile, mesh, moveTagged, { doGameOverChecks });\n\tif (mesh) animateMove(move.changes, true);\n\treturn move;\n}\n\n/**\n * Wrapper for performing the graphical mesh changes of an edit.\n *\n * If the newlyRegenerated flag is present, indicating the organized pieces were regenerated,\n * than we instead need to regenerate all piece models.\n * Otherwise, we run graphical changes as normal.\n *\n * We have to regenerate ALL types here, not just the ones whos type ranges\n * were affected, because other pieces may still need graphical changes\n * from the move's changes! For example, pawn deleted that promoted.\n */\nfunction runMeshChanges(\n\tboardsim: FullGame['boardsim'],\n\tmesh: Mesh,\n\tedit: Edit,\n\tforward: boolean,\n): void {\n\tif (boardsim.pieces.newlyRegenerated) piecemodels.regenAll(boardsim, mesh);\n\telse boardchanges.runChanges(mesh, edit.changes, meshChanges, forward); // Graphical changes\n\tframetracker.onVisualChange(); // Flag the next frame to be rendered, since we ran some graphical changes.\n}\n\n/**\n * Makes a global backward move in the game.\n */\nfunction rewindMove(gamefile: FullGame, mesh: Mesh | undefined): void {\n\t// Terminate all current animations to avoid a crash when undoing moves\n\tanimation.clearAnimations();\n\t// movepiece.rewindMove() deletes the move, so we need to keep a reference here.\n\tconst lastMove = moveutil.getLastMove(gamefile.boardsim.moves)!;\n\tmovepiece.rewindMove(gamefile); // Logical changes\n\tif (mesh) boardchanges.runChanges(mesh, lastMove.changes, meshChanges, false); // Graphical changes\n\tframetracker.onVisualChange(); // Flag the next frame to be rendered, since we ran some graphical changes.\n\t// Un-conclude the game if it was concluded\n\tif (gamefileutility.isGameOver(gamefile.basegame)) gameslot.unConcludeGame();\n\tupdateGui(false); // GUI changes\n\n\tpremoves.cancelPremoves(gamefile, mesh); // Any move change invalidates all premoves.\n}\n\n// Local Moving ----------------------------------------------------------------------------------------------------------\n\n/**\n * Apply the move to the board state and the mesh, whether forward or backward,\n * as if we were wanting to *view* the move, instead of making it.\n *\n * This does not change the game state, for example, whos turn it is,\n * what square enpassant is legal on, or the running count of checks given.\n *\n * But it does change the check state.\n */\nfunction viewMove(\n\tgamefile: FullGame,\n\tmesh: Mesh | undefined,\n\tmove: MoveFull,\n\tforward = true,\n): void {\n\tmovepiece.applyMove(gamefile, move, forward); // Apply the logical changes.\n\tif (mesh) {\n\t\tboardchanges.runChanges(mesh, move.changes, meshChanges, forward); // Apply the graphical changes.\n\t\tframetracker.onVisualChange(); // Flag the next frame to be rendered, since we ran some graphical changes.\n\t}\n\tenginegame.onViewMove();\n}\n\n/**\n * Makes the game view a set move index\n * @param index the move index to goto\n */\nfunction viewIndex(gamefile: FullGame, mesh: Mesh | undefined, index: number): void {\n\tmovepiece.goToMove(gamefile.boardsim, index, (move: MoveFull) =>\n\t\tviewMove(gamefile, mesh, move, index >= gamefile.boardsim.state.local.moveIndex),\n\t);\n\tupdateGui(false);\n}\n\n/**\n * Makes the game view the last move\n */\nfunction viewFront(gamefile: FullGame, mesh: Mesh | undefined): void {\n\t/** Call {@link viewIndex} with the index of the last move in the game */\n\tviewIndex(gamefile, mesh, gamefile.boardsim.moves.length - 1);\n}\n\n/**\n * Called when we hit the left/right arrows keys,\n * or click the rewind/forward move buttons.\n *\n * This VIEWS the next move, whether forward or backward,\n * makes the graphical (mesh) changes, animates it, and updates the GUI.\n *\n * ASSUMES that it is legal to navigate in the direction.\n */\nfunction navigateMove(gamefile: FullGame, mesh: Mesh | undefined, forward: boolean): void {\n\tconst { boardsim } = gamefile;\n\n\t// Determine the index of the move to apply\n\tconst idx = forward ? boardsim.state.local.moveIndex + 1 : boardsim.state.local.moveIndex;\n\n\t// Make sure the move exists. Normally we'd never call this method\n\t// if it does, but just in case we forget to check.\n\tconst move = boardsim.moves[idx];\n\tif (move === undefined)\n\t\tthrow Error(`Move is undefined. Should not be navigating move. forward: ${forward}`);\n\n\tviewMove(gamefile, mesh, move, forward); // Apply the logical + graphical changes\n\tanimateMove(move.changes, forward); // Animate\n\tupdateGui(true);\n}\n\n/**\n * Updates the display of whos turn it is (if it changed),\n * the transparency of the rewind/forward move buttons,\n * updates the move number below the move buttons.\n * @param showMoveCounter Whether to show the move counter below the move buttons in the navigation bar.\n */\nfunction updateGui(showMoveCounter: boolean): void {\n\tif (showMoveCounter) stats.showMoves();\n\telse stats.updateTextContentOfMoves(); // While we may not be OPENING the move counter, if it WAS already open we should still update the number!\n\tguinavigation.update_MoveButtons();\n\tguigameinfo.updateWhosTurn();\n}\n\n// --------------------------------------------------------------------------------------------------------------------------\n\nexport default {\n\tnavigateMove,\n\tmakeMove,\n\tmakeMoveAndAnimate,\n\trewindMove,\n\tviewMove,\n\tviewFront,\n\tviewIndex,\n\trunMeshChanges,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/pastegame.ts",
    "content": "// src/client/scripts/esm/game/chess/pastegame.ts\n\n/**\n * This script handles pasting games\n */\n\nimport type { MetaData } from '../../../../../shared/types.js';\nimport type { CoordsKey } from '../../../../../shared/chess/util/coordutil.js';\nimport type { Additional } from '../../../../../shared/chess/logic/gamefile.js';\nimport type { MovePacket } from '../../../../../shared/types.js';\nimport type { VariantCode } from '../../../../../shared/chess/variants/variantdictionary.js';\nimport type { MetadataKey } from '../../../../../shared/chess/util/metadatautil.js';\nimport type { VariantOptions } from '../../../../../shared/chess/logic/initvariant.js';\n\nimport variant from '../../../../../shared/chess/variants/variant.js';\nimport timeutil from '../../../../../shared/util/timeutil.js';\nimport boardutil from '../../../../../shared/chess/util/boardutil.js';\nimport { pieceCountToDisableCheckmate } from '../../../../../shared/chess/logic/checkmate.js';\nimport icnconverter, {\n\tMoveParsed,\n\tLongFormatOut,\n} from '../../../../../shared/chess/logic/icn/icnconverter.js';\n\nimport toast from '../gui/toast.js';\nimport IndexedDB from '../../util/IndexedDB.js';\nimport onlinegame from '../misc/onlinegame/onlinegame.js';\nimport enginegame from '../misc/enginegame.js';\nimport gameloader from './gameloader.js';\nimport boardeditor from '../boardeditor/boardeditor.js';\nimport socketmessages from '../websocket/socketmessages.js';\nimport clientmetadatautil from './clientmetadatautil.js';\nimport gameslot, { PresetAnnotes } from './gameslot.js';\n\n/**\n * A list of metadata properties that are retained from the current game when pasting an external game.\n * These will overwrite the pasted game's metadata with the current game's metadata.\n */\nconst retainMetadataWhenPasting: MetadataKey[] = [\n\t'White',\n\t'Black',\n\t'WhiteID',\n\t'BlackID',\n\t'WhiteElo',\n\t'BlackElo',\n\t'WhiteRatingDiff',\n\t'BlackRatingDiff',\n\t'TimeControl',\n\t'Event',\n\t'Site',\n\t'Round',\n];\n/** The pasted game will refuse to override these unless specified explicitly. This prevents them from just being deleted.\n * It means if the pasted game doesn't have these properties, we fall back to the current game's properties. */\nconst retainIfNotOverridden: MetadataKey[] = ['UTCDate', 'UTCTime'];\n\n/**\n * Pastes the clipboard ICN to the current game.\n * This callback is called when the \"Paste Game\" button is pressed.\n * @param event - The event fired from the event listener\n */\nasync function callbackPaste(_event: Event): Promise<void> {\n\tif (boardeditor.areInBoardEditor()) return; // Editor has its own handler\n\n\tif (document.activeElement instanceof HTMLInputElement) return; // Don't paste if the user is typing in an input field\n\n\t// Can't paste a game when the current gamefile isn't finished loading all the way.\n\tif (gameloader.areWeLoadingGame()) return toast.showPleaseWaitForTask();\n\n\t// Make sure we're not in a public match\n\tif (onlinegame.areInOnlineGame()) {\n\t\tif (!onlinegame.getIsPrivate())\n\t\t\treturn toast.show(translations.copypaste.cannot_paste_in_public);\n\t\tif (onlinegame.isRated()) return toast.show(translations.copypaste.cannot_paste_in_rated);\n\t}\n\t// Make sure we're not in an engine match\n\tif (enginegame.areInEngineGame())\n\t\treturn toast.show(translations.copypaste.cannot_paste_in_engine);\n\t// Make sure it's legal in a private match\n\tif (\n\t\tonlinegame.areInOnlineGame() &&\n\t\tonlinegame.getIsPrivate() &&\n\t\tgameslot.getGamefile()!.boardsim.moves.length > 0\n\t)\n\t\treturn toast.show(translations.copypaste.cannot_paste_after_moves);\n\n\t// Do we have clipboard permission?\n\tlet clipboard: string;\n\ttry {\n\t\tclipboard = await navigator.clipboard.readText();\n\t} catch (error) {\n\t\tconst message: string = translations.copypaste.clipboard_denied;\n\t\treturn toast.show(message + '\\n' + error, { error: true });\n\t}\n\n\t// Convert clipboard text to object\n\tlet longformOut: LongFormatOut;\n\ttry {\n\t\tlongformOut = icnconverter.ShortToLong_Format(clipboard);\n\t} catch (e) {\n\t\tconsole.error(e);\n\t\ttoast.show(translations.copypaste.clipboard_invalid, { error: true });\n\t\treturn;\n\t}\n\n\t// console.log(jsutil.deepCopyObject(longformOut));\n\n\tpasteGame(longformOut);\n\n\t// Let the server know if we pasted a custom position in a private match\n\tif (onlinegame.areInOnlineGame() && onlinegame.getIsPrivate())\n\t\tsocketmessages.send('game', 'paste');\n}\n\n/**\n * Loads a game from the provided game in longformat.\n *\n * TODO: REMOVE A LOT OF THE REDUNDANT LOGIC BETWEEN\n * THIS FUNCTION AND gameforulator.formulateGame()!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n *\n * @param longformOut - The game in longformat, or primed for copying. This is NOT the gamefile, we'll need to use the gamefile constructor.\n * @returns Whether the paste was successful\n */\nfunction pasteGame(longformOut: LongFormatOut): void {\n\tconsole.log('Pasting game...');\n\n\t// Create a new gamefile from the longformat...\n\n\t// Retain most of the existing metadata on the currently loaded gamefile\n\tconst currentGamefile = gameslot.getGamefile()!;\n\tconst currentGameMetadata = currentGamefile.basegame.metadata;\n\tretainMetadataWhenPasting.forEach((metadataName) => {\n\t\tdelete longformOut.metadata[metadataName];\n\t\tif (currentGameMetadata[metadataName] !== undefined)\n\t\t\tclientmetadatautil.copyMetadataField(\n\t\t\t\tlongformOut.metadata,\n\t\t\t\tcurrentGameMetadata,\n\t\t\t\tmetadataName,\n\t\t\t);\n\t});\n\n\tfor (const metadataName of retainIfNotOverridden) {\n\t\tif (currentGameMetadata[metadataName] && !longformOut.metadata[metadataName])\n\t\t\tclientmetadatautil.copyMetadataField(\n\t\t\t\tlongformOut.metadata,\n\t\t\t\tcurrentGameMetadata,\n\t\t\t\tmetadataName,\n\t\t\t);\n\t}\n\n\t// Resolve variant code from the ICN metadata, normalizing it to the English display name.\n\tconst resolvedVariantCode = variant.resolveAndNormalizeVariantInMetadata(longformOut.metadata);\n\n\tconst timestamp = clientmetadatautil.resolveTimestampFromMetadata(\n\t\tlongformOut.metadata.UTCDate,\n\t\tlongformOut.metadata.UTCTime,\n\t);\n\tconst { position, specialRights } = getPositionAndSpecialRightsFromLongFormat(\n\t\tlongformOut,\n\t\tresolvedVariantCode,\n\t\ttimestamp,\n\t);\n\n\t// The variant options passed into the variant loader needs to contain the following properties:\n\t// `fullMove`, `enpassant`, `moveRuleState`, `position`, `specialRights`, `gameRules`.\n\tconst variantOptions: VariantOptions = {\n\t\tfullMove: longformOut.fullMove,\n\t\tgameRules: longformOut.gameRules,\n\t\tposition,\n\t\tstate_global: {\n\t\t\t...longformOut.state_global,\n\t\t\tspecialRights,\n\t\t},\n\t};\n\n\tif (onlinegame.areInOnlineGame() && onlinegame.getIsPrivate()) {\n\t\t// Playing a custom private game! Save the pasted position in browser\n\t\t// storage so that we can remember it upon refreshing.\n\t\tconst gameID = onlinegame.getGameID();\n\t\tconst storageKey = onlinegame.getKeyForOnlineGameVariantOptions(gameID);\n\t\tconst expiryMillis = timeutil.getTotalMilliseconds({ days: 3 });\n\t\tIndexedDB.saveItem(storageKey, variantOptions, expiryMillis);\n\t}\n\n\t// What is the warning message if pasting in a private match?\n\tconst privateMatchWarning: string =\n\t\tonlinegame.areInOnlineGame() && onlinegame.getIsPrivate()\n\t\t\t? ` ${translations.copypaste.pasting_in_private}`\n\t\t\t: '';\n\n\tconst additional: Additional = { variantOptions };\n\tif (longformOut.moves) {\n\t\t// Trim the excess properties from the MoveParsed type, including the comment.\n\t\tadditional.moves = longformOut.moves.map((m: MoveParsed) => {\n\t\t\tconst move: MovePacket = { token: m.token };\n\t\t\tif (m.clockStamp !== undefined) move.clockStamp = m.clockStamp;\n\t\t\t// Potentially also transfer the pasted comments into the gamefile here in the future!\n\t\t\t// ...\n\t\t\treturn move;\n\t\t});\n\t}\n\n\tconst options: {\n\t\tmetadata: MetaData;\n\t\tvariant: VariantCode | null;\n\t\tdateTimestamp: number;\n\t\tadditional: Additional;\n\t\tpresetAnnotes?: PresetAnnotes;\n\t} = {\n\t\tmetadata: longformOut.metadata,\n\t\tvariant: resolvedVariantCode,\n\t\tdateTimestamp: timestamp,\n\t\tadditional,\n\t};\n\tif (longformOut.presetAnnotes) options.presetAnnotes = longformOut.presetAnnotes;\n\n\tgameloader.pasteGame(options).then(() => {\n\t\t// This isn't accessible until gameloader.pasteGame() resolves its promise.\n\t\tconst gamefile = gameslot.getGamefile()!;\n\n\t\t// If there's too many pieces, notify them that the win condition has changed from checkmate to royalcapture.\n\t\tconst pieceCount = boardutil.getPieceCountOfGame(gamefile.boardsim.pieces);\n\t\tif (pieceCount >= pieceCountToDisableCheckmate) {\n\t\t\t// TOO MANY pieces!\n\t\t\ttoast.show(\n\t\t\t\t`${translations.copypaste.piece_count} ${pieceCount} ${translations.copypaste.exceeded} ${pieceCountToDisableCheckmate}! ${translations.copypaste.changed_wincon}${privateMatchWarning}`,\n\t\t\t\t{ durationMultiplier: 1.5 },\n\t\t\t);\n\t\t} else {\n\t\t\t// Only print \"Loaded game from clipboard.\" if we haven't already shown a different toast cause of too many pieces\n\t\t\ttoast.show(`${translations.copypaste.loaded_from_clipboard}${privateMatchWarning}`);\n\t\t}\n\t});\n\n\tconsole.log('Loaded game from clipboard!');\n}\n\n/**\n * Utility for extracting position and specialRights from a LongFormatOut.\n * @param longFormat - The parsed long format from ICN.\n * @param variantCode - The pre-resolved variant code (avoids re-resolving from metadata).\n * @param timestamp - The game's start timestamp in ms since epoch.\n */\nfunction getPositionAndSpecialRightsFromLongFormat(\n\tlongFormat: LongFormatOut,\n\tvariantCode: VariantCode | null,\n\ttimestamp: number,\n): {\n\tposition: Map<CoordsKey, number>;\n\tspecialRights: Set<CoordsKey>;\n} {\n\t// Get relevant position and specialRights information from longformat\n\tif (longFormat.position && longFormat.state_global.specialRights) {\n\t\treturn {\n\t\t\tposition: longFormat.position,\n\t\t\tspecialRights: longFormat.state_global.specialRights,\n\t\t};\n\t} else if (variantCode !== null) {\n\t\t// No position specified in the ICN, extract from the variant\n\t\treturn variant.getStartingPositionOfVariant(variantCode, timestamp);\n\t} else {\n\t\t// Empty position\n\t\treturn { position: new Map(), specialRights: new Set() };\n\t}\n}\n\nexport default {\n\tcallbackPaste,\n\tgetPositionAndSpecialRightsFromLongFormat,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/premoves.ts",
    "content": "// src/client/scripts/esm/game/chess/premoves.ts\n\n/**\n * This script handles the processing and execution of premoves\n * after the opponent's move.\n *\n * Premoves are handled client-side, not server side.\n */\n\nimport type { Mesh } from '../rendering/piecemodels.js';\nimport type { Color } from '../../../../../shared/util/math/math.js';\nimport type { FullGame } from '../../../../../shared/chess/logic/gamefile.js';\n\nimport typeutil from '../../../../../shared/chess/util/typeutil.js';\nimport boardutil from '../../../../../shared/chess/util/boardutil.js';\nimport coordutil from '../../../../../shared/chess/util/coordutil.js';\nimport legalmoves from '../../../../../shared/chess/logic/legalmoves.js';\nimport specialdetect from '../../../../../shared/chess/logic/specialdetect.js';\nimport movepiece, {\n\tCoordsTagged,\n\tEdit,\n\tMoveTagged,\n} from '../../../../../shared/chess/logic/movepiece.js';\n\nimport mouse from '../../util/mouse.js';\nimport boardpos from '../rendering/boardpos.js';\nimport gameslot from './gameslot.js';\nimport selection from './selection.js';\nimport animation from '../rendering/animation.js';\nimport { Mouse } from '../input.js';\nimport preferences from '../../components/header/preferences.js';\nimport { GameBus } from '../GameBus.js';\nimport movesequence from './movesequence.js';\nimport squarerendering from '../rendering/highlights/squarerendering.js';\nimport { animateMove } from './graphicalchanges.js';\n\n// Types --------------------------------------------------------\n\ninterface Premove extends Edit, MoveTagged {\n\t/** The type of piece moved */\n\ttype: number;\n}\n\n// Variables ----------------------------------------------------\n\n/** The list of all premoves we currently have, in order. */\nlet premoves: Premove[] = [];\n\n/**\n * Whether the premoves board and state changes have been applied to the board.\n * This is purely for DEBUGGING so you don't accidentally call these\n * methods at the wrong times.\n *\n * When premove's changes have to be reapplied, we have to recalculate all\n * of their changes, since for all we know they could end up capturing a\n * piece when they didn't when we originally premoved, or vice versa.\n *\n * THIS SHOULD ONLY TEMPORARILY ever be false!! If it is, it means we just\n * need to do something like calculating legal moves, then reapply the premoves.\n *\n * This can even be true when there's no premoves queued.\n */\nlet applied: boolean = true;\n\n// Events ----------------------------------------------------------------------------------\n\nGameBus.addEventListener('game-concluded', () => {\n\t// console.error(\"Game ended, clearing premoves\");\n\n\t// Erase pending premoves, leaving the `applied` state at what it was before\n\t// so the rest of the code doesn't experience it changed randomly.\n\n\tconst originalApplied = applied; // Save the original applied state\n\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh();\n\n\tif (applied) rewindPremoves(gamefile, mesh);\n\tclearPremoves();\n\n\t// Restore the original applied state, as the rest of the code will have expected it not to change.\n\tapplied = originalApplied;\n});\nGameBus.addEventListener('game-unloaded', () => {\n\tclearPremoves();\n});\n\n/** Event listener for when we change the Premoves toggle */\ndocument.addEventListener('premoves-toggle', (_e) => {\n\t// const enabled: boolean = _e.detail;\n\n\tconst gamefile = gameslot.getGamefile();\n\tconst mesh = gameslot.getMesh();\n\n\tif (!gamefile) return;\n\n\tcancelPremoves(gamefile, mesh);\n});\n\n// Processing Premoves ---------------------------------------------------------------------\n\n/** Gets all pending premoves. */\nfunction hasAtleastOnePremove(): boolean {\n\treturn premoves.length > 0;\n}\n\n/** Whether premove board changes are applied (can be true even when there's zero queued premoves) */\nfunction arePremovesApplied(): boolean {\n\treturn applied;\n}\n\n/** Similar to {@link movesequence.makeMove} Adds an premove and applies its changes to the board. */\nfunction addPremove(gamefile: FullGame, mesh: Mesh | undefined, moveTagged: MoveTagged): Premove {\n\t// console.log(\"Adding premove\");\n\n\tif (!applied) throw Error(\"Don't addPremove when other premoves are not applied!\");\n\n\tconst premove = generatePremove(gamefile, moveTagged);\n\n\tapplyPremove(gamefile, mesh, premove, true); // Apply the premove to the game state\n\n\tpremoves.push(premove);\n\t// console.log(premoves);\n\n\tGameBus.dispatch('physical-move');\n\n\treturn premove;\n}\n\n/** Applies a premove's changes to the board. */\nfunction applyPremove(\n\tgamefile: FullGame,\n\tmesh: Mesh | undefined,\n\tpremove: Premove,\n\tforward: boolean,\n): void {\n\t// console.log(`Applying premove ${forward ? 'FORWARD' : 'BACKWARD'}:`, premove);\n\tmovepiece.applyEdit(gamefile, premove, forward, true); // forward & global are true\n\tif (mesh) movesequence.runMeshChanges(gamefile.boardsim, mesh, premove, forward);\n}\n\n/** Similar to {@link movepiece.generateMove}, but generates the edit for a Premove. */\nfunction generatePremove(gamefile: FullGame, moveTagged: MoveTagged): Premove {\n\tconst piece = boardutil.getPieceFromCoords(gamefile.boardsim.pieces, moveTagged.startCoords);\n\tif (!piece)\n\t\tthrow Error(\n\t\t\t`Cannot generate premove because no piece exists at coords ${JSON.stringify(moveTagged.startCoords)}.`,\n\t\t);\n\n\t// Initialize the state, and change list, as empty for now.\n\tconst premove: Premove = {\n\t\t...moveTagged,\n\t\ttype: piece.type,\n\t\tchanges: [],\n\t\tstate: { local: [], global: [] },\n\t};\n\n\tconst rawType = typeutil.getRawType(piece.type);\n\tlet specialMoveMade: boolean = false;\n\t// If a special move function exists for this piece type, run it.\n\t// The actual function will return whether a special move was actually made or not.\n\t// If a special move IS made, we skip the normal move piece method.\n\n\tif (rawType in gamefile.boardsim.specialMoves)\n\t\tspecialMoveMade = gamefile.boardsim.specialMoves[rawType]!(\n\t\t\tgamefile.boardsim,\n\t\t\tpiece,\n\t\t\tpremove,\n\t\t);\n\tif (!specialMoveMade) movepiece.calcMovesChanges(gamefile.boardsim, piece, moveTagged, premove); // Move piece regularly (no special tag)\n\n\t// Delete all special rights that should be revoked from the move.\n\tmovepiece.queueSpecialRightDeletionStateChanges(gamefile.boardsim, premove);\n\n\treturn premove;\n}\n\n/** Clears all pending premoves */\nfunction clearPremoves(): void {\n\t// console.error(\"Clearing premoves\");\n\tpremoves = [];\n\t// Since we now have zero premoves, they are technically applied.\n\t// console.error(\"Setting applied to true.\");\n\tapplied = true;\n}\n\n/** Cancels all premoves */\nfunction cancelPremoves(gamefile: FullGame, mesh?: Mesh): void {\n\t// console.log(\"Clearing premoves\");\n\tconst hadAtleastOnePremove = hasAtleastOnePremove();\n\n\trewindPremoves(gamefile, mesh);\n\tclearPremoves();\n\n\tif (selection.arePremoving()) {\n\t\t// Unselect in the case where the premoves are being rewound\n\t\tif (hadAtleastOnePremove) selection.unselectPiece();\n\t\t// Reselect if we haven't actually made any premoves yet\n\t\telse selection.reselectPiece();\n\t}\n\n\t// If there were any animations, this should ensure they're only cancelled if they are for premoves,\n\t// and not for the opponent's move. After all cancelPremoves() can be called at any time.\n\tif (hadAtleastOnePremove) animation.clearAnimations();\n}\n\n/** Unapplies all pending premoves by undoing their changes on the board. */\nfunction rewindPremoves(gamefile: FullGame, mesh?: Mesh): void {\n\tif (!applied) throw Error(\"Don't rewindPremoves when other premoves are not applied!\");\n\n\t// Reverse the original array so all changes are made in the reverse order they were added\n\tpremoves\n\t\t.slice()\n\t\t.reverse()\n\t\t.forEach((premove) => {\n\t\t\tapplyPremove(gamefile, mesh, premove, false); // Apply the premove to the game state backwards\n\t\t});\n\n\t// console.error(\"Setting applied to false.\");\n\tapplied = false;\n}\n\n/**\n * Reapplies all pending premoves' changes onto the board.\n *\n * All premove's must be regenerated, as for all we know\n * their destination square could have a new piece, or lack thereof.\n */\nfunction applyPremoves(gamefile: FullGame, mesh?: Mesh): void {\n\tif (applied) throw Error(\"Don't applyPremoves when other premoves are already applied!\");\n\n\tfor (let i = 0; i < premoves.length; i++) {\n\t\tconst oldPremove = premoves[i]!;\n\n\t\t// Check if the premove is still legal to premove\n\t\t// It might not be if the premoved piece was captured,\n\t\t// Or if a castling premove's rook was captured.\n\t\tconst results = premoveIsLegal(gamefile, oldPremove, 'premove');\n\n\t\tif (results.legal === true) {\n\t\t\t// Extract the original MoveTagged from the premove\n\t\t\tconst premoveTagged: MoveTagged = {\n\t\t\t\tstartCoords: oldPremove.startCoords,\n\t\t\t\tendCoords: oldPremove.endCoords,\n\t\t\t\tpromotion: oldPremove.promotion,\n\t\t\t};\n\t\t\tspecialdetect.transferSpecialTags_FromCoordsToMove(\n\t\t\t\tresults.endCoordsTagged,\n\t\t\t\tpremoveTagged,\n\t\t\t);\n\n\t\t\t// MUST RECALCULATE CHANGES\n\t\t\tconst premove = generatePremove(gamefile, premoveTagged);\n\n\t\t\tpremoves[i] = premove; // Update the premove with the new changes\n\t\t\tapplyPremove(gamefile, mesh, premove, true); // Apply the premove to the game state\n\t\t} else {\n\t\t\tconsole.log('Premove is no longer legal:', oldPremove);\n\t\t\t// Premove is no longer legal to premove.\n\t\t\t// This could happen if it was a castling premove, and the rook was captured,\n\t\t\t// so there's no longer a valid rook to premove castle with.\n\n\t\t\t// Delete this premove and all following premoves\n\t\t\tpremoves.splice(i, premoves.length - i);\n\t\t\tbreak;\n\t\t}\n\t}\n\n\t// console.error(\"Setting applied to true.\");\n\tapplied = true;\n\n\tGameBus.dispatch('physical-move');\n}\n\n/**\n * Processes the premoves array after the opponent's move.\n * Attempts to play the first premove in the list, then applies the remaining premoves.\n * A. Legal => Plays it, submits it, then applies the remaining premoves.\n * B. Illegal => Clears all premoves.\n */\nfunction processPremoves(gamefile: FullGame, mesh?: Mesh): void {\n\t// console.error(\"Processing premoves\");\n\n\tif (applied)\n\t\tthrow Error(\n\t\t\t\"Don't processPremoves when other premoves are still applied! rewindPremoves() first.\",\n\t\t);\n\n\tconst premove: Premove | undefined = premoves[0];\n\t// CAN'T EARLY EXIT if there are no premoves, as\n\t// we still need clearPremoves() to set applied to true!\n\n\t// Check if the move is legal\n\tconst results = premoveIsLegal(gamefile, premove, 'physical');\n\n\tif (premove && results.legal === true) {\n\t\t// console.log(\"Premove is legal, applying it\");\n\n\t\t// Legal, apply the premove to the real game state\n\n\t\tconst moveTagged: MoveTagged = {\n\t\t\tstartCoords: premove.startCoords,\n\t\t\tendCoords: premove.endCoords,\n\t\t\tpromotion: premove.promotion,\n\t\t};\n\t\tspecialdetect.transferSpecialTags_FromCoordsToMove(results.endCoordsTagged, moveTagged);\n\n\t\tconst move = movesequence.makeMove(gamefile, mesh, moveTagged); // Make move\n\n\t\tGameBus.dispatch('user-move-played');\n\n\t\tpremoves.shift(); // Remove premove\n\n\t\t// Only instant animate\n\t\t// This also immediately terminates the opponent's move animation\n\t\t// MUST READ the move's changes returned from movesequence.makeMove()\n\t\t// instead of the premove's changes, as the changes need to be regenerated!\n\t\tanimateMove(move.changes, true, false, false, true); // true for force instant animation, even secondary pieces aren't animated!\n\n\t\t// Apply remaining premove changes & visuals, but don't make them physically on the board\n\t\tapplyPremoves(gamefile, mesh);\n\t} else {\n\t\t// console.log(\"Premove is illegal, clearing all premoves\");\n\t\t// Illegal, clear all premoves (they have already been rewounded before processPremoves() was called)\n\t\tclearPremoves();\n\t}\n}\n\n/**\n * Tests whether a given premove is legal to make on the board.\n * @param gamefile\n * @param premove\n * @param mode - Whether we should be testing if the premove is legal to make physically in the game, OR if it's still a valid premove to PREMOVE. A premove may no longer become a valid premove if for example the castling opportunity dissapears due to the opponent capturing the rook.\n * @returns\n */\nfunction premoveIsLegal(\n\tgamefile: FullGame,\n\tpremove: Premove | undefined,\n\tmode: 'physical' | 'premove',\n): { legal: true; endCoordsTagged: CoordsTagged } | { legal: false } {\n\tif (!premove) return { legal: false };\n\n\tconst piece = boardutil.getPieceFromCoords(gamefile.boardsim.pieces, premove.startCoords);\n\tif (!piece) return { legal: false }; // Can't premove nothing, could happen if your piece was captured by enpassant\n\n\tif (premove.type !== piece.type) return { legal: false }; // Our piece was probably captured, so it can't move anymore, thus the premove is illegal.\n\n\t// Check if the move is legal\n\tconst premovedPieceLegalMoves =\n\t\tmode === 'physical'\n\t\t\t? legalmoves.calculateAll(gamefile, piece)\n\t\t\t: legalmoves.calculateAllPremoves(gamefile, piece);\n\tconst color = typeutil.getColorFromType(piece.type);\n\n\t// A copy of the end coords for applying the special tags too.\n\t// We have to do this because enpassant capture tags aren't\n\t// generated for normal premoves\n\tconst endCoordsTagged: CoordsTagged = coordutil.copyCoords(premove.endCoords);\n\n\tconst isLegal = legalmoves.checkIfMoveLegal(\n\t\tgamefile,\n\t\tpremovedPieceLegalMoves,\n\t\tpremove.startCoords,\n\t\tendCoordsTagged,\n\t\tcolor,\n\t);\n\n\tif (isLegal || selection.getEditMode()) return { legal: true, endCoordsTagged };\n\telse return { legal: false };\n}\n\n/**\n * Called externally when its our move in the game.\n *\n * Shouldn't care whether the game is over, as all premoves should have been cleared,\n * and not to mention we still need applied to be set to true.\n *\n * Similar to {@link applyPremoves}, but before applying premoves, it attempts to play the first premove in the list if legal.\n */\nfunction onYourMove(gamefile: FullGame, mesh?: Mesh): void {\n\t// Process the next premove, will reapply the premoves\n\tprocessPremoves(gamefile, mesh);\n}\n\n/**\n * Executes a callback function with all premoves rewound, so the game state is correct for any board checks.\n * Then depending on the return value, may attempt to physically play the next premove when re-applying them.\n * @param gamefile\n * @param mesh\n * @param callback - A function that returns true if we should attempt to physically play our next premove when re-applying them.\n */\nfunction performWithUnapplied(\n\tgamefile: FullGame,\n\tmesh: Mesh | undefined,\n\tcallback: () => boolean,\n): void {\n\t// Rewind all to get the real game state\n\trewindPremoves(gamefile, mesh);\n\n\tconst result = callback();\n\n\tif (result) {\n\t\t// Attempt to physically make our next premove, and re-apply the remaining.\n\t\tonYourMove(gamefile, mesh);\n\t} else {\n\t\t// Just re-apply\n\t\tapplyPremoves(gamefile, mesh);\n\t}\n}\n\n// Updating Premoves ------------------------------------------------\n\n/** Clears premoves if right mouse is down and Lingering Annotations mode is off. */\nfunction update(gamefile: FullGame, mesh?: Mesh): void {\n\tif (preferences.getLingeringAnnotationsMode()) return; // Right mouse down doesn't clear premoves in Lingering Annotations mode\n\n\tif (mouse.isMouseDown(Mouse.RIGHT)) {\n\t\tif (!hasAtleastOnePremove()) return; // No premoves to clear. Don't claim the right mouse button.\n\n\t\tmouse.claimMouseDown(Mouse.RIGHT); // Claim the right mouse button so it doesn't propagate to arrow drawing\n\t\tmouse.cancelMouseClick(Mouse.RIGHT); // Prevents the up-release from registering a click later, drawing a square highlight\n\n\t\tcancelPremoves(gamefile, mesh);\n\t}\n}\n\n// Rendering --------------------------------------------------------\n\n/** Renders the premoves */\nfunction render(): void {\n\tif (premoves.length === 0) return; // No premoves to render\n\n\tlet premoveSquares = premoves.flatMap((p) => [p.startCoords, p.endCoords]);\n\n\t// De-duplicate the squares\n\tpremoveSquares = premoveSquares.filter((coords, index, self) => {\n\t\treturn self.findIndex((c) => coordutil.areCoordsEqual(c, coords)) === index;\n\t});\n\n\tconst u_size: number = boardpos.getBoardScaleAsNumber();\n\tconst color: Color = preferences.getAnnoteSquareColor();\n\n\t// Render preset squares\n\tsquarerendering.genModel(premoveSquares, color).render(undefined, undefined, { u_size });\n}\n\n// Exports ------------------------------------------------\n\nexport default {\n\thasAtleastOnePremove,\n\tarePremovesApplied,\n\taddPremove,\n\tcancelPremoves,\n\trewindPremoves,\n\tapplyPremoves,\n\tonYourMove,\n\tperformWithUnapplied,\n\tupdate,\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/chess/selection.ts",
    "content": "// src/client/scripts/esm/game/chess/selection.ts\n\n/**\n * This script tests for piece selection and keeps track of the selected piece,\n * including the legal moves it has available.\n */\n\nimport type { Mesh } from '../rendering/piecemodels.js';\nimport type { Piece } from '../../../../../shared/chess/util/boardutil.js';\nimport type { LegalMoves } from '../../../../../shared/chess/logic/legalmoves.js';\nimport type { Game, FullGame } from '../../../../../shared/chess/logic/gamefile.js';\nimport type { CoordsTagged, MoveTagged } from '../../../../../shared/chess/logic/movepiece.js';\n\nimport bounds from '../../../../../shared/util/math/bounds.js';\nimport typeutil from '../../../../../shared/chess/util/typeutil.js';\nimport moveutil from '../../../../../shared/chess/util/moveutil.js';\nimport boardutil from '../../../../../shared/chess/util/boardutil.js';\nimport legalmoves from '../../../../../shared/chess/logic/legalmoves.js';\nimport specialdetect from '../../../../../shared/chess/logic/specialdetect.js';\nimport gamefileutility from '../../../../../shared/chess/util/gamefileutility.js';\nimport coordutil, { Coords } from '../../../../../shared/chess/util/coordutil.js';\nimport { rawTypes as r, players as p } from '../../../../../shared/chess/util/typeutil.js';\n\nimport mouse from '../../util/mouse.js';\nimport toast from '../gui/toast.js';\nimport pieces from '../rendering/pieces.js';\nimport arrows from '../rendering/arrows/arrows.js';\nimport config from '../config.js';\nimport guipause from '../gui/guipause.js';\nimport gameslot from './gameslot.js';\nimport boardpos from '../rendering/boardpos.js';\nimport premoves from '../chess/premoves.js';\nimport keybinds from '../misc/keybinds.js';\nimport { Mouse } from '../input.js';\nimport droparrows from '../rendering/dragging/droparrows.js';\nimport gameloader from './gameloader.js';\nimport onlinegame from '../misc/onlinegame/onlinegame.js';\nimport enginegame from '../misc/enginegame.js';\nimport Transition from '../rendering/transitions/Transition.js';\nimport normaltool from '../boardeditor/tools/normaltool.js';\nimport preferences from '../../components/header/preferences.js';\nimport boardeditor from '../boardeditor/boardeditor.js';\nimport perspective from '../rendering/perspective.js';\nimport { GameBus } from '../GameBus.js';\nimport movesequence from './movesequence.js';\nimport frametracker from '../rendering/frametracker.js';\nimport guipromotion from '../gui/guipromotion.js';\nimport draganimation from '../rendering/dragging/draganimation.js';\nimport { animateMove } from './graphicalchanges.js';\nimport { listener_document, listener_overlay } from './game.js';\n\n// Variables -----------------------------------------------------------------------------\n\n/** The currently selected piece, if there is one */\nlet pieceSelected: Piece | undefined;\n/** The pre-calculated legal moves of the current selected piece. */\nlet legalMoves: LegalMoves | undefined;\n/** Whether or not the piece selected belongs to the opponent.\n * If so, it's legal moves are rendered a different color, and you aren't allowed to move it.  */\nlet isOpponentPiece = false;\n/** Whether or not the piece selected activated premove mode.\n * This happens when we select our own pieces, in online games, when it's not our turn. */\nlet isPremove = false;\n\n/** The tile the mouse is hovering over, OR the tile we just performed a simulated click over: `[x,y]` */\nlet hoverSquare: CoordsTagged | undefined; // Current square mouse is hovering over\n/** Whether the {@link hoverSquare} is legal to move the selected piece to. */\nlet hoverSquareLegal: boolean = false;\n\n/** If a pawn is currently promoting (waiting on the promotion UI selection),\n * this will be set to the square it's moving to: `[x,y]`. */\nlet pawnIsPromotingOn: CoordsTagged | undefined;\n/** When a promotion UI piece is selected, this is set to the promotion you selected. */\nlet promoteTo: number | undefined;\n\n/**\n * When enabled, allows moving pieces anywhere else on the board, disregarding whether it's legal.\n * Special tags however will still only be transferred if the destination is legal.\n */\nlet editMode = false; // editMode, allows moving pieces anywhere else on the board!\n\n// Events ----------------------------------------------------------------------------------------\n\nGameBus.addEventListener('game-concluded', () => {\n\tunselectPiece();\n});\nGameBus.addEventListener('game-unloaded', () => {\n\tdisableEditMode();\n\tunselectPiece();\n});\n\n// Getters ---------------------------------------------------------------------------------------\n\n/** Returns the current selected piece, if there is one. */\nfunction getPieceSelected(): Piece | undefined {\n\treturn pieceSelected;\n}\n\n/** Returns *true* if a piece is currently selected. */\nfunction isAPieceSelected(): boolean {\n\treturn pieceSelected !== undefined;\n}\n\n/** Returns true if we have selected an opponents piece to view their moves */\nfunction isOpponentPieceSelected(): boolean {\n\treturn isOpponentPiece;\n}\n\n/** Returns true if we are in premove mode (i.e. selected our own piece in an online game, when it's not our turn) */\nfunction arePremoving(): boolean {\n\treturn isPremove;\n}\n\n/** Returns the pre-calculated legal moves of the selected piece. */\nfunction getLegalMovesOfSelectedPiece(): LegalMoves | undefined {\n\treturn legalMoves;\n}\n\n/** Returns *true* if a pawn is currently promoting (promotion UI open). */\nfunction getSquarePawnIsCurrentlyPromotingOn(): CoordsTagged | undefined {\n\treturn pawnIsPromotingOn;\n}\n\n/**\n * Marks the currently selected pawn to be promoted next frame.\n * Call when a choice is made on the promotion UI.\n */\nfunction promoteToType(type: number): void {\n\tpromoteTo = type;\n}\n\nfunction getEditMode(): boolean {\n\treturn editMode;\n}\n\n// Toggles EDIT MODE! editMode\n// Called when '1' is pressed!\nfunction toggleEditMode(): void {\n\t// Make sure it's legal\n\tconst legalInPrivate =\n\t\tonlinegame.areInOnlineGame() &&\n\t\tonlinegame.getIsPrivate() &&\n\t\tlistener_document.isKeyHeld('Digit0');\n\tif (onlinegame.areInOnlineGame() && !legalInPrivate) return; // Don't toggle if in an online game\n\tif (enginegame.areInEngineGame()) return; // Don't toggle if in an engine game\n\tif (boardeditor.areInBoardEditor()) return; // Don't toggle if in board editor\n\n\teditMode = !editMode;\n\ttoast.show(`Toggled Edit Mode: ${editMode}`);\n}\n\nfunction disableEditMode(): void {\n\teditMode = false;\n}\n\nfunction enableEditMode(): void {\n\teditMode = true;\n}\n\n// Updating ---------------------------------------------------------------------------------------------\n\n/** Tests if we have selected a piece, or moved the currently selected piece. */\nfunction update(): void {\n\tguipromotion.update();\n\tif (mouse.isMouseDown(Mouse.MIDDLE)) return unselectPiece(); // Right-click deselects everything\n\n\t// Guard clauses...\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh();\n\tif (pawnIsPromotingOn) {\n\t\t// Do nothing else this frame but wait for a promotion piece to be selected\n\t\tif (promoteTo) makePromotionMove(gamefile, mesh);\n\t\treturn;\n\t}\n\tif (\n\t\tboardpos.areZoomedOut() ||\n\t\tgamefileutility.isGameOver(gamefile.basegame) ||\n\t\tguipause.areWePaused() ||\n\t\tperspective.isLookingUp()\n\t) {\n\t\t// We might be zoomed way out.\n\t\t// If we are still dragging a piece, we still want to be able to drop it.\n\t\tif (draganimation.areDraggingPiece() && draganimation.hasPointerReleased())\n\t\t\tdraganimation.dropPiece(); // Drop it without moving it.\n\t\treturn;\n\t}\n\n\t// Update the hover square to:\n\t// 1. The draganimation hover coords, if present. The droparrows and dragarrows features can change this.\n\t// 2. Fallback to current mouse coords.\n\thoverSquare = draganimation.getHoveredCoords() ?? mouse.getTileMouseOver_Integer(); // Update the tile the mouse is hovering over, if any.\n\t// console.log('Hover square:', hoverSquare);\n\n\tupdateHoverSquareLegal(gamefile); // Update whether the hover square is legal to move to.\n\tif (!hoverSquare) return; // Looking into sky\n\n\t// Only exit during a transition after updating hover square\n\tif (Transition.areTransitioning()) return;\n\n\t// What should selection.ts do?\n\n\t// 1. Test if we selected a new piece, or a different piece.\n\n\ttestIfPieceSelected(gamefile, mesh); // Test this EVEN if a piece is currently selected, because we can always select a different piece.\n\n\t// Piece IS selected...\n\n\t// 2. Test if the piece was dropped. If it happened to be dropped on a legal square, then make the move.\n\n\ttestIfPieceDropped(gamefile, mesh);\n\n\t// 3. Test if the piece was moved.\n\n\ttestIfPieceMoved(gamefile, mesh);\n}\n\n/**\n * Updates the hover square, and tests if it is among\n * our pre-calculated legal moves for our selected piece.\n *\n * This is required to call BEFORE we test if a piece should\n * be selected, because if we are switching selections, but\n * it turns out the new piece is legal to move to, we don't want\n * to select it instead, but capture it.\n */\nfunction updateHoverSquareLegal(gamefile: FullGame): void {\n\tif (!pieceSelected) return;\n\tif (!hoverSquare) {\n\t\thoverSquareLegal = false;\n\t\treturn;\n\t}\n\tconst colorOfSelectedPiece = typeutil.getColorFromType(pieceSelected.type);\n\t// Required to pass on the special tag\n\tconst legal = legalmoves.checkIfMoveLegal(\n\t\tgamefile,\n\t\tlegalMoves!,\n\t\tpieceSelected!.coords,\n\t\thoverSquare,\n\t\tcolorOfSelectedPiece,\n\t);\n\thoverSquareLegal =\n\t\t(legal && canMovePieceType(pieceSelected!.type)) ||\n\t\t(editMode &&\n\t\t\tlegalmoves.testSquareValidity(\n\t\t\t\tgamefile.boardsim,\n\t\t\t\tgamefile.basegame.gameRules.worldBorder,\n\t\t\t\thoverSquare,\n\t\t\t\tcolorOfSelectedPiece,\n\t\t\t\tfalse,\n\t\t\t\tfalse,\n\t\t\t) <= 1) ||\n\t\t(boardeditor.areInBoardEditor() &&\n\t\t\t!coordutil.areCoordsEqual(hoverSquare, pieceSelected.coords) &&\n\t\t\t(gamefile.basegame.gameRules.worldBorder === undefined ||\n\t\t\t\tbounds.boxContainsSquare(gamefile.basegame.gameRules.worldBorder, hoverSquare))); // Allow ALL moves in board editor.\n}\n\n// Piece Select / Drop / Move -----------------------------------------------------------------------------\n\n/** If a piece was clicked or dragged, this will attempt to select that piece. */\nfunction testIfPieceSelected(gamefile: FullGame, mesh: Mesh | undefined): void {\n\tif (arrows.areHoveringAtleastOneArrow()) return; // Don't select a piece if we're hovering over an arrow\n\n\tconst mouseKeybind = keybinds.getPieceSelectionMouseButton();\n\tif (mouseKeybind === undefined) return; // Nothing assigned to selecting pieces currently\n\n\t// If we did not click, exit...\n\tconst effectiveDragEnabled = keybinds.getEffectiveDragEnabled();\n\tif (\n\t\teffectiveDragEnabled &&\n\t\t!mouse.isMouseDown(mouseKeybind) &&\n\t\t!mouse.isMouseClicked(mouseKeybind)\n\t)\n\t\treturn; // If dragging is enabled, all we need is pointer down event.\n\telse if (!effectiveDragEnabled && !mouse.isMouseClicked(mouseKeybind)) return; // When dragging is off, we actually need a pointer click.\n\n\tif (boardpos.boardHasMomentum()) return; // Don't select a piece if the boardsim is moving\n\n\t// We have clicked, test if we clicked a piece...\n\n\tconst pieceClicked = boardutil.getPieceFromCoords(gamefile.boardsim.pieces, hoverSquare!);\n\t// if (pieceClicked) console.log(typeutil.debugType(pieceClicked?.type));\n\n\t// Is the type selectable by us? (not necessarily moveable)\n\tconst selectionLevel = canSelectPieceType(gamefile.basegame, pieceClicked?.type);\n\t// console.log('Selection Level:', selectionLevel);\n\tif (selectionLevel === 0)\n\t\treturn; // Can't select this piece type\n\telse if (selectionLevel >= 1 && mouse.isMouseClicked(mouseKeybind)) {\n\t\t// CAN select this piece type\n\t\t/** Just quickly make sure that, if we already have selected a piece,\n\t\t * AND we just clicked a piece that's legal to MOVE to,\n\t\t * that we don't select it instead! */\n\t\tif (pieceSelected && hoverSquareLegal) return; // Return. Don't select it, NOR make the move, let testIfPieceMoved() catch that.\n\t\tmouse.claimMouseClick(mouseKeybind); // Claim the mouse click so that annotations does use it to Collapse annotations.\n\t\t// If we are viewing past moves, forward to front instead!!\n\t\tif (viewFrontIfNotViewingLatestMove(gamefile, mesh)) return; // Forwarded to front, DON'T select the piece.\n\t\tselectPiece(gamefile, mesh, pieceClicked!, false); // Select, but don't start dragging\n\t} else if (selectionLevel === 2 && mouse.isMouseDown(mouseKeybind)) {\n\t\t// Can DRAG this piece type\n\t\t/** Just quickly make sure that, if we already have selected a piece,\n\t\t * AND we just clicked a piece that's legal to MOVE to,\n\t\t * that we don't select it instead! */\n\t\tif (pieceSelected && hoverSquareLegal) return; // Return. Don't select it, NOR make the move, let testIfPieceMoved() catch that.\n\t\tmouse.claimMouseDown(mouseKeybind); // Claim the mouse down so board dragging doesn't use it\n\t\tmouse.cancelMouseClick(mouseKeybind); // Cancel the click so annotation doesn't clear when the mouse released in a few frames, simulating a click.\n\t\tif (viewFrontIfNotViewingLatestMove(gamefile, mesh)) return; // Forwarded to front, DON'T select the piece.\n\t\tselectPiece(gamefile, mesh, pieceClicked!, true); // Select, AND start dragging if that's enabled.\n\t}\n}\n\n/** If a piece is being dragged, this will test if it was dropped, making the move if it is legal. */\nfunction testIfPieceDropped(gamefile: FullGame, mesh: Mesh | undefined): void {\n\tif (!pieceSelected) return; // No piece selected, can't move nor drop anything.\n\tif (!draganimation.areDraggingPiece()) return; // The selected piece is not being dragged.\n\tdroparrows.updateCapturedPiece(); // Update the piece that would be captured if we were to let go of the dragged piece right now.\n\n\tif (!draganimation.hasPointerReleased()) return; // The pointer has not released yet, don't drop it.\n\n\t// The pointer has released, drop the piece.\n\n\t// If it was dropped on its own square, AND the parity is negative, then also deselect the piece.\n\n\tconst droppedOnOwnSquare = coordutil.areCoordsEqual(hoverSquare!, pieceSelected!.coords);\n\tif (droppedOnOwnSquare && !draganimation.getDragParity()) unselectPiece();\n\telse if (hoverSquareLegal)\n\t\tmoveGamefilePiece(gamefile, mesh, hoverSquare!); // It was dropped on a legal square. Make the move. Making a move automatically deselects the piece and cancels the drag.\n\telse draganimation.dropPiece(); // Drop it without moving it.\n}\n\n/** If a piece is selected, and we clicked a legal square to move to, this will make the move. */\nfunction testIfPieceMoved(gamefile: FullGame, mesh: Mesh | undefined): void {\n\tif (!pieceSelected) return;\n\tif (arrows.areHoveringAtleastOneArrow()) return; // Don't move a piece if we're hovering over an arrow\n\n\tconst mouseKeybind = keybinds.getPieceSelectionMouseButton();\n\tif (mouseKeybind === undefined) return; // Nothing assigned to moving pieces currently\n\n\tif (!mouse.isMouseClicked(mouseKeybind)) return; // Pointer did not click, couldn't have moved a piece.\n\n\tif (!hoverSquareLegal) return; // Don't move it\n\tmoveGamefilePiece(gamefile, mesh, hoverSquare!);\n\n\tmouse.claimMouseClick(mouseKeybind); // Claim the mouse click so that annotations does use it to Collapse annotations.\n}\n\n/** Forwards to the front of the game if we're viewing history, and returns true if we did. */\nfunction viewFrontIfNotViewingLatestMove(gamefile: FullGame, mesh: Mesh | undefined): boolean {\n\t// If we're viewing history, return.\n\tif (moveutil.areWeViewingLatestMove(gamefile.boardsim)) return false;\n\n\tmovesequence.viewFront(gamefile, mesh);\n\t// Also animate the last move\n\tconst lastMove = moveutil.getLastMove(gamefile.boardsim.moves)!;\n\tanimateMove(lastMove.changes);\n\treturn true;\n}\n\n// Can Select/Move/Drop Piece Type ---------------------------------------------------------------------------------\n\n/**\n * 0 => Can't select this piece type EVER (i.e. voids, neutrals).\n * 1 => Can select this piece type, but not draggable.\n * 2 => Can select and drag this piece type.\n *\n * A piece will not be considered draggable (level 2) if the user disabled dragging.\n * This means more information is needed to tell if the piece is moveable by us.\n */\nfunction canSelectPieceType(basegame: Game, type: number | undefined): 0 | 1 | 2 {\n\tif (type === undefined) return 0; // Can't select nothing\n\tconst dragEnabled = keybinds.getEffectiveDragEnabled();\n\tif (boardeditor.areInBoardEditor()) return dragEnabled ? 2 : 1; // In board editor, we can select and drag ANY piece type, even voids!\n\tconst [raw, player] = typeutil.splitType(type);\n\tif (raw === r.VOID) return 0; // Can't select voids\n\tif (editMode && gameloader.areInLocalGame()) return dragEnabled ? 2 : 1; // Edit mode allows any piece besides voids to be selected and dragged in local games.\n\tif (player === p.NEUTRAL) return 0; // Can't select neutrals, period.\n\tif (isOpponentType(basegame, type)) return 1; // Can select opponent pieces, but not draggable..\n\t// It is our piece type...\n\tconst isOurTurn = gameloader.isItOurTurn();\n\tif (!isOurTurn && !preferences.getPremoveEnabled()) return 1; // Can select our piece when it's not our turn, but not draggable.\n\treturn dragEnabled ? 2 : 1; // Can select and move this piece type (draggable too IF THAT IS ENABLED).\n}\n\n/**\n * Returns true if the user is currently allowed to move the pieceType. It must be our piece and our turn.\n */\nfunction canMovePieceType(pieceType: number): boolean {\n\tif (editMode) return true; // Edit mode allows pieces to be moved on any turn.\n\tconst isOpponentPiece = isOpponentType(gameslot.getGamefile()!.basegame, pieceType);\n\tif (isOpponentPiece) return false; // Don't move opponent pieces\n\t// It is our piece type...\n\tconst isOurTurn = gameloader.isItOurTurn();\n\tif (isOurTurn) return true; // Can always move pieces on our turn\n\treturn preferences.getPremoveEnabled(); // If it's not out turn, can only move if premoving is enabled.\n}\n\n/** Returns true if the type belongs to our opponent, no matter what kind of game we're in. */\nfunction isOpponentType(basegame: Game, type: number): boolean {\n\tconst pieceColor = typeutil.getColorFromType(type);\n\tif (boardeditor.areInBoardEditor()) return false;\n\telse if (gameloader.areInLocalGame()) return pieceColor !== basegame.whosTurn;\n\telse return pieceColor !== gameloader.getOurColor();\n}\n\n// Selection & Moving ---------------------------------------------------------------------------------------------\n\n/**\n * Selects the provided piece. If the piece is already selected, it will be deselected.\n * @param gamefile\n * @param piece\n * @param drag - If true, the piece starts being dragged. This also means it won't be deselected if you clicked the selected piece again.\n */\nfunction selectPiece(\n\tgamefile: FullGame,\n\tmesh: Mesh | undefined,\n\tpiece: Piece,\n\tdrag: boolean,\n): void {\n\thoverSquareLegal = false; // Reset the hover square legal flag so that it doesn't remain true for the remainer of the update loop.\n\n\tconst alreadySelected =\n\t\tpieceSelected !== undefined && coordutil.areCoordsEqual(pieceSelected.coords, piece.coords);\n\tif (drag) {\n\t\t// Pick up anyway, don't unselect it if it was already selected.\n\t\tif (alreadySelected) {\n\t\t\tdraganimation.pickUpPiece(piece, false); // Toggle the parity since it's the same piece being picked up.\n\t\t\treturn; // Already selected, don't have to recalculate legal moves.\n\t\t}\n\t\tdraganimation.pickUpPiece(piece, true); // Reset parity since it's a new piece being picked up.\n\t} else {\n\t\t// Not being dragged. If this piece is already selected, unselect it.\n\t\tif (alreadySelected) return unselectPiece();\n\t}\n\n\tinitSelectedPieceInfo(gamefile, mesh, piece);\n}\n\n/**\n * Reselects the currently selected piece by recalculating its legal moves again,\n * and changing the color if needed.\n * Typically called after our opponent makes a move while we have a piece selected.\n */\nfunction reselectPiece(): void {\n\tif (!pieceSelected) return; // No piece to reselect.\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh();\n\t// Test if the piece is no longer there\n\t// This will work for us long as it is impossible to capture friendly's\n\tconst pieceTypeOnCoords = boardutil.getTypeFromCoords(\n\t\tgamefile.boardsim.pieces,\n\t\tpieceSelected.coords,\n\t);\n\tif (pieceTypeOnCoords !== pieceSelected.type) {\n\t\t// It either moved, or was captured\n\t\tunselectPiece(); // Can't be reselected, unselect it instead.\n\t\treturn;\n\t}\n\n\tif (gamefileutility.isGameOver(gamefile.basegame)) return; // Don't reselect, game is over\n\n\t// Reselect! Recalc its legal moves, and recolor.\n\tconst pieceToReselect = boardutil.getPieceFromCoords(\n\t\tgamefile.boardsim.pieces,\n\t\tpieceSelected.coords,\n\t)!;\n\tinitSelectedPieceInfo(gamefile, mesh, pieceToReselect);\n\n\t// FIXES BUG where if you premove a promotion, but leave the promotion UI open,\n\t// then your opponent moves, resulting in that promotion being illegal,\n\t// the promotion UI remains open, allowing you to make that illegal promotion,\n\t// resulting in the game being aborted from an illegal move played!\n\n\t// Close the promotion UI if it's open, ONLY if the square being promoted to is now illegal.\n\tif (pawnIsPromotingOn) {\n\t\tconst colorOfSelectedPiece = typeutil.getColorFromType(pieceSelected.type);\n\t\t// Use a copy so special tags aren't attached to the original pawnIsPromotingOn\n\t\tconst endCoordsCopy: Coords = coordutil.copyCoords(pawnIsPromotingOn);\n\t\tconst legal = legalmoves.checkIfMoveLegal(\n\t\t\tgamefile,\n\t\t\tlegalMoves!,\n\t\t\tpieceSelected.coords,\n\t\t\tendCoordsCopy,\n\t\t\tcolorOfSelectedPiece,\n\t\t);\n\t\tif (!legal) {\n\t\t\t// Cancel promotion (but can still leave the piece selected)\n\t\t\tpawnIsPromotingOn = undefined;\n\t\t\tpromoteTo = undefined; // Just in case a promotion was selected same frame\n\t\t\tguipromotion.close();\n\t\t}\n\t}\n}\n\n/** Unselects the currently selected piece. Cancels pawns currently promoting, closes the promotion UI. */\nfunction unselectPiece(): void {\n\t// console.error(\"Unselecting piece\");\n\tif (pieceSelected === undefined) return; // No piece to unselect.\n\tpieceSelected = undefined;\n\tisOpponentPiece = false;\n\tisPremove = false;\n\tlegalMoves = undefined;\n\tpawnIsPromotingOn = undefined;\n\tpromoteTo = undefined;\n\thoverSquareLegal = false;\n\tframetracker.onVisualChange();\n\n\tGameBus.dispatch('piece-unselected');\n}\n\n/** Initializes the selected piece, and calculates its legal moves. */\nfunction initSelectedPieceInfo(gamefile: FullGame, mesh: Mesh | undefined, piece: Piece): void {\n\t// Initiate\n\tpieceSelected = piece;\n\n\tisOpponentPiece = isOpponentType(gamefile.basegame, piece.type);\n\tisPremove = !isOpponentPiece && !gameloader.isItOurTurn();\n\n\t// Calculate the legal moves it has...\n\n\tif (isPremove && preferences.getPremoveEnabled()) {\n\t\t// DO NOT rewind the premoves here before calculation,\n\t\t// because we do need the SPECIALRIGHT state changes to still be applied!\n\t\t// Else if you premove a pawn onto an opponent's pawn that hasn't moved,\n\t\t// your premoved pawn will be able to double push again past their 8th rank.\n\t\tlegalMoves = legalmoves.calculateAllPremoves(gamefile, piece);\n\t} else {\n\t\tpremoves.performWithUnapplied(gamefile, mesh, () => {\n\t\t\tlegalMoves = legalmoves.calculateAll(gamefile, piece);\n\t\t\treturn false; // Do NOT attempt to physically play the next premove when they're re-applied\n\t\t});\n\t}\n\n\t// console.log('Selected Legal Moves:', legalMoves);\n\n\tGameBus.dispatch('piece-selected', { piece: pieceSelected, legalMoves: legalMoves! });\n}\n\n/**\n * Moves the currently selected piece to the specified coordinates, then unselects the piece.\n * The destination coordinates MUST contain any special move tags.\n * @param coords - The destination coordinates`[x,y]`. MUST contain any special move tags.\n */\nfunction moveGamefilePiece(gamefile: FullGame, mesh: Mesh | undefined, coords: CoordsTagged): void {\n\t// Check if the move is a pawn promotion\n\tif (coords.promoteTrigger && !boardeditor.areInBoardEditor()) return onPromoteTrigger(coords);\n\n\tconst strippedCoords: Coords = moveutil.stripSpecialMoveTagsFromCoords(coords);\n\tconst moveTagged: MoveTagged = {\n\t\tstartCoords: pieceSelected!.coords,\n\t\tendCoords: strippedCoords,\n\t};\n\tspecialdetect.transferSpecialTags_FromCoordsToMove(coords, moveTagged);\n\n\t// Since making a move immediately cancels the current drag, we\n\t// have to note whether it was being dragged BEFORE we move it!\n\tconst wasBeingDragged = draganimation.areDraggingPiece();\n\n\tconst changes = boardeditor.areInBoardEditor()\n\t\t? normaltool.makeMoveEdit(gamefile, mesh, moveTagged).changes\n\t\t: isPremove\n\t\t\t? premoves.addPremove(gamefile, mesh, moveTagged).changes\n\t\t\t: movesequence.makeMove(gamefile, mesh, moveTagged).changes;\n\n\t// Not actually needed? Test it. To my knowledge, animation.ts will automatically cancel previous animations, since now it handles playing the sound for drops.\n\t// if (wasBeingDragged) animation.clearAnimations(); // We still need to clear any other animations in progress BEFORE we make the move (in case a secondary needs to be animated)\n\t// Don't animate the main piece if it's being dragged, but still animate secondary pieces affected by the move (like the rook in castling).\n\tconst animateMain = !wasBeingDragged;\n\tanimateMove(changes, true, animateMain, isPremove);\n\n\tif (!isPremove) GameBus.dispatch('user-move-played');\n\n\t// Do very last, so that isPremove doesn't get reset.\n\tunselectPiece();\n}\n\n/** Opens the promotion UI */\nfunction onPromoteTrigger(coords: CoordsTagged): void {\n\tconst color = typeutil.getColorFromType(pieceSelected!.type);\n\tguipromotion.open(color);\n\tperspective.unlockMouse();\n\tpawnIsPromotingOn = coords;\n\t// Delete the promoteTrigger now\n\tdelete coords.promoteTrigger;\n}\n\n/** Adds the promotion tag to the destination coordinates before making the move. */\nfunction makePromotionMove(gamefile: FullGame, mesh: Mesh | undefined): void {\n\tconst coords: CoordsTagged = pawnIsPromotingOn!;\n\t// Add the promoteTo tag\n\tcoords.promotion = promoteTo!;\n\tmoveGamefilePiece(gamefile, mesh, coords);\n\tperspective.relockMouse();\n}\n\n/** If the given pointer is currently being used to drag a piece, this stops using it. */\nfunction stealPointer(pointerIdToSteal: string): void {\n\tif (!pieceSelected || !draganimation.areDraggingPiece()) return;\n\tconst pointerDraggingPiece = draganimation.getPointerIdDraggingPiece();\n\tif (pointerDraggingPiece !== pointerIdToSteal) return; // Not the pointer dragging the piece, don't stop using it.\n\n\tif (draganimation.getDragParity()) return unselectPiece();\n\treturn draganimation.dropPiece();\n}\n\n// Rendering ---------------------------------------------------------------------------------------------------------\n\n/** Renders the translucent piece underneath your mouse when hovering over the blue legal move fields. */\nfunction renderGhostPiece(): void {\n\tconst mouseKeybind = keybinds.getPieceSelectionMouseButton();\n\tif (mouseKeybind === undefined) return; // Nothing assigned to selecting pieces currently, can't move piece => shouldn't render ghost piece.\n\n\tif (\n\t\t!pieceSelected ||\n\t\t!hoverSquare ||\n\t\t!hoverSquareLegal ||\n\t\tdraganimation.areDraggingPiece() ||\n\t\tlistener_overlay.isMouseTouch(mouseKeybind) ||\n\t\tconfig.VIDEO_MODE\n\t)\n\t\treturn;\n\tconst rawType = typeutil.getRawType(pieceSelected.type);\n\tif (typeutil.SVGLESS_TYPES.has(rawType)) return; // No svg/texture for this piece (void), don't render the ghost image.\n\n\tpieces.renderGhostPiece(pieceSelected!.type, hoverSquare);\n}\n\n// Exports ------------------------------------------------------------------------------------\n\nexport default {\n\tisAPieceSelected,\n\tgetPieceSelected,\n\treselectPiece,\n\tunselectPiece,\n\tgetLegalMovesOfSelectedPiece,\n\tgetSquarePawnIsCurrentlyPromotingOn,\n\tgetEditMode,\n\ttoggleEditMode,\n\tdisableEditMode,\n\tenableEditMode,\n\tpromoteToType,\n\tupdate,\n\trenderGhostPiece,\n\tisOpponentPieceSelected,\n\tarePremoving,\n\tstealPointer,\n\tselectPiece,\n\tcanSelectPieceType,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/config.ts",
    "content": "// src/client/scripts/esm/game/config.ts\n\n/** This script contains our game configurations. */\n\nimport docutil from '../util/docutil.js';\n\n/** Video mode disables the rendering of some items, making making recordings more immersive. */\nconst VIDEO_MODE: boolean = false;\n\n/**\n * True if the current page is running on a local environment (localhost or local IP).\n * If so, some dev/debugging features are enabled.\n * Also, the main menu background stops moving after 2 seconds instead of 30.\n */\nconst DEV_BUILD: boolean = docutil.isLocalEnvironment();\n\nexport default {\n\tVIDEO_MODE,\n\tDEV_BUILD,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/boardeditor/actions/guiclearposition.ts",
    "content": "// src/client/scripts/esm/game/gui/boardeditor/actions/guiclearposition.ts\n\n/**\n * Manages the GUI popup window for the Clear position button of the Board Editor\n */\n\nimport eactions from '../../../boardeditor/actions/eactions';\nimport guipause from '../../guipause';\nimport guifloatingwindow from '../guifloatingwindow';\nimport { listener_document } from '../../../chess/game';\n\n// Elements ----------------------------------------------------------\n\n/** The button the toggles visibility of the Start local game popup window. */\nconst element_clearbutton = document.getElementById('clearall')!;\n\n/** The actual window of the Game Rules popup. */\nconst element_window = document.getElementById('clear-position-UI')!;\nconst element_header = document.getElementById('clear-position-UI-header')!;\nconst element_closeButton = document.getElementById('close-clear-position-UI')!;\n\nconst yesButton = document.getElementById('clear-position-yes')!;\nconst noButton = document.getElementById('clear-position-no')!;\n\n// Create floating window -------------------------------------\n\nconst floatingWindow = guifloatingwindow.create({\n\twindowEl: element_window,\n\theaderEl: element_header,\n\tcloseButtonEl: element_closeButton,\n\tonOpen,\n\tonClose,\n});\n\n// Toggling ---------------------------------------------\n\nfunction onOpen(): void {\n\telement_clearbutton.classList.add('active');\n\tinitClearPositionUIListeners();\n}\n\nfunction onClose(resetPositioning: boolean): void {\n\tif (resetPositioning) floatingWindow.resetPositioning();\n\telement_clearbutton.classList.remove('active');\n\tcloseClearPositionUIListeners();\n}\n\n// Gamerules-specific listeners -------------------------------------------\n\nfunction initClearPositionUIListeners(): void {\n\tyesButton.addEventListener('click', onYesButtonPress);\n\tnoButton.addEventListener('click', onNoButtonPress);\n\tdocument.addEventListener('keydown', onKeyDown);\n}\n\nfunction closeClearPositionUIListeners(): void {\n\tyesButton.removeEventListener('click', onYesButtonPress);\n\tnoButton.removeEventListener('click', onNoButtonPress);\n\tdocument.removeEventListener('keydown', onKeyDown);\n}\n\n// Utilities---------------------------------------------------------------------\n\nfunction onKeyDown(e: KeyboardEvent): void {\n\tif (e.key === 'Enter') onYesButtonPress();\n\telse if (e.key === 'Escape') {\n\t\t// Ensure priority when deciding who gets the escape key event\n\t\tif (guipause.areWePaused()) return;\n\t\tlistener_document.claimKey('Escape');\n\t\tonNoButtonPress();\n\t}\n}\n\nfunction onYesButtonPress(): void {\n\teactions.clearAll();\n\tfloatingWindow.close(false);\n}\n\nfunction onNoButtonPress(): void {\n\tfloatingWindow.close(false);\n}\n\n// Exports -----------------------------------------------------------------\n\nexport default {\n\topen: floatingWindow.open,\n\tclose: floatingWindow.close,\n\tisOpen: floatingWindow.isOpen,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/boardeditor/actions/guigamerules.ts",
    "content": "// src/client/scripts/esm/game/gui/boardeditor/actions/guigamerules.ts\n\n/**\n * Manages the GUI popup window for the Game Rules of the Board Editor\n */\n\nimport type { Edit } from '../../../../../../../shared/chess/logic/movepiece';\nimport type { Coords } from '../../../../../../../shared/chess/util/coordutil';\nimport type { UnboundedRectangle } from '../../../../../../../shared/util/math/bounds';\nimport type { GameruleWinCondition } from '../../../../../../../shared/chess/util/winconutil';\n\nimport bounds from '../../../../../../../shared/util/math/bounds';\nimport boardutil from '../../../../../../../shared/chess/util/boardutil';\nimport icnconverter from '../../../../../../../shared/chess/logic/icn/icnconverter';\nimport typeutil, {\n\tplayers as p,\n\trawTypes as r,\n\tRawType,\n} from '../../../../../../../shared/chess/util/typeutil';\n\nimport gameslot from '../../../chess/gameslot';\nimport boardeditor from '../../../boardeditor/boardeditor';\nimport edithistory from '../../../boardeditor/edithistory';\nimport guifloatingwindow from '../guifloatingwindow';\nimport egamerules, { GameRulesGUIinfo } from '../../../boardeditor/egamerules';\n\n// Elements ----------------------------------------------------------\n\n/** The button the toggles visibility of the Game Rules popup window. */\nconst element_gamerules = document.getElementById('gamerules')!;\n\n/** The actual window of the Game Rules popup. */\nconst element_window = document.getElementById('game-rules')!;\nconst element_header = document.getElementById('game-rules-header')!;\nconst element_closeButton = document.getElementById('close-rules')!;\n\nconst element_white = document.getElementById('rules-white')! as HTMLInputElement;\nconst element_black = document.getElementById('rules-black')! as HTMLInputElement;\nconst element_enPassantX = document.getElementById('rules-enpassant-x')! as HTMLInputElement;\nconst element_enPassantY = document.getElementById('rules-enpassant-y')! as HTMLInputElement;\nconst element_moveruleCurrent = document.getElementById(\n\t'rules-moverule-current',\n)! as HTMLInputElement;\nconst element_moveruleMax = document.getElementById('rules-moverule-max')! as HTMLInputElement;\nconst element_promotionranksWhite = document.getElementById(\n\t'rules-promotionranks-white',\n)! as HTMLInputElement;\nconst element_promotionranksBlack = document.getElementById(\n\t'rules-promotionranks-black',\n)! as HTMLInputElement;\nconst element_promotionpieces = document.getElementById(\n\t'rules-promotionpieces',\n)! as HTMLInputElement;\nconst element_checkmate = document.getElementById('rules-checkmate')! as HTMLInputElement;\nconst element_royalcapture = document.getElementById('rules-royalcapture')! as HTMLInputElement;\nconst element_allroyalscaptured = document.getElementById(\n\t'rules-allroyalscaptured',\n)! as HTMLInputElement;\nconst element_allpiecescaptured = document.getElementById(\n\t'rules-allpiecescaptured',\n)! as HTMLInputElement;\nconst element_pawnDoublePush = document.getElementById('rules-doublepush')! as HTMLInputElement;\nconst element_castling = document.getElementById('rules-castling')! as HTMLInputElement;\n\nconst element_borderLeft = document.getElementById('rules-border-left')! as HTMLInputElement;\nconst element_borderRight = document.getElementById('rules-border-right')! as HTMLInputElement;\nconst element_borderBottom = document.getElementById('rules-border-bottom')! as HTMLInputElement;\nconst element_borderTop = document.getElementById('rules-border-top')! as HTMLInputElement;\n\nconst elements_selectionList: HTMLInputElement[] = [\n\telement_white,\n\telement_black,\n\telement_enPassantX,\n\telement_enPassantY,\n\telement_moveruleCurrent,\n\telement_moveruleMax,\n\telement_promotionranksWhite,\n\telement_promotionranksBlack,\n\telement_promotionpieces,\n\telement_checkmate,\n\telement_royalcapture,\n\telement_allroyalscaptured,\n\telement_allpiecescaptured,\n\telement_pawnDoublePush,\n\telement_castling,\n\telement_borderLeft,\n\telement_borderRight,\n\telement_borderBottom,\n\telement_borderTop,\n];\n\n// Constants --------------------------------------------------------------\n\n/** Regexes for validating game rules input fields */\nconst integerRegex = new RegExp(String.raw`^${icnconverter.integerSource}$`);\nconst promotionRanksRegex = new RegExp(String.raw`^${icnconverter.promotionRanksSource}$`);\nconst promotionsAllowedRegex = new RegExp(String.raw`^${icnconverter.promotionsAllowedSource}$`);\n\n// Create floating window -------------------------------------\n\nconst floatingWindow = guifloatingwindow.create({\n\twindowEl: element_window,\n\theaderEl: element_header,\n\tcloseButtonEl: element_closeButton,\n\tinputElList: elements_selectionList,\n\tonOpen,\n\tonClose,\n});\n\n// Toggling ---------------------------------------------\n\nfunction onOpen(): void {\n\telement_gamerules.classList.add('active');\n\tinitGameRulesListeners();\n}\n\nfunction onClose(resetPositioning: boolean): void {\n\tif (resetPositioning) floatingWindow.resetPositioning();\n\telement_gamerules.classList.remove('active');\n\tcloseGameRulesListeners();\n}\n\n// Gamerules-specific listeners -------------------------------------------\n\nfunction initGameRulesListeners(): void {\n\telements_selectionList.forEach((el) => {\n\t\tel.addEventListener('blur', readGameRules);\n\t});\n}\n\nfunction closeGameRulesListeners(): void {\n\telements_selectionList.forEach((el) => {\n\t\tel.removeEventListener('blur', readGameRules);\n\t});\n}\n\n// Reading/Writing Game Rules -----------------------------------------------\n\n/** Reads the game rules inserted into the input boxes and updates egamerules.gameRulesGUIinfo */\nfunction readGameRules(): void {\n\t// playerToMove\n\tconst playerToMove = element_white.checked ? 'white' : 'black';\n\n\t// enPassant\n\tlet validEnPassantCoords = 0;\n\tconst enPassantX = element_enPassantX.value;\n\tif (integerRegex.test(enPassantX)) {\n\t\telement_enPassantX.classList.remove('invalid-input');\n\t\tvalidEnPassantCoords++;\n\t} else if (enPassantX === '') {\n\t\telement_enPassantX.classList.remove('invalid-input');\n\t} else {\n\t\telement_enPassantX.classList.add('invalid-input');\n\t}\n\n\tconst enPassantY = element_enPassantY.value;\n\tif (integerRegex.test(enPassantY)) {\n\t\telement_enPassantY.classList.remove('invalid-input');\n\t\tvalidEnPassantCoords++;\n\t} else if (enPassantY === '') {\n\t\telement_enPassantY.classList.remove('invalid-input');\n\t} else {\n\t\telement_enPassantY.classList.add('invalid-input');\n\t}\n\n\tconst enPassant =\n\t\tvalidEnPassantCoords === 2 ? { x: BigInt(enPassantX), y: BigInt(enPassantY) } : undefined;\n\n\t// moveRule\n\tlet validMoveRuleInputs = 0;\n\tconst moveRuleCurrent = element_moveruleCurrent.value;\n\tif (integerRegex.test(moveRuleCurrent) && Number(moveRuleCurrent) >= 0) {\n\t\telement_moveruleCurrent.classList.remove('invalid-input');\n\t\tvalidMoveRuleInputs++;\n\t} else if (moveRuleCurrent === '') {\n\t\telement_moveruleCurrent.classList.remove('invalid-input');\n\t} else {\n\t\telement_moveruleCurrent.classList.add('invalid-input');\n\t}\n\n\tconst moveRuleMax = element_moveruleMax.value;\n\tif (integerRegex.test(moveRuleMax) && Number(moveRuleMax) > 0) {\n\t\tif (validMoveRuleInputs === 1 && Number(moveRuleCurrent) > Number(moveRuleMax)) {\n\t\t\telement_moveruleMax.classList.add('invalid-input');\n\t\t} else {\n\t\t\telement_moveruleMax.classList.remove('invalid-input');\n\t\t\tvalidMoveRuleInputs++;\n\t\t}\n\t} else if (moveRuleMax === '') {\n\t\telement_moveruleMax.classList.remove('invalid-input');\n\t} else {\n\t\telement_moveruleMax.classList.add('invalid-input');\n\t}\n\n\t// prettier-ignore\n\tconst moveRule = (validMoveRuleInputs === 2 ? { current: Number(moveRuleCurrent), max: Number(moveRuleMax) } : undefined);\n\n\t// promotionRanks\n\tlet promotionRanksWhite: bigint[] = [];\n\tconst promotionRanksWhiteInput = element_promotionranksWhite.value;\n\tif (promotionRanksRegex.test(promotionRanksWhiteInput)) {\n\t\telement_promotionranksWhite.classList.remove('invalid-input');\n\t\tpromotionRanksWhite = [...new Set(promotionRanksWhiteInput.split(',').map(BigInt))];\n\t} else if (promotionRanksWhiteInput === '') {\n\t\telement_promotionranksWhite.classList.remove('invalid-input');\n\t} else {\n\t\telement_promotionranksWhite.classList.add('invalid-input');\n\t}\n\n\tlet promotionRanksBlack: bigint[] = [];\n\tconst promotionRanksBlackInput = element_promotionranksBlack.value;\n\tif (promotionRanksRegex.test(promotionRanksBlackInput)) {\n\t\telement_promotionranksBlack.classList.remove('invalid-input');\n\t\tpromotionRanksBlack = [...new Set(promotionRanksBlackInput.split(',').map(BigInt))];\n\t} else if (promotionRanksBlackInput === '') {\n\t\telement_promotionranksBlack.classList.remove('invalid-input');\n\t} else {\n\t\telement_promotionranksBlack.classList.add('invalid-input');\n\t}\n\n\t// prettier-ignore\n\tconst promotionRanks = (promotionRanksWhite.length === 0 && promotionRanksBlack.length === 0) ? undefined : {\n\t\twhite: promotionRanksWhite.length === 0 ? undefined : promotionRanksWhite,\n\t\tblack: promotionRanksBlack.length === 0 ? undefined : promotionRanksBlack\n\t};\n\n\t// promotions allowed\n\tlet promotionsAllowed: RawType[] | undefined = undefined;\n\tconst promotionsAllowedRaw = element_promotionpieces.value;\n\tpa: if (promotionsAllowedRegex.test(promotionsAllowedRaw)) {\n\t\tconst runningPromotionsAllowed: RawType[] = [];\n\n\t\tfor (const code of promotionsAllowedRaw.split(',')) {\n\t\t\tconst typeStr: string | undefined = icnconverter.piece_codes_inverted[code];\n\t\t\tif (typeStr === undefined) {\n\t\t\t\telement_promotionpieces.classList.add('invalid-input');\n\t\t\t\tbreak pa;\n\t\t\t}\n\t\t\tconst type = Number(typeStr);\n\t\t\tconst [rawType, color] = typeutil.splitType(type);\n\n\t\t\tif (\n\t\t\t\ttypeutil.royals.includes(rawType) || // Can't promote to royals\n\t\t\t\trawType === r.PAWN || // Can't promote to pawns\n\t\t\t\tcolor === p.NEUTRAL || // Can't promote to neutrals\n\t\t\t\trunningPromotionsAllowed.includes(rawType) // No duplicates\n\t\t\t) {\n\t\t\t\telement_promotionpieces.classList.add('invalid-input');\n\t\t\t\tbreak pa;\n\t\t\t}\n\n\t\t\trunningPromotionsAllowed.push(rawType);\n\t\t}\n\n\t\t// All promotion pieces are valid\n\t\telement_promotionpieces.classList.remove('invalid-input');\n\t\tpromotionsAllowed = runningPromotionsAllowed;\n\t} else if (promotionsAllowedRaw === '') {\n\t\telement_promotionpieces.classList.remove('invalid-input');\n\t} else {\n\t\telement_promotionpieces.classList.add('invalid-input');\n\t}\n\n\t// win conditions\n\tconst winConditions: GameruleWinCondition[] = [];\n\tif (element_checkmate.checked) winConditions.push('checkmate');\n\tif (element_royalcapture.checked) winConditions.push('royalcapture');\n\tif (element_allroyalscaptured.checked) winConditions.push('allroyalscaptured');\n\tif (element_allpiecescaptured.checked) winConditions.push('allpiecescaptured');\n\tif (winConditions.length === 0) winConditions.push(icnconverter.default_win_condition);\n\n\t// pawn double push\n\tlet pawnDoublePush: boolean | undefined = undefined;\n\tif (!element_pawnDoublePush.indeterminate) pawnDoublePush = element_pawnDoublePush.checked;\n\n\t// castling with rooks\n\tlet castling: boolean | undefined = undefined;\n\tif (!element_castling.indeterminate) castling = element_castling.checked;\n\n\t// World Border\n\tlet worldBorder: UnboundedRectangle | undefined = undefined;\n\tconst borderInputs = [\n\t\t{ el: element_borderLeft, val: element_borderLeft.value },\n\t\t{ el: element_borderRight, val: element_borderRight.value },\n\t\t{ el: element_borderBottom, val: element_borderBottom.value },\n\t\t{ el: element_borderTop, val: element_borderTop.value },\n\t];\n\n\tconst gamefile = gameslot.getGamefile()!;\n\n\tconst anyBorderSet = borderInputs.some((input) => input.val !== '');\n\tif (!anyBorderSet) {\n\t\t// All empty -> Valid (Undefined)\n\t\tborderInputs.forEach((input) => input.el.classList.remove('invalid-input'));\n\t\tworldBorder = undefined;\n\t} else {\n\t\t// Must be valid integers or empty, and must be ascending\n\t\t// Empty represents Infinity or -Infinity\n\t\tlet leftValid = !element_borderLeft.value || integerRegex.test(element_borderLeft.value);\n\t\tlet rightValid =\n\t\t\t!element_borderRight.value ||\n\t\t\t(integerRegex.test(element_borderRight.value) &&\n\t\t\t\t(!leftValid ||\n\t\t\t\t\t!element_borderLeft.value ||\n\t\t\t\t\tBigInt(element_borderRight.value) >= BigInt(element_borderLeft.value)));\n\t\tlet bottomValid =\n\t\t\t!element_borderBottom.value || integerRegex.test(element_borderBottom.value);\n\t\tlet topValid =\n\t\t\t!element_borderTop.value ||\n\t\t\t(integerRegex.test(element_borderTop.value) &&\n\t\t\t\t(!bottomValid ||\n\t\t\t\t\t!element_borderBottom.value ||\n\t\t\t\t\tBigInt(element_borderTop.value) >= BigInt(element_borderBottom.value)));\n\n\t\tif (leftValid && rightValid && bottomValid && topValid) {\n\t\t\t// Initial values look valid\n\t\t\tworldBorder = {\n\t\t\t\tleft: element_borderLeft.value ? BigInt(element_borderLeft.value) : null,\n\t\t\t\tright: element_borderRight.value ? BigInt(element_borderRight.value) : null,\n\t\t\t\tbottom: element_borderBottom.value ? BigInt(element_borderBottom.value) : null,\n\t\t\t\ttop: element_borderTop.value ? BigInt(element_borderTop.value) : null,\n\t\t\t};\n\t\t\tif (\n\t\t\t\tworldBorder.left === null &&\n\t\t\t\tworldBorder.right === null &&\n\t\t\t\tworldBorder.bottom === null &&\n\t\t\t\tworldBorder.top === null\n\t\t\t)\n\t\t\t\tworldBorder = undefined;\n\n\t\t\t// Further check if all pieces are within the border\n\t\t\tif (worldBorder) {\n\t\t\t\tconst allCoords = boardutil.getCoordsOfAllPieces(gamefile.boardsim.pieces);\n\t\t\t\tif (allCoords.some((coords) => !bounds.boxContainsSquare(worldBorder!, coords))) {\n\t\t\t\t\t// One or more pieces are outside the border -> All invalid\n\t\t\t\t\tleftValid = false;\n\t\t\t\t\trightValid = false;\n\t\t\t\t\tbottomValid = false;\n\t\t\t\t\ttopValid = false;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Mark invalid fields as invalid.\n\t\tif (!leftValid) element_borderLeft.classList.add('invalid-input');\n\t\telse element_borderLeft.classList.remove('invalid-input');\n\t\tif (!rightValid) element_borderRight.classList.add('invalid-input');\n\t\telse element_borderRight.classList.remove('invalid-input');\n\t\tif (!bottomValid) element_borderBottom.classList.add('invalid-input');\n\t\telse element_borderBottom.classList.remove('invalid-input');\n\t\tif (!topValid) element_borderTop.classList.add('invalid-input');\n\t\telse element_borderTop.classList.remove('invalid-input');\n\n\t\tif (!leftValid || !rightValid || !bottomValid || !topValid) worldBorder = undefined;\n\t}\n\n\tconst gameRules: GameRulesGUIinfo = {\n\t\tplayerToMove,\n\t\tenPassant,\n\t\tmoveRule,\n\t\tpromotionRanks,\n\t\tpromotionsAllowed,\n\t\twinConditions,\n\t\tpawnDoublePush,\n\t\tcastling,\n\t\tworldBorder,\n\t};\n\n\t// Update gamefile properties for rendering purposes and correct legal move calculation\n\t// prettier-ignore\n\tconst enpassantSquare: Coords | undefined = gameRules.enPassant !== undefined ? [gameRules.enPassant.x, gameRules.enPassant.y] : undefined;\n\tegamerules.updateGamefileProperties(\n\t\tenpassantSquare,\n\t\tgameRules.promotionRanks,\n\t\tgameRules.playerToMove,\n\t\tgameRules.worldBorder,\n\t);\n\n\tconst mesh = gameslot.getMesh()!;\n\tconst edit: Edit = { changes: [], state: { local: [], global: [] } };\n\n\t// Fetch previous values before updating, to skip queuing when unchanged and prevent unnecessary edit history bloat.\n\tconst previousPositionDependentGameRules = egamerules.getPositionDependentGameRules();\n\n\t// Update pawn double push specialrights of position, only if the value changed\n\tif (\n\t\tgameRules.pawnDoublePush !== undefined &&\n\t\tgameRules.pawnDoublePush !== previousPositionDependentGameRules.pawnDoublePush\n\t)\n\t\tegamerules.queueToggleGlobalPawnDoublePush(gameRules.pawnDoublePush, edit);\n\n\t// Update castling with rooks specialrights of position, only if the value changed\n\tif (\n\t\tgameRules.castling !== undefined &&\n\t\tgameRules.castling !== previousPositionDependentGameRules.castling\n\t)\n\t\tegamerules.queueToggleGlobalCastlingWithRooks(gameRules.castling, edit);\n\n\t// Upate boardeditor.gamerulesGUIinfo\n\tegamerules.updateGamerulesGUIinfo(gameRules);\n\n\tedithistory.runEdit(gamefile, mesh, edit, true);\n\tedithistory.addEditToHistory(edit);\n\t// Mark as dirty anyway, since edithistory.addEditToHistory() may early exit\n\t// if the edit has no changes, but gamerule changes still consider the position dirty.\n\tboardeditor.markPositionDirty();\n}\n\n/** Sets the game rules in the game rules GUI according to the supplied GameRulesGUIinfo object*/\nfunction setGameRules(gamerulesGUIinfo: GameRulesGUIinfo): void {\n\tif (gamerulesGUIinfo.playerToMove === 'white') {\n\t\telement_white.checked = true;\n\t\telement_black.checked = false;\n\t} else {\n\t\telement_white.checked = false;\n\t\telement_black.checked = true;\n\t}\n\n\tif (gamerulesGUIinfo.enPassant !== undefined) {\n\t\telement_enPassantX.value = String(gamerulesGUIinfo.enPassant.x);\n\t\telement_enPassantY.value = String(gamerulesGUIinfo.enPassant.y);\n\t} else {\n\t\telement_enPassantX.value = '';\n\t\telement_enPassantY.value = '';\n\t}\n\n\tif (gamerulesGUIinfo.moveRule !== undefined) {\n\t\telement_moveruleCurrent.value = String(gamerulesGUIinfo.moveRule.current);\n\t\telement_moveruleMax.value = String(gamerulesGUIinfo.moveRule.max);\n\t} else {\n\t\telement_moveruleCurrent.value = '';\n\t\telement_moveruleMax.value = '';\n\t}\n\n\tif (gamerulesGUIinfo.promotionRanks !== undefined) {\n\t\tif (gamerulesGUIinfo.promotionRanks.white !== undefined) {\n\t\t\telement_promotionranksWhite.value = gamerulesGUIinfo.promotionRanks.white\n\t\t\t\t.map((bigint) => String(bigint))\n\t\t\t\t.join(',');\n\t\t} else element_promotionranksWhite.value = '';\n\t\tif (gamerulesGUIinfo.promotionRanks.black !== undefined) {\n\t\t\telement_promotionranksBlack.value = gamerulesGUIinfo.promotionRanks.black\n\t\t\t\t.map((bigint) => String(bigint))\n\t\t\t\t.join(',');\n\t\t} else element_promotionranksBlack.value = '';\n\t} else {\n\t\telement_promotionranksWhite.value = '';\n\t\telement_promotionranksBlack.value = '';\n\t}\n\n\tif (gamerulesGUIinfo.promotionsAllowed !== undefined) {\n\t\telement_promotionpieces.value = gamerulesGUIinfo.promotionsAllowed\n\t\t\t.map((type) => icnconverter.piece_codes_raw[type])\n\t\t\t.join(',')\n\t\t\t.toUpperCase();\n\t} else element_promotionpieces.value = '';\n\n\telement_checkmate.checked = gamerulesGUIinfo.winConditions.includes('checkmate');\n\telement_royalcapture.checked = gamerulesGUIinfo.winConditions.includes('royalcapture');\n\telement_allroyalscaptured.checked =\n\t\tgamerulesGUIinfo.winConditions.includes('allroyalscaptured');\n\telement_allpiecescaptured.checked =\n\t\tgamerulesGUIinfo.winConditions.includes('allpiecescaptured');\n\n\tif (gamerulesGUIinfo.pawnDoublePush === undefined) {\n\t\telement_pawnDoublePush.indeterminate = true;\n\t\telement_pawnDoublePush.checked = false;\n\t} else {\n\t\telement_pawnDoublePush.indeterminate = false;\n\t\telement_pawnDoublePush.checked = gamerulesGUIinfo.pawnDoublePush;\n\t}\n\n\tif (gamerulesGUIinfo.castling === undefined) {\n\t\telement_castling.indeterminate = true;\n\t\telement_castling.checked = false;\n\t} else {\n\t\telement_castling.indeterminate = false;\n\t\telement_castling.checked = gamerulesGUIinfo.castling;\n\t}\n\n\t// World Border\n\tif (gamerulesGUIinfo.worldBorder !== undefined) {\n\t\telement_borderLeft.value = String(gamerulesGUIinfo.worldBorder.left ?? '');\n\t\telement_borderRight.value = String(gamerulesGUIinfo.worldBorder.right ?? '');\n\t\telement_borderBottom.value = String(gamerulesGUIinfo.worldBorder.bottom ?? '');\n\t\telement_borderTop.value = String(gamerulesGUIinfo.worldBorder.top ?? '');\n\t} else {\n\t\telement_borderLeft.value = '';\n\t\telement_borderRight.value = '';\n\t\telement_borderBottom.value = '';\n\t\telement_borderTop.value = '';\n\t}\n\n\t// Since we manually set all inputs in this function, they are all valid\n\telement_enPassantX.classList.remove('invalid-input');\n\telement_enPassantY.classList.remove('invalid-input');\n\telement_moveruleCurrent.classList.remove('invalid-input');\n\telement_moveruleMax.classList.remove('invalid-input');\n\telement_promotionranksWhite.classList.remove('invalid-input');\n\telement_promotionranksBlack.classList.remove('invalid-input');\n\telement_promotionpieces.classList.remove('invalid-input');\n\n\telement_borderLeft.classList.remove('invalid-input');\n\telement_borderRight.classList.remove('invalid-input');\n\telement_borderBottom.classList.remove('invalid-input');\n\telement_borderTop.classList.remove('invalid-input');\n}\n\n// Exports -----------------------------------------------------------------\n\nexport default {\n\topen: floatingWindow.open,\n\tclose: floatingWindow.close,\n\tisOpen: floatingWindow.isOpen,\n\tsetGameRules,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/boardeditor/actions/guiresetposition.ts",
    "content": "// src/client/scripts/esm/game/gui/boardeditor/actions/guiresetposition.ts\n\n/**\n * Manages the GUI popup window for the Reset position button of the Board Editor\n */\n\nimport eactions from '../../../boardeditor/actions/eactions';\nimport guipause from '../../guipause';\nimport guifloatingwindow from '../guifloatingwindow';\nimport { listener_document } from '../../../chess/game';\n\n// Elements ----------------------------------------------------------\n\n/** The button the toggles visibility of the Start local game popup window. */\nconst element_resetbutton = document.getElementById('reset')!;\n\n/** The actual window of the Game Rules popup. */\nconst element_window = document.getElementById('reset-position-UI')!;\nconst element_header = document.getElementById('reset-position-UI-header')!;\nconst element_closeButton = document.getElementById('close-reset-position-UI')!;\n\nconst yesButton = document.getElementById('reset-position-yes')!;\nconst noButton = document.getElementById('reset-position-no')!;\n\n// Create floating window -------------------------------------\n\nconst floatingWindow = guifloatingwindow.create({\n\twindowEl: element_window,\n\theaderEl: element_header,\n\tcloseButtonEl: element_closeButton,\n\tonOpen,\n\tonClose,\n});\n\n// Toggling ---------------------------------------------\n\nfunction onOpen(): void {\n\telement_resetbutton.classList.add('active');\n\tinitResetPositionUIListeners();\n}\n\nfunction onClose(resetPositioning: boolean): void {\n\tif (resetPositioning) floatingWindow.resetPositioning();\n\telement_resetbutton.classList.remove('active');\n\tcloseResetPositionUIListeners();\n}\n\n// Gamerules-specific listeners -------------------------------------------\n\nfunction initResetPositionUIListeners(): void {\n\tyesButton.addEventListener('click', onYesButtonPress);\n\tnoButton.addEventListener('click', onNoButtonPress);\n\tdocument.addEventListener('keydown', onKeyDown);\n}\n\nfunction closeResetPositionUIListeners(): void {\n\tyesButton.removeEventListener('click', onYesButtonPress);\n\tnoButton.removeEventListener('click', onNoButtonPress);\n\tdocument.removeEventListener('keydown', onKeyDown);\n}\n\n// Utilities---------------------------------------------------------------------\n\nfunction onKeyDown(e: KeyboardEvent): void {\n\tif (e.key === 'Enter') onYesButtonPress();\n\telse if (e.key === 'Escape') {\n\t\t// Ensure priority when deciding who gets the escape key event\n\t\tif (guipause.areWePaused()) return;\n\t\tlistener_document.claimKey('Escape');\n\t\tonNoButtonPress();\n\t}\n}\n\nfunction onYesButtonPress(): void {\n\teactions.reset();\n\tfloatingWindow.close(false);\n}\n\nfunction onNoButtonPress(): void {\n\tfloatingWindow.close(false);\n}\n\n// Exports -----------------------------------------------------------------\n\nexport default {\n\topen: floatingWindow.open,\n\tclose: floatingWindow.close,\n\tisOpen: floatingWindow.isOpen,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/boardeditor/actions/guistartenginegame.ts",
    "content": "// src/client/scripts/esm/game/gui/boardeditor/actions/guistartenginegame.ts\n\n/**\n * Manages the GUI popup window for the Start engine game button of the Board Editor\n */\n\nimport type { Player } from '../../../../../../../shared/chess/util/typeutil';\nimport type { TimeControl } from '../../../../../../../shared/types';\n\nimport icnconverter from '../../../../../../../shared/chess/logic/icn/icnconverter';\nimport { players as p } from '../../../../../../../shared/chess/util/typeutil';\n\nimport eactions from '../../../boardeditor/actions/eactions';\nimport gameslot from '../../../chess/gameslot';\nimport guipause from '../../guipause';\nimport guifloatingwindow from '../guifloatingwindow';\nimport { listener_document } from '../../../chess/game';\n\n// Types -------------------------------------------------------------\n\ninterface EngineUIConfig {\n\tyouAreColor: Player;\n\ttimeControl: TimeControl;\n\tstrengthLevel: 1 | 2 | 3;\n\tsetDefaultWorldBorder: boolean;\n}\n\n// Constants ----------------------------------------------------------\n\nconst timeControlRegex = new RegExp(\n\tString.raw`^${icnconverter.wholeNumberSource}\\+${icnconverter.wholeNumberSource}$`,\n);\n\n// Elements ----------------------------------------------------------\n\n/** The button the toggles visibility of the Start engine game popup window. */\nconst element_enginegamebutton = document.getElementById('start-engine-game')!;\n\n/** The actual window of the Game Rules popup. */\nconst element_window = document.getElementById('engine-game-UI')!;\nconst element_header = document.getElementById('engine-game-UI-header')!;\nconst element_closeButton = document.getElementById('close-engine-game-UI')!;\n\nconst noButton = document.getElementById('start-engine-game-no')!;\nconst yesButton = document.getElementById('start-engine-game-yes')!;\n\nconst element_white = document.getElementById('engine-game-white')! as HTMLInputElement;\nconst element_black = document.getElementById('engine-game-black')! as HTMLInputElement;\n\nconst element_timecontrol = document.getElementById('engine-game-timecontrol')! as HTMLInputElement;\n\nconst element_easy = document.getElementById('engine-game-easy')! as HTMLInputElement;\nconst element_medium = document.getElementById('engine-game-medium')! as HTMLInputElement;\nconst element_hard = document.getElementById('engine-game-hard')! as HTMLInputElement;\n\nconst element_noborder = document.getElementById('engine-game-border-no')! as HTMLInputElement;\nconst element_yesborder = document.getElementById('engine-game-border-yes')! as HTMLInputElement;\n\nconst elements_selectionList: HTMLInputElement[] = [\n\telement_white,\n\telement_black,\n\telement_timecontrol,\n\telement_easy,\n\telement_medium,\n\telement_hard,\n\telement_noborder,\n\telement_yesborder,\n];\n\n// Create floating window ----------------------------------------------------\n\nconst floatingWindow = guifloatingwindow.create({\n\twindowEl: element_window,\n\theaderEl: element_header,\n\tcloseButtonEl: element_closeButton,\n\tinputElList: elements_selectionList,\n\tonOpen,\n\tonClose,\n});\n\n// Toggling ------------------------------------------------------------\n\nfunction onOpen(): void {\n\tupdateEngineUIcontents();\n\telement_enginegamebutton.classList.add('active');\n\tinitEngineGameUIListeners();\n}\n\nfunction onClose(resetPositioning = false): void {\n\tif (resetPositioning) floatingWindow.resetPositioning();\n\telement_enginegamebutton.classList.remove('active');\n\tcloseEngineGameUIListeners();\n}\n\n// Enginegame-UI-specific listeners -------------------------------------------\n\nfunction initEngineGameUIListeners(): void {\n\telements_selectionList.forEach((el) => {\n\t\tel.addEventListener('blur', readEngineUIConfig);\n\t});\n\tyesButton.addEventListener('click', onYesButtonPress);\n\tnoButton.addEventListener('click', onNoButtonPress);\n\tdocument.addEventListener('keydown', onKeyDown);\n}\n\nfunction closeEngineGameUIListeners(): void {\n\telements_selectionList.forEach((el) => {\n\t\tel.removeEventListener('blur', readEngineUIConfig);\n\t});\n\tyesButton.removeEventListener('click', onYesButtonPress);\n\tnoButton.removeEventListener('click', onNoButtonPress);\n\tdocument.removeEventListener('keydown', onKeyDown);\n}\n\n// Utilities ----------------------------------------------------------------------\n\nfunction onKeyDown(e: KeyboardEvent): void {\n\tif (e.key === 'Enter' && !(e.target instanceof HTMLInputElement && e.target.type === 'text'))\n\t\tonYesButtonPress();\n\telse if (e.key === 'Escape') {\n\t\t// Ensure priority when deciding who gets the escape key event\n\t\tif (guipause.areWePaused()) return;\n\t\tlistener_document.claimKey('Escape');\n\t\tonNoButtonPress();\n\t}\n}\n\nfunction onYesButtonPress(): void {\n\tconst engineUIConfig = readEngineUIConfig();\n\teactions.startEngineGame(engineUIConfig);\n}\n\nfunction onNoButtonPress(): void {\n\tfloatingWindow.close(false);\n}\n\n/** Updates the engineconfig UI values when opened */\nfunction updateEngineUIcontents(): void {\n\tconst existingBorder = gameslot.getGamefile()?.basegame.gameRules.worldBorder !== undefined;\n\telement_noborder.checked = existingBorder;\n\telement_yesborder.checked = !existingBorder;\n}\n\n/** Constructs the engineconfig by reading the input boxes, and validating them */\nfunction readEngineUIConfig(): EngineUIConfig {\n\t// Player color\n\tconst youAreColor = element_white.checked ? p.WHITE : p.BLACK;\n\n\t// Time control\n\tlet timeControl: TimeControl = '-';\n\tconst timeControlRaw = element_timecontrol.value;\n\tif (timeControlRaw === '-' || timeControlRaw === '') {\n\t\telement_timecontrol.classList.remove('invalid-input');\n\t} else if (timeControlRegex.test(timeControlRaw)) {\n\t\tconst [a, b] = timeControlRaw.split('+').map(Number);\n\t\tif (a !== undefined && b !== undefined && Number.isFinite(a) && Number.isFinite(b)) {\n\t\t\ttimeControl = `${a}+${b}`;\n\t\t\telement_timecontrol.classList.remove('invalid-input');\n\t\t} else {\n\t\t\telement_timecontrol.classList.add('invalid-input');\n\t\t}\n\t} else {\n\t\telement_timecontrol.classList.add('invalid-input');\n\t}\n\n\t// Strength level\n\tconst strengthLevel = element_hard.checked ? 3 : element_medium.checked ? 2 : 1;\n\n\t// Set default world border\n\tconst setDefaultWorldBorder = element_yesborder.checked ? true : false;\n\n\treturn { youAreColor, timeControl, strengthLevel, setDefaultWorldBorder };\n}\n\n// Exports -----------------------------------------------------------------\n\nexport default {\n\topen: floatingWindow.open,\n\tclose: floatingWindow.close,\n\tisOpen: floatingWindow.isOpen,\n};\n\nexport type { EngineUIConfig };\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/boardeditor/actions/guistartlocalgame.ts",
    "content": "// src/client/scripts/esm/game/gui/boardeditor/actions/guistartlocalgame.ts\n\n/**\n * Manages the GUI popup window for the Start local game button of the Board Editor\n */\n\nimport eactions from '../../../boardeditor/actions/eactions';\nimport guipause from '../../guipause';\nimport guifloatingwindow from '../guifloatingwindow';\nimport { listener_document } from '../../../chess/game';\n\n// Elements ----------------------------------------------------------\n\n/** The button the toggles visibility of the Start local game popup window. */\nconst element_localgamebutton = document.getElementById('start-local-game')!;\n\n/** The actual window of the Game Rules popup. */\nconst element_window = document.getElementById('local-game-UI')!;\nconst element_header = document.getElementById('local-game-UI-header')!;\nconst element_closeButton = document.getElementById('close-local-game-UI')!;\n\nconst yesButton = document.getElementById('start-local-game-yes')!;\nconst noButton = document.getElementById('start-local-game-no')!;\n\n// Create floating window -------------------------------------\n\nconst floatingWindow = guifloatingwindow.create({\n\twindowEl: element_window,\n\theaderEl: element_header,\n\tcloseButtonEl: element_closeButton,\n\tonOpen,\n\tonClose,\n});\n\n// Toggling ---------------------------------------------\n\nfunction onOpen(): void {\n\telement_localgamebutton.classList.add('active');\n\tinitLocalGameUIListeners();\n}\n\nfunction onClose(resetPositioning: boolean): void {\n\tif (resetPositioning) floatingWindow.resetPositioning();\n\telement_localgamebutton.classList.remove('active');\n\tcloseLocalGameUIListeners();\n}\n\n// Gamerules-specific listeners -------------------------------------------\n\nfunction initLocalGameUIListeners(): void {\n\tyesButton.addEventListener('click', onYesButtonPress);\n\tnoButton.addEventListener('click', onNoButtonPress);\n\tdocument.addEventListener('keydown', onKeyDown);\n}\n\nfunction closeLocalGameUIListeners(): void {\n\tyesButton.removeEventListener('click', onYesButtonPress);\n\tnoButton.removeEventListener('click', onNoButtonPress);\n\tdocument.removeEventListener('keydown', onKeyDown);\n}\n\n// Utilities---------------------------------------------------------------------\n\nfunction onKeyDown(e: KeyboardEvent): void {\n\tif (e.key === 'Enter') onYesButtonPress();\n\telse if (e.key === 'Escape') {\n\t\t// Ensure priority when deciding who gets the escape key event\n\t\tif (guipause.areWePaused()) return;\n\t\tlistener_document.claimKey('Escape');\n\t\tonNoButtonPress();\n\t}\n}\n\nfunction onYesButtonPress(): void {\n\teactions.startLocalGame();\n}\n\nfunction onNoButtonPress(): void {\n\tfloatingWindow.close(false);\n}\n\n// Exports -----------------------------------------------------------------\n\nexport default {\n\topen: floatingWindow.open,\n\tclose: floatingWindow.close,\n\tisOpen: floatingWindow.isOpen,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/boardeditor/actions/loadposition/guiloadposition.ts",
    "content": "// src/client/scripts/esm/game/gui/boardeditor/actions/loadposition/guiloadposition.ts\n\n/**\n * Manages the GUI popup window for the Load Positions UI of the board editor.\n * Coordinates the floating window, save-as form, confirmation modal, and position list.\n */\n\nimport editorutil from '../../../../../../../../shared/util/editorutil';\n\nimport esave from '../../../../boardeditor/actions/esave';\nimport boardeditor from '../../../../boardeditor/boardeditor';\nimport guifloatingwindow from '../../guifloatingwindow';\nimport guiloadpositionmodal from './guiloadpositionmodal';\nimport guiloadpositionsavelist from './guiloadpositionsavelist';\n\n// Elements ----------------------------------------------------------\n\n/** The button the toggles visibility of the Load Position game popup window. */\nconst element_loadbutton = document.getElementById('load-position')!;\n\n/** The actual window of the Load Positions popup. */\nconst element_window = document.getElementById('load-position-UI')!;\nconst element_header = document.getElementById('load-position-UI-header')!;\nconst element_headerText = document.getElementById('load-position-UI-header-text')!;\nconst element_closeButton = document.getElementById('close-load-position-UI')!;\n/** The button the toggles visibility of the Save Position As popup window. */\nconst element_saveasbutton = document.getElementById('save-position-as')!;\n\n/** The container for entering a new position name. */\nconst element_enterPositionName = document.getElementById('enter-position-name')!;\n/** Textbox for entering position name */\nconst element_saveAsPositionName = document.getElementById(\n\t'save-as-position-name',\n)! as HTMLInputElement;\n/** \"Save\" button in UI */\nconst element_saveCurrentPositionButton = document.getElementById('save-position-button')!;\n\n// Variables ----------------------------------------------------------------\n\n/** The current open/close mode of the Load Position UI */\nlet mode: 'load' | 'save-as' | undefined = undefined;\n\n// Create floating window -------------------------------------\n\nconst floatingWindow = guifloatingwindow.create({\n\twindowEl: element_window,\n\theaderEl: element_header,\n\tcloseButtonEl: element_closeButton,\n\tonOpen,\n\tonClose,\n});\n\n// Toggling ------------------------------------------------\n\nfunction onOpen(): void {\n\tguiloadpositionsavelist.updateSavedPositionListUI();\n}\n\nfunction openLoadPosition(): void {\n\telement_headerText.textContent = translations.editor.load_position_header;\n\telement_enterPositionName.classList.add('hidden');\n\telement_loadbutton.classList.add('active');\n\telement_saveasbutton.classList.remove('active');\n\tfloatingWindow.open();\n\tmode = 'load';\n}\n\nfunction openSavePositionAs(): void {\n\telement_headerText.textContent = translations.editor.save_position_as_header;\n\telement_enterPositionName.classList.remove('hidden');\n\telement_saveasbutton.classList.add('active');\n\telement_loadbutton.classList.remove('active');\n\tfloatingWindow.open();\n\tmode = 'save-as';\n\tinitSavePositionUIListeners();\n\telement_saveAsPositionName.focus();\n}\n\nfunction onClose(resetPositioning = false): void {\n\tif (resetPositioning) floatingWindow.resetPositioning();\n\tguiloadpositionmodal.closeModal();\n\telement_loadbutton.classList.remove('active');\n\telement_saveasbutton.classList.remove('active');\n\tmode = undefined;\n\tguiloadpositionsavelist.unregisterAllPositionButtonListeners();\n\tguiloadpositionsavelist.clearSavedPositionList();\n\tcloseSavePositionUIListeners();\n\telement_saveAsPositionName.value = '';\n}\n\n/** Gets the current open/close mode of the Load Position UI */\nfunction getMode(): typeof mode {\n\treturn mode;\n}\n\n// Save-as form listeners -------------------------------------------\n\nfunction initSavePositionUIListeners(): void {\n\telement_saveCurrentPositionButton.addEventListener('click', onSaveButtonPress);\n\tdocument.addEventListener('keydown', onSaveKeyDown);\n}\n\nfunction closeSavePositionUIListeners(): void {\n\telement_saveCurrentPositionButton.removeEventListener('click', onSaveButtonPress);\n\tdocument.removeEventListener('keydown', onSaveKeyDown);\n}\n\nfunction onSaveKeyDown(e: KeyboardEvent): void {\n\t// Only trigger save on Enter when the confirmation modal is not open\n\tif (e.key === 'Enter' && !guiloadpositionmodal.isOpen()) onSaveButtonPress();\n}\n\n// Save-as form functions -------------------------------------------\n\n/** Gets executed when the \"save\" button is pressed. */\nasync function onSaveButtonPress(): Promise<void> {\n\tconst positionname = element_saveAsPositionName.value.trim(); // Disallow pure whitespace names\n\tif (positionname === '') return;\n\tif (positionname.length > editorutil.MAX_POSITION_NAME_LENGTH) {\n\t\tconsole.error(\n\t\t\t`This should not happen, position name input box is restricted to ${editorutil.MAX_POSITION_NAME_LENGTH} chars, you submitted ${positionname.length} chars.`,\n\t\t);\n\t\treturn;\n\t}\n\n\t// If a local save already exists, ask to overwrite it locally\n\tif (await esave.localSaveExists(positionname)) {\n\t\tguiloadpositionmodal.openModal('overwrite_save', positionname, async () => {\n\t\t\tawait esave.saveLocal(positionname);\n\t\t\tboardeditor.setActivePosition({ name: positionname, storage_type: 'local' });\n\t\t\tguiloadpositionsavelist.updateSavedPositionListUI();\n\t\t});\n\t\treturn;\n\t}\n\n\t// No existing save found — save locally\n\tawait esave.saveLocal(positionname);\n\tboardeditor.setActivePosition({ name: positionname, storage_type: 'local' });\n\telement_saveAsPositionName.value = '';\n\tguiloadpositionsavelist.updateSavedPositionListUI();\n}\n\n// Exports -----------------------------------------------------------------\n\nexport default {\n\topenLoadPosition,\n\topenSavePositionAs,\n\tclose: floatingWindow.close,\n\tgetMode,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/boardeditor/actions/loadposition/guiloadpositionmodal.ts",
    "content": "// src/client/scripts/esm/game/gui/boardeditor/actions/loadposition/guiloadpositionmodal.ts\n\n/**\n * Manages the confirmation dialog modal for the Load Position UI of the board editor.\n * Accepts a generic onConfirm callback so it stays decoupled from the list and save-form modules.\n */\n\nimport guipause from '../../../guipause';\nimport { listener_document } from '../../../../chess/game';\n\n// Types -------------------------------------------------------------------------\n\n/** Different modes for the modal confirmation dialog */\nexport type ModalMode = 'load' | 'delete' | 'overwrite_save';\n\n/** Type for current config of the confirmation dialog modal */\ntype ModalConfig = {\n\tmode: ModalMode;\n\tposition_name: string;\n\tonConfirm: () => Promise<void> | void;\n};\n\n// Elements ----------------------------------------------------------\n\n/** Confirmation dialog modal elements */\nconst element_modal = document.getElementById('load-position-modal-overlay')!;\nconst element_modalCloseButton = document.getElementById('close-load-position-modal')!;\nconst element_modalTitle = document.getElementById('load-position-modal-title')!;\nconst element_modalMessage = document.getElementById('load-position-modal-message')!;\nconst element_modalNoButton = document.getElementById('load-position-modal-no')!;\nconst element_modalYesButton = document.getElementById('load-position-modal-yes')!;\n\n// Variables ----------------------------------------------------------------\n\n/** The current config of the Confirmation dialog modal */\nlet modal_config: ModalConfig | undefined = undefined;\n\n// Functions -----------------------------------------------------------------\n\n/**\n * Open the confirmation modal with the given mode and callback.\n * @param onConfirm Called when the user presses the \"Yes\" button.\n */\nfunction openModal(\n\tmode: ModalMode,\n\tposition_name: string,\n\tonConfirm: () => Promise<void> | void,\n): void {\n\tmodal_config = { mode, position_name, onConfirm };\n\n\tif (modal_config.mode === 'delete') {\n\t\telement_modalTitle.textContent = translations.editor.delete_title;\n\t\telement_modalMessage.textContent =\n\t\t\ttranslations.editor.delete_message[0] +\n\t\t\tposition_name +\n\t\t\ttranslations.editor.delete_message[1];\n\t} else if (modal_config.mode === 'load') {\n\t\telement_modalTitle.textContent = translations.editor.load_title;\n\t\telement_modalMessage.textContent =\n\t\t\ttranslations.editor.load_message[0] +\n\t\t\tposition_name +\n\t\t\ttranslations.editor.load_message[1];\n\t} else if (modal_config.mode === 'overwrite_save') {\n\t\telement_modalTitle.textContent = translations.editor.overwrite_title;\n\t\telement_modalMessage.textContent =\n\t\t\ttranslations.editor.overwrite_message[0] +\n\t\t\tposition_name +\n\t\t\ttranslations.editor.overwrite_message[1];\n\t}\n\telement_modal.classList.remove('hidden');\n\t// Blur the triggering button so that when the modal closes via keyboard (Escape/Enter),\n\t// focus doesn't snap back to it and show an unwanted blue outline.\n\t(document.activeElement as HTMLElement)?.blur();\n\tinitModalListeners();\n}\n\nfunction closeModal(): void {\n\tmodal_config = undefined;\n\telement_modal.classList.add('hidden');\n\tcloseModalListeners();\n}\n\n// Listeners -------------------------------------------\n\nfunction initModalListeners(): void {\n\telement_modalCloseButton.addEventListener('click', closeModal);\n\telement_modalNoButton.addEventListener('click', closeModal);\n\telement_modalYesButton.addEventListener('click', onModalYesButtonPress);\n\tdocument.addEventListener('keydown', onModalKeyDown);\n}\n\nfunction closeModalListeners(): void {\n\telement_modalCloseButton.removeEventListener('click', closeModal);\n\telement_modalNoButton.removeEventListener('click', closeModal);\n\telement_modalYesButton.removeEventListener('click', onModalYesButtonPress);\n\tdocument.removeEventListener('keydown', onModalKeyDown);\n}\n\nfunction onModalKeyDown(e: KeyboardEvent): void {\n\tif (e.key === 'Enter') {\n\t\te.preventDefault(); // Prevent browser from firing a synthetic click on the focused \"Save\" button\n\t\tonModalYesButtonPress();\n\t} else if (e.key === 'Escape') {\n\t\t// Ensure priority when deciding who gets the escape key event\n\t\tif (guipause.areWePaused()) return;\n\t\tlistener_document.claimKey('Escape');\n\t\tcloseModal();\n\t}\n}\n\nfunction onModalYesButtonPress(): void {\n\tif (modal_config === undefined) {\n\t\tcloseModal();\n\t\treturn;\n\t}\n\n\tconst { onConfirm } = modal_config; // Pull callback before clearing state\n\tcloseModal(); // Close modal immediately to clear UI\n\tonConfirm();\n}\n\n/** Returns true if the confirmation modal is currently open. */\nfunction isOpen(): boolean {\n\treturn modal_config !== undefined;\n}\n\n// Exports -----------------------------------------------------------------\n\nexport default {\n\topenModal,\n\tcloseModal,\n\tisOpen,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/boardeditor/actions/loadposition/guiloadpositionsavelist.ts",
    "content": "// src/client/scripts/esm/game/gui/boardeditor/actions/loadposition/guiloadpositionsavelist.ts\n\n/**\n * Manages the saved-positions list for the Load Position UI of the board editor:\n * rendering position rows, performing load/delete/cloud-transfer operations,\n * and refreshing the list from local and cloud storage.\n */\n\nimport type { StorageType } from '../../../../boardeditor/boardeditor';\nimport type { CloudSaveListRecord } from '../../../../boardeditor/actions/editorSavesAPI';\nimport type { EditorAbridgedSaveState } from '../../../../boardeditor/editortypes';\n\nimport esave from '../../../../boardeditor/actions/esave';\nimport style from '../../../style';\nimport ecloud from '../../../../boardeditor/actions/ecloud';\nimport eactions from '../../../../boardeditor/actions/eactions';\nimport boardeditor from '../../../../boardeditor/boardeditor';\nimport { GameBus } from '../../../../GameBus';\nimport validatorama from '../../../../../util/validatorama';\nimport guiloadposition from './guiloadposition';\nimport guiloadpositionmodal from './guiloadpositionmodal';\n\n// Types -------------------------------------------------------------------------\n\n/** Object to keep track of listener for position button */\ntype ButtonHandlerPair = {\n\ttype: 'click';\n\thandler: (e: MouseEvent) => void;\n};\n\n/** A unified save entry for display, regardless of whether it's stored locally or on the cloud */\ntype UnifiedSave = { storage_type: StorageType } & EditorAbridgedSaveState;\n\n/** Cloud saves list returned by a mutation, used to skip a follow-up GET */\ntype PreloadedCloudSaves = CloudSaveListRecord[] | undefined;\n\n// Elements ----------------------------------------------------------\n\n/** The outer container for the saved positions section. */\nconst element_savedPositions = document.querySelector('.saved-positions')!;\n\n/** List of saved positions */\nconst element_savedPositionsToLoad = document.getElementById('saved-position-list')!;\n\n/** Empty-state message shown when there are no saves */\nconst element_noSavesMessage = document.getElementById('saved-position-list-empty')!;\n\n/** Spinny pawn loading animation shown during in-flight API requests */\nconst element_loadingPawn = document.getElementById('load-position-loading-pawn')!;\n\n// Variables ----------------------------------------------------------------\n\n/** Object to keep track of all position button listeners */\nconst registeredButtonListeners = new Map<HTMLButtonElement, ButtonHandlerPair>();\n\n/**\n * A counter for tracking new position loads. Cloud load position\n * requests are discarded if this is different when they return.\n */\nlet load_counter = 0;\n\n/** Count of in-flight API requests — spinner is visible whenever this is > 0 */\nlet activeRequestCount = 0;\n\n// Load Counter ----------------------------------------------------------\n\nGameBus.addEventListener('game-loaded', () => {\n\tload_counter++;\n\t// console.log('Incremented positionLoadEpoch');\n});\n\n// Loading animation -----------------------------------------------\n\n/**\n * Runs an async API call while showing the loading spinner, hiding it when done.\n * @param fn The async function that performs the API call. All errors should be caught internally, this wrapper does not catch errors!\n */\nasync function withRequest<T>(fn: () => Promise<T>): Promise<T> {\n\tactiveRequestCount++;\n\telement_loadingPawn.classList.remove('hidden');\n\n\tconst result = await fn();\n\n\tactiveRequestCount = Math.max(0, activeRequestCount - 1);\n\tif (activeRequestCount === 0) element_loadingPawn.classList.add('hidden');\n\n\treturn result;\n}\n\n// Utilities----------------------------------------------------------------\n\nfunction registerButtonClick(button: HTMLButtonElement, handler: (e: MouseEvent) => void): void {\n\tbutton.addEventListener('click', handler);\n\tregisteredButtonListeners.set(button, { type: 'click', handler });\n}\n\nfunction unregisterAllPositionButtonListeners(): void {\n\tfor (const [button, { type, handler }] of registeredButtonListeners) {\n\t\tbutton.removeEventListener(type, handler);\n\t}\n\tregisteredButtonListeners.clear();\n}\n\n/** Removes all rendered rows from the saved-position list. */\nfunction clearSavedPositionList(): void {\n\telement_savedPositionsToLoad.replaceChildren();\n}\n\n// Operations ---------------------------------------------------------------\n\n/** Performs the actual load operation for a saved position, bypassing the modal. */\nasync function performLoad(position_name: string, storage_type: StorageType): Promise<void> {\n\tconst initialLoadCount = load_counter;\n\tconst editorSaveState =\n\t\tstorage_type === 'cloud'\n\t\t\t? await withRequest(() => ecloud.readCloud(position_name))\n\t\t\t: await esave.readLocal(position_name);\n\t// If the load count changed while the request was in-flight, the user already\n\t// loaded a different position — discard this stale result.\n\tif (load_counter !== initialLoadCount) {\n\t\tconsole.log(`Discarding cloud load result`);\n\t\treturn;\n\t}\n\tif (editorSaveState !== undefined) {\n\t\t// Pass false to skip resetting the window's position on screen\n\t\tguiloadposition.close(false);\n\t\tawait eactions.load(editorSaveState, storage_type);\n\t}\n}\n\n/**\n * Handles pressing the cloud-save button for a position row.\n * - If local: uploads to server and deletes local copy.\n * - If cloud: downloads from server, deletes from server, and saves locally.\n */\nasync function onCloudButtonPress(\n\tposition_name: string,\n\tstorage_type: StorageType,\n\tcloudBtn: HTMLButtonElement,\n): Promise<void> {\n\t// Disable cloud button to prevent multiple clicks while operation is in-flight\n\tcloudBtn.disabled = true;\n\n\tconst preloadedCloudSaves = await withRequest(() =>\n\t\tstorage_type === 'local'\n\t\t\t? ecloud.transferPositionToCloud(position_name)\n\t\t\t: ecloud.removePositionFromCloud(position_name),\n\t);\n\n\t// Re-enable cloud button regardless of success or failure\n\tcloudBtn.disabled = false;\n\tupdateSavedPositionListUI(preloadedCloudSaves);\n}\n\n/** Performs the actual delete operation for a saved position, bypassing the modal. */\nasync function performDelete(position_name: string, storage_type: StorageType): Promise<void> {\n\tlet preloadedCloudSaves: PreloadedCloudSaves;\n\tif (storage_type === 'cloud') {\n\t\tpreloadedCloudSaves = await withRequest(() => ecloud.deleteCloud(position_name));\n\t} else {\n\t\tawait esave.deleteLocal(position_name);\n\t}\n\t// Clear active position name if the deleted position was active\n\tif (boardeditor.isActivePosition(position_name, storage_type))\n\t\tboardeditor.clearActivePosition();\n\tupdateSavedPositionListUI(preloadedCloudSaves);\n}\n\n// Row generation ---------------------------------------------------------------\n\n/**\n * Update the saved positions list.\n * @param preloadedCloudSaves If provided, skips the cloud GET request and uses this data directly.\n */\nasync function updateSavedPositionListUI(preloadedCloudSaves?: PreloadedCloudSaves): Promise<void> {\n\tconst areLoggedIn = validatorama.areWeLoggedIn();\n\n\t// Toggle CSS class to adjust header column widths for cloud button\n\telement_savedPositions.classList.toggle('with-cloud', areLoggedIn);\n\n\t// Build unified list (local + cloud)\n\tconst allSaves: UnifiedSave[] = [];\n\n\t// Fetch cloud saves if logged in\n\tif (areLoggedIn) {\n\t\tconst cloudSaves: CloudSaveListRecord[] =\n\t\t\tpreloadedCloudSaves ?? (await withRequest(() => ecloud.getAllCloudSaveInfos()));\n\n\t\tcloudSaves.forEach((save) => {\n\t\t\tallSaves.push({\n\t\t\t\tstorage_type: 'cloud',\n\t\t\t\tposition_name: save.name,\n\t\t\t\ttimestamp: save.timestamp,\n\t\t\t\tpiece_count: save.piece_count,\n\t\t\t});\n\t\t});\n\t}\n\n\t// Load all local saves\n\tconst localSaveList = await esave.getAllLocalSaveInfos();\n\n\t// Add local saves\n\tfor (const abridged of localSaveList) {\n\t\tallSaves.push({ storage_type: 'local', ...abridged });\n\t}\n\n\t// Sort by timestamp (newest first)\n\tallSaves.sort((a, b) => b.timestamp - a.timestamp);\n\n\t// All data is ready — unregister old listeners, generate new rows, then swap in atomically\n\tunregisterAllPositionButtonListeners();\n\n\t// If there are no saves, show the \"No saved positions.\" message; otherwise hide it\n\tconst isEmpty = allSaves.length === 0;\n\telement_noSavesMessage.classList.toggle('hidden', !isEmpty);\n\n\tconst newRows = allSaves.map((save) => generateRowForSavedPositionsElement(save, areLoggedIn));\n\telement_savedPositionsToLoad.replaceChildren(element_noSavesMessage, ...newRows);\n}\n\n/**\n * Given a UnifiedSave entry,\n * generate a row for the list of saved positions.\n * A \"row\" has the following DOM structure:\n *\n * <div class=\"saved-position\">\n *   <div>POSITION_NAME</div>\n *   <div class=\"piece-count\">PIECE_COUNT</div>\n *   <div class=\"date\">DATE</div>\n *   <!-- Load -->\n *   <button class=\"btn saved-position-btn\">\n *     <svg><use href=\"#svg-load\"/></svg>\n *   </button>\n *   <!-- Cloud Save (only when logged in) -->\n *   <button class=\"btn saved-position-btn cloud-save [local]\">\n *     <svg><use href=\"#svg-cloud-save\"/></svg>\n *   </button>\n *   <!-- Delete -->\n *   <button class=\"btn saved-position-btn\">\n *     <svg><use href=\"#svg-delete\"/></svg>\n *   </button>\n * </div>\n */\nfunction generateRowForSavedPositionsElement(\n\tsave: UnifiedSave,\n\tshowCloudButton: boolean,\n): HTMLDivElement {\n\tconst row = document.createElement('div');\n\trow.classList.add('saved-position');\n\n\t// Name\n\tconst name_cell = document.createElement('div');\n\tconst position_name = save.position_name ?? '';\n\tname_cell.textContent = position_name;\n\tname_cell.title = position_name; // Let browser's automatic tooltips show the full title on hover, if it's truncated via ellipsis\n\trow.appendChild(name_cell);\n\n\t// Piececount\n\tconst piececount_cell = document.createElement('div');\n\tpiececount_cell.classList.add('piece-count');\n\tconst piece_count = String(save.piece_count);\n\tpiececount_cell.textContent = piece_count;\n\tpiececount_cell.title = piece_count;\n\trow.appendChild(piececount_cell);\n\n\t// Date\n\tconst date_cell = document.createElement('div');\n\tdate_cell.classList.add('date');\n\tconst timestamp = save.timestamp;\n\t// Localize the date display to the user's locale\n\tconst dateObj = new Date(timestamp);\n\tconst localeDate = dateObj.toLocaleDateString(undefined, {\n\t\tyear: 'numeric',\n\t\tmonth: '2-digit',\n\t\tday: '2-digit',\n\t});\n\tdate_cell.textContent = localeDate;\n\trow.appendChild(date_cell);\n\n\t// Buttons\n\n\t// \"Load\" button\n\tconst loadBtn = createButtonElement('#svg-load');\n\tloadBtn.classList.add('tooltip-d');\n\tloadBtn.dataset['tooltip'] = translations.editor.tooltip_load_position;\n\tregisterButtonClick(loadBtn, () => {\n\t\t// Skip confirmation modal if the position has no unsaved changes\n\t\tif (!boardeditor.isPositionDirty()) {\n\t\t\tperformLoad(position_name, save.storage_type);\n\t\t} else {\n\t\t\tguiloadpositionmodal.openModal('load', position_name, () =>\n\t\t\t\tperformLoad(position_name, save.storage_type),\n\t\t\t);\n\t\t}\n\t});\n\trow.appendChild(loadBtn);\n\n\t// \"Cloud Save\" button (only when logged in)\n\tif (showCloudButton) {\n\t\tconst cloudBtn = createButtonElement('#svg-cloud-save');\n\t\tcloudBtn.classList.add('cloud-save');\n\t\tcloudBtn.classList.add('tooltip-d');\n\t\tif (save.storage_type === 'local') {\n\t\t\t// Local save: greyed-out cloud button (not yet on cloud)\n\t\t\tcloudBtn.classList.add('local');\n\t\t\tcloudBtn.dataset['tooltip'] = translations.editor.tooltip_save_to_cloud;\n\t\t} else {\n\t\t\tcloudBtn.dataset['tooltip'] = translations.editor.tooltip_remove_from_cloud;\n\t\t}\n\t\tregisterButtonClick(cloudBtn, () =>\n\t\t\tonCloudButtonPress(position_name, save.storage_type, cloudBtn),\n\t\t);\n\t\trow.appendChild(cloudBtn);\n\t}\n\n\t// \"Delete\" button\n\tconst deleteBtn = createButtonElement('#svg-delete');\n\tdeleteBtn.classList.add('tooltip-d');\n\tdeleteBtn.dataset['tooltip'] = translations.editor.tooltip_delete_position;\n\tregisterButtonClick(deleteBtn, () =>\n\t\tguiloadpositionmodal.openModal('delete', position_name, () =>\n\t\t\tperformDelete(position_name, save.storage_type),\n\t\t),\n\t);\n\trow.appendChild(deleteBtn);\n\n\t// Highlight row if position is active\n\tif (boardeditor.isActivePosition(position_name, save.storage_type))\n\t\trow.classList.add('active-position');\n\n\treturn row;\n}\n\n/** Create a button element for one position row, with given SVG href. */\nfunction createButtonElement(svgHref: string): HTMLButtonElement {\n\tconst button = document.createElement('button');\n\tconst svg = document.createElementNS(style.SVG_NS, 'svg');\n\tconst use = document.createElementNS(style.SVG_NS, 'use');\n\tuse.setAttribute('href', svgHref);\n\tsvg.appendChild(use);\n\tbutton.appendChild(svg);\n\tbutton.classList.add('btn');\n\tbutton.classList.add('saved-position-btn');\n\treturn button;\n}\n\n// Exports -----------------------------------------------------------------\n\nexport default {\n\tregisterButtonClick,\n\tunregisterAllPositionButtonListeners,\n\tclearSavedPositionList,\n\tupdateSavedPositionListUI,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/boardeditor/guiboardeditor.ts",
    "content": "// src/client/scripts/esm/game/gui/boardeditor/guiboardeditor.ts\n\n/**\n * Manages the board editor GUI lifecycle: opening, closing,\n * the sidebar menu toggle, and dispatching action button events.\n */\n\nimport esave from '../../boardeditor/actions/esave.js';\nimport ecloud from '../../boardeditor/actions/ecloud.js';\nimport gameslot from '../../chess/gameslot.js';\nimport eactions from '../../boardeditor/actions/eactions.js';\nimport eautosave from '../../boardeditor/actions/eautosave.js';\nimport gameloader from '../../chess/gameloader.js';\nimport guitoolbar from './guitoolbar.js';\nimport guipalette from './guipalette.js';\nimport boardeditor from '../../boardeditor/boardeditor.js';\nimport guigamerules from './actions/guigamerules.js';\nimport selectiontool from '../../boardeditor/tools/selection/selectiontool.js';\nimport guiloadposition from './actions/loadposition/guiloadposition.js';\nimport stransformations from '../../boardeditor/tools/selection/stransformations.js';\nimport guiresetposition from './actions/guiresetposition.js';\nimport guiclearposition from './actions/guiclearposition.js';\nimport guistartlocalgame from './actions/guistartlocalgame.js';\nimport guistartenginegame from './actions/guistartenginegame.js';\nimport guiloadpositionsavelist from './actions/loadposition/guiloadpositionsavelist.js';\n\n// Elements ---------------------------------------------------------------\n\nconst element_menu = document.getElementById('editor-menu')!;\nconst element_menuToggle = document.getElementById('editor-menu-toggle')!;\n\nconst elements_actions = [\n\t// Position\n\tdocument.getElementById('reset')!,\n\tdocument.getElementById('clearall')!,\n\tdocument.getElementById('load-position')!,\n\tdocument.getElementById('save-position-as')!,\n\tdocument.getElementById('save-position')!,\n\tdocument.getElementById('copy-notation')!,\n\tdocument.getElementById('paste-notation')!,\n\tdocument.getElementById('gamerules')!,\n\tdocument.getElementById('start-local-game')!,\n\tdocument.getElementById('start-engine-game')!,\n\t// Selection\n\tdocument.getElementById('select-all')!,\n\tdocument.getElementById('delete-selection')!,\n\tdocument.getElementById('copy-selection')!,\n\tdocument.getElementById('paste-selection')!,\n\tdocument.getElementById('invert-color')!,\n\tdocument.getElementById('rotate-left')!,\n\tdocument.getElementById('rotate-right')!,\n\tdocument.getElementById('flip-horizontal')!,\n\tdocument.getElementById('flip-vertical')!,\n\t// Palette\n\tdocument.getElementById('editor-color-select')!,\n];\n\n// State -------------------------------------------------------------------\n\n/** Whether the board editor UI is open. */\nlet boardEditorOpen = false;\n\n// Functions ---------------------------------------------------------------\n\n/**\n * Open the board editor GUI\n */\nasync function open(): Promise<void> {\n\tboardEditorOpen = true;\n\telement_menu.classList.remove('hidden');\n\twindow.dispatchEvent(new CustomEvent('resize')); // the screen and canvas get effectively resized when the vertical board editor bar is toggled\n\n\t// Try to read in autosave and initialize board editor\n\t// If there is no autosave, initialize board editor with Classical position\n\tconst autoSaveState = await eautosave.loadAutosave();\n\n\tif (autoSaveState === undefined) {\n\t\tboardeditor.clearActivePosition();\n\t\tawait gameloader.startBoardEditor();\n\t} else {\n\t\tif (autoSaveState.active_position !== undefined)\n\t\t\tboardeditor.setActivePosition(autoSaveState.active_position);\n\t\telse boardeditor.clearActivePosition();\n\n\t\tawait gameloader.startBoardEditorFromCustomPosition(\n\t\t\t{\n\t\t\t\tadditional: {\n\t\t\t\t\tvariantOptions: autoSaveState.variantOptions,\n\t\t\t\t},\n\t\t\t},\n\t\t\tautoSaveState.dirty,\n\t\t\tautoSaveState.pawnDoublePush,\n\t\t\tautoSaveState.castling,\n\t\t);\n\t}\n\n\tinitListeners();\n}\n\n/** Whether the board editor UI is open. */\nfunction isOpen(): boolean {\n\treturn boardEditorOpen;\n}\n\nfunction close(): void {\n\tif (!boardEditorOpen) return;\n\n\tcloseAllFloatingWindows(true);\n\n\telement_menu.classList.remove('expanded');\n\telement_menu.classList.add('hidden');\n\twindow.dispatchEvent(new CustomEvent('resize')); // The screen and canvas get effectively resized when the vertical board editor bar is toggled\n\tcloseListeners();\n\tboardEditorOpen = false;\n}\n\nfunction initListeners(): void {\n\telement_menuToggle.addEventListener('click', callback_ToggleMenu);\n\telements_actions.forEach((element) => {\n\t\telement.addEventListener('click', callback_Action);\n\t});\n\tguitoolbar.initListeners();\n\tguipalette.initListeners();\n}\n\nfunction closeListeners(): void {\n\telement_menuToggle.removeEventListener('click', callback_ToggleMenu);\n\telements_actions.forEach((element) => {\n\t\telement.removeEventListener('click', callback_Action);\n\t});\n\tguitoolbar.closeListeners();\n\tguipalette.closeListeners();\n}\n\n/** Close and reset the positioning and contents of all floating windows */\nfunction closeAllFloatingWindows(resetPositioning: boolean): void {\n\tguiresetposition.close(resetPositioning);\n\tguiclearposition.close(resetPositioning);\n\tguiloadposition.close(resetPositioning);\n\tguigamerules.close(resetPositioning);\n\tguistartlocalgame.close(resetPositioning);\n\tguistartenginegame.close(resetPositioning);\n}\n\n// Callbacks ---------------------------------------------------------------\n\nfunction callback_ToggleMenu(): void {\n\tsetSidebarExpanded(!element_menu.classList.contains('expanded'));\n}\n\n/**\n * Sets the sidebar expanded/collapsed state, correctly updating all related elements:\n * the `expanded` class on the menu, the tooltip text, and the tooltip direction classes.\n */\nfunction setSidebarExpanded(expanded: boolean): void {\n\telement_menu.classList.toggle('expanded', expanded);\n\telement_menuToggle.setAttribute(\n\t\t'data-tooltip',\n\t\texpanded ? translations.editor.collapse_sidebar : translations.editor.expand_sidebar,\n\t);\n\telement_menuToggle.classList.toggle('tooltip-dr', !expanded);\n\telement_menuToggle.classList.toggle('tooltip-d', expanded);\n}\n\nfunction callback_Action(e: Event): void {\n\tconst target = e.currentTarget as HTMLElement;\n\tconst action = target.getAttribute('data-action');\n\n\t// Position/Palette actions...\n\n\tswitch (action) {\n\t\t// Position ---------------------\n\t\tcase 'reset': {\n\t\t\tconst wasOpen = guiresetposition.isOpen();\n\t\t\tcloseAllFloatingWindows(false);\n\t\t\t// Skip confirmation dialog if there are no unsaved changes\n\t\t\tif (!boardeditor.isPositionDirty()) eactions.reset();\n\t\t\telse if (!wasOpen) guiresetposition.open();\n\t\t\treturn;\n\t\t}\n\t\tcase 'clearall': {\n\t\t\tconst wasOpen = guiclearposition.isOpen();\n\t\t\tcloseAllFloatingWindows(false);\n\t\t\t// Skip confirmation dialog if there are no unsaved changes\n\t\t\tif (!boardeditor.isPositionDirty()) eactions.clearAll();\n\t\t\telse if (!wasOpen) guiclearposition.open();\n\t\t\treturn;\n\t\t}\n\t\tcase 'load-position': {\n\t\t\tconst wasOpen = guiloadposition.getMode() !== 'load';\n\t\t\tcloseAllFloatingWindows(false);\n\t\t\tif (wasOpen) guiloadposition.openLoadPosition();\n\t\t\treturn;\n\t\t}\n\t\tcase 'save-position-as': {\n\t\t\tconst wasOpen = guiloadposition.getMode() !== 'save-as';\n\t\t\tcloseAllFloatingWindows(false);\n\t\t\tif (wasOpen) guiloadposition.openSavePositionAs();\n\t\t\treturn;\n\t\t}\n\t\tcase 'save-position': {\n\t\t\tconst active_position = boardeditor.getActivePosition();\n\t\t\tif (active_position === undefined) {\n\t\t\t\t// If there is no active position name, treat this the same way as \"Save as\" if that window is not open\n\t\t\t\tconst wasOpen = guiloadposition.getMode() !== 'save-as';\n\t\t\t\tif (wasOpen) {\n\t\t\t\t\tcloseAllFloatingWindows(false);\n\t\t\t\t\tguiloadposition.openSavePositionAs();\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// If there is an active position name, simply overwrite save\n\t\t\t\tif (active_position.storage_type === 'cloud') {\n\t\t\t\t\t// If it's a cloud save, upload to cloud (which will overwrite)\n\t\t\t\t\tecloud.saveCloud(active_position.name);\n\t\t\t\t} else {\n\t\t\t\t\t// If it's a local save, simply overwrite in IndexedDB\n\t\t\t\t\tesave.saveLocal(active_position.name);\n\t\t\t\t}\n\n\t\t\t\t// Update UI if necessary\n\t\t\t\tif (guiloadposition.getMode() !== undefined)\n\t\t\t\t\tguiloadpositionsavelist.updateSavedPositionListUI();\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\tcase 'copy-notation':\n\t\t\teactions.copy();\n\t\t\treturn;\n\t\tcase 'paste-notation':\n\t\t\teactions.paste();\n\t\t\treturn;\n\t\tcase 'gamerules': {\n\t\t\tconst wasOpen = guigamerules.isOpen();\n\t\t\tcloseAllFloatingWindows(false);\n\t\t\tif (!wasOpen) guigamerules.open();\n\t\t\treturn;\n\t\t}\n\t\tcase 'start-local-game': {\n\t\t\tconst wasOpen = guistartlocalgame.isOpen();\n\t\t\tcloseAllFloatingWindows(false);\n\t\t\tif (!wasOpen) guistartlocalgame.open();\n\t\t\treturn;\n\t\t}\n\t\tcase 'start-engine-game': {\n\t\t\tconst wasOpen = guistartenginegame.isOpen();\n\t\t\tcloseAllFloatingWindows(false);\n\t\t\tif (!wasOpen) guistartenginegame.open();\n\t\t\treturn;\n\t\t}\n\t\t// Selection (buttons that are always active)\n\t\tcase 'select-all':\n\t\t\tselectiontool.selectAll();\n\t\t\treturn;\n\t\t// Palette ---------------------\n\t\tcase 'color':\n\t\t\tguipalette.nextColor();\n\t\t\treturn;\n\t}\n\n\t// Selection actions...\n\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh()!;\n\tconst selectionBox = selectiontool.getSelectionIntBox();\n\tif (!selectionBox) return; // Might have clicked action button when there was no selection.\n\n\tswitch (action) {\n\t\tcase 'delete-selection':\n\t\t\tstransformations.Delete(gamefile, mesh, selectionBox);\n\t\t\tbreak;\n\t\tcase 'copy-selection':\n\t\t\tstransformations.Copy(gamefile, selectionBox);\n\t\t\tbreak;\n\t\tcase 'paste-selection':\n\t\t\tstransformations.Paste(gamefile, mesh, selectionBox);\n\t\t\tbreak;\n\t\tcase 'invert-color':\n\t\t\tstransformations.InvertColor(gamefile, mesh, selectionBox);\n\t\t\tbreak;\n\t\tcase 'rotate-left':\n\t\t\tstransformations.RotateLeft(gamefile, mesh, selectionBox);\n\t\t\tbreak;\n\t\tcase 'rotate-right':\n\t\t\tstransformations.RotateRight(gamefile, mesh, selectionBox);\n\t\t\tbreak;\n\t\tcase 'flip-horizontal':\n\t\t\tstransformations.FlipHorizontal(gamefile, mesh, selectionBox);\n\t\t\tbreak;\n\t\tcase 'flip-vertical':\n\t\t\tstransformations.FlipVertical(gamefile, mesh, selectionBox);\n\t\t\tbreak;\n\t\tdefault:\n\t\t\tconsole.error(`Unknown action: ${action}`);\n\t}\n}\n\n// Exports ----------------------------------------------------------------\n\nexport default {\n\topen,\n\tisOpen,\n\tclose,\n\tsetSidebarExpanded,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/boardeditor/guifloatingwindow.ts",
    "content": "// src/client/scripts/esm/game/gui/boardeditor/guifloatingwindow.ts\n\n/**\n * Handles reusable floating window behavior in the board editor:\n * - open/close/toggle\n * - draggable by header (mouse + touch)\n * - clamped to a parent container\n * - remembers last position while open\n */\n\nimport math from '../../../../../../shared/util/math/math';\n\nimport guiboardeditor from './guiboardeditor.js';\n\n// Elements ----------------------------------------------------------\n\nconst element_boardUI = document.getElementById('boardUI')!;\nconst element_menu = document.getElementById('editor-menu')!;\nconst element_menuToggle = document.getElementById('editor-menu-toggle')!;\n\n// Constants -----------------------------------------------------------\n\n/**\n * The viewport width (px) below which the sidebar switches to overlay/collapsible mode.\n * MUST MATCH the CSS @media max-width value in play.css — CSS variables cannot\n * be used in media queries, so this value must be kept in sync manually.\n */\nconst NARROW_THRESHOLD = 727;\n\n// Types -------------------------------------------------------------\n\n/** Functions that handle all floating window behavior */\ninterface FloatingWindowHandle {\n\topen: () => void;\n\tclose: (resetPositioning: boolean) => void;\n\tresetPositioning: () => void;\n\tclampToParentBounds: () => void;\n\tisOpen: () => boolean;\n}\n\n/** Options for initializing a floating window in the board editor */\ninterface FloatingWindowOptions {\n\t/** Floating window element */\n\twindowEl: HTMLElement;\n\n\t/** Header element of floating window */\n\theaderEl: HTMLElement;\n\n\t/** Close button inside the floating window */\n\tcloseButtonEl: HTMLElement;\n\n\t/** Optional list of input elements in floating window. They will get deselected on any click outside the floating window */\n\tinputElList?: HTMLInputElement[];\n\n\t/** Called after the floating window opens (use for window-specific listeners) */\n\tonOpen: () => void;\n\n\t/** Called after the floating window closes (use for window-specific listener cleanup) */\n\tonClose: (resetPositioning: boolean) => void;\n}\n\n// Utilities -------------------------------------------------------------\n\n/** Create the functions needed for the handling of a floating window in the board editor */\nfunction create(opts: FloatingWindowOptions): FloatingWindowHandle {\n\tconst { windowEl, headerEl, closeButtonEl, inputElList, onOpen, onClose } = opts;\n\n\t// Window Position & Dragging State\n\tlet offsetX = 0;\n\tlet offsetY = 0;\n\tlet isDragging = false;\n\tlet savedPos: { left: number; top: number } | undefined;\n\n\tfunction clampToParentBounds(): void {\n\t\tconst parentRect = element_boardUI.getBoundingClientRect();\n\t\tconst elWidth = windowEl.offsetWidth;\n\t\tconst elHeight = windowEl.offsetHeight;\n\n\t\tconst newLeft = math.clamp(windowEl.offsetLeft, 0, parentRect.width - elWidth);\n\t\tconst newTop = math.clamp(windowEl.offsetTop, 0, parentRect.height - elHeight);\n\n\t\twindowEl.style.left = `${newLeft}px`;\n\t\twindowEl.style.top = `${newTop}px`;\n\t\tsavedPos = { left: newLeft, top: newTop };\n\t}\n\n\t// --- Dragging ---\n\tfunction startDrag(clientX: number, clientY: number): void {\n\t\tisDragging = true;\n\t\toffsetX = clientX - windowEl.offsetLeft;\n\t\toffsetY = clientY - windowEl.offsetTop;\n\t\tdocument.body.style.userSelect = 'none';\n\t}\n\n\tfunction startMouseDrag(e: MouseEvent): void {\n\t\tstartDrag(e.clientX, e.clientY);\n\t}\n\n\tfunction startTouchDrag(e: TouchEvent): void {\n\t\tif (e.touches.length === 1) {\n\t\t\tconst touch = e.touches[0]!;\n\t\t\tstartDrag(touch.clientX, touch.clientY);\n\t\t}\n\t}\n\n\tfunction stopDrag(): void {\n\t\tif (isDragging) clampToParentBounds();\n\t\tisDragging = false;\n\t\tdocument.body.style.userSelect = 'auto';\n\t}\n\n\tfunction duringDrag(clientX: number, clientY: number): void {\n\t\tif (!isDragging) return;\n\n\t\tconst parentRect = element_boardUI.getBoundingClientRect();\n\t\tconst elWidth = windowEl.offsetWidth;\n\t\tconst elHeight = windowEl.offsetHeight;\n\n\t\tconst newLeft = clientX - offsetX;\n\t\tconst newTop = clientY - offsetY;\n\n\t\tconst clampedLeft = math.clamp(newLeft, 0, parentRect.width - elWidth);\n\t\tconst clampedTop = math.clamp(newTop, 0, parentRect.height - elHeight);\n\n\t\twindowEl.style.left = `${clampedLeft}px`;\n\t\twindowEl.style.top = `${clampedTop}px`;\n\t\tsavedPos = { left: clampedLeft, top: clampedTop };\n\t}\n\n\tfunction duringMouseDrag(e: MouseEvent): void {\n\t\tduringDrag(e.clientX, e.clientY);\n\t}\n\n\tfunction duringTouchDrag(e: TouchEvent): void {\n\t\tif (e.touches.length === 1) {\n\t\t\tif (isDragging) e.preventDefault(); // prevent scrolling\n\t\t\tconst touch = e.touches[0]!;\n\t\t\tduringDrag(touch.clientX, touch.clientY);\n\t\t}\n\t}\n\n\t/** Deselects input boxes when pressing Enter */\n\tfunction blurOnEnter(e: KeyboardEvent): void {\n\t\tif (e.key === 'Enter') {\n\t\t\t(e.target as HTMLInputElement).blur();\n\t\t}\n\t}\n\n\t/** Deselects input boxes when clicking somewhere outside the floating window */\n\tfunction blurOnClickorTouchOutside(e: MouseEvent | TouchEvent): void {\n\t\tif (inputElList === undefined) return;\n\t\tif (!windowEl.contains(e.target as Node)) {\n\t\t\tconst activeEl = document.activeElement as HTMLInputElement;\n\t\t\tif (activeEl && inputElList.includes(activeEl) && activeEl.tagName === 'INPUT') {\n\t\t\t\tactiveEl.blur();\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Initialize general floating window listeners */\n\tfunction initBaseListeners(): void {\n\t\theaderEl.addEventListener('mousedown', startMouseDrag);\n\t\tdocument.addEventListener('mousemove', duringMouseDrag);\n\t\tdocument.addEventListener('mouseup', stopDrag);\n\n\t\theaderEl.addEventListener('touchstart', startTouchDrag, { passive: false });\n\t\tdocument.addEventListener('touchmove', duringTouchDrag, { passive: false });\n\t\tdocument.addEventListener('touchend', stopDrag, { passive: false });\n\n\t\twindow.addEventListener('resize', clampToParentBounds);\n\n\t\tif (closeButtonEl) closeButtonEl.addEventListener('click', callbackClose);\n\n\t\tif (inputElList) {\n\t\t\tinputElList.forEach((el) => {\n\t\t\t\tif (el.type === 'text') el.addEventListener('keydown', blurOnEnter);\n\t\t\t});\n\t\t\tdocument.addEventListener('click', blurOnClickorTouchOutside);\n\t\t\tdocument.addEventListener('touchstart', blurOnClickorTouchOutside);\n\t\t}\n\t}\n\n\t/** Close general floating window listeners */\n\tfunction removeBaseListeners(): void {\n\t\theaderEl.removeEventListener('mousedown', startMouseDrag);\n\t\tdocument.removeEventListener('mousemove', duringMouseDrag);\n\t\tdocument.removeEventListener('mouseup', stopDrag);\n\n\t\theaderEl.removeEventListener('touchstart', startTouchDrag);\n\t\tdocument.removeEventListener('touchmove', duringTouchDrag);\n\t\tdocument.removeEventListener('touchend', stopDrag);\n\n\t\twindow.removeEventListener('resize', clampToParentBounds);\n\n\t\tif (closeButtonEl) closeButtonEl.removeEventListener('click', callbackClose);\n\n\t\tif (inputElList) {\n\t\t\tinputElList.forEach((el) => {\n\t\t\t\tif (el.type === 'text') el.removeEventListener('keydown', blurOnEnter);\n\t\t\t});\n\t\t\tdocument.removeEventListener('click', blurOnClickorTouchOutside);\n\t\t\tdocument.removeEventListener('touchstart', blurOnClickorTouchOutside);\n\t\t}\n\t}\n\n\tfunction isOpen(): boolean {\n\t\treturn !windowEl.classList.contains('hidden');\n\t}\n\n\tfunction callbackClose(): void {\n\t\tclose(false);\n\t}\n\n\tfunction close(resetPositioning: boolean): void {\n\t\twindowEl.classList.add('hidden');\n\n\t\tonClose(resetPositioning);\n\t\tremoveBaseListeners();\n\t}\n\n\tfunction resetPositioning(): void {\n\t\twindowEl.style.left = '';\n\t\twindowEl.style.top = '';\n\t\tsavedPos = undefined;\n\t}\n\n\t/**\n\t * Returns the rendered width of the window.\n\t * If currently hidden, temporarily makes it invisible-but-laid-out to measure it.\n\t */\n\tfunction measureWidth(): number {\n\t\tif (!windowEl.classList.contains('hidden')) return windowEl.offsetWidth;\n\t\twindowEl.style.visibility = 'hidden';\n\t\twindowEl.classList.remove('hidden');\n\t\tconst w = windowEl.offsetWidth;\n\t\twindowEl.classList.add('hidden');\n\t\twindowEl.style.visibility = '';\n\t\treturn w;\n\t}\n\n\t/**\n\t * On narrow screens, computes the initial position for the floating window, placing it\n\t * to the right of the sidebar tab. Collapses the sidebar first if the window would not fit\n\t * alongside it. Returns undefined on wide screens (no special positioning needed).\n\t */\n\tfunction computeNarrowInitialPos(): { left: number; top: number } | undefined {\n\t\tif (window.innerWidth > NARROW_THRESHOLD) return undefined;\n\n\t\tconst winWidth = measureWidth();\n\t\tconst topPx = Math.round(element_boardUI.offsetHeight * 0.11);\n\t\tconst sidebarWidth = element_menu.offsetWidth;\n\t\tconst tabWidth = element_menuToggle.offsetWidth;\n\t\tconst expandedRightEdge = sidebarWidth + tabWidth;\n\n\t\tif (\n\t\t\telement_menu.classList.contains('expanded') &&\n\t\t\texpandedRightEdge + winWidth <= window.innerWidth\n\t\t) {\n\t\t\t// Sidebar is open and the window fits alongside it\n\t\t\treturn { left: expandedRightEdge, top: topPx };\n\t\t} else {\n\t\t\t// Place window right of the collapsed tab\n\t\t\treturn { left: tabWidth, top: topPx };\n\t\t}\n\t}\n\n\t/**\n\t * Opens the floating window, smartly positioning it if this is the first opening,\n\t * and potentially collapsing the sidebar in order for the window to be visible.\n\t */\n\tfunction open(): void {\n\t\tlet effectiveLeft: number | undefined;\n\n\t\tif (savedPos !== undefined) {\n\t\t\t// Restore previous drag position\n\t\t\twindowEl.style.left = `${savedPos.left}px`;\n\t\t\twindowEl.style.top = `${savedPos.top}px`;\n\t\t\teffectiveLeft = savedPos.left;\n\t\t} else {\n\t\t\t// No saved drag position - compute smart initial position\n\t\t\tconst initialPos = computeNarrowInitialPos();\n\t\t\tif (initialPos !== undefined) {\n\t\t\t\twindowEl.style.left = `${initialPos.left}px`;\n\t\t\t\twindowEl.style.top = `${initialPos.top}px`;\n\t\t\t\teffectiveLeft = initialPos.left;\n\t\t\t}\n\t\t}\n\n\t\t// On narrow screens, collapse the sidebar if it would overlap the window\n\t\tif (\n\t\t\twindow.innerWidth <= NARROW_THRESHOLD &&\n\t\t\teffectiveLeft !== undefined &&\n\t\t\teffectiveLeft < element_menu.offsetWidth + element_menuToggle.offsetWidth\n\t\t) {\n\t\t\tguiboardeditor.setSidebarExpanded(false);\n\t\t}\n\n\t\t// Open the window\n\t\twindowEl.classList.remove('hidden');\n\n\t\t// Ensure it’s inside bounds on open (and after becoming visible)\n\t\tclampToParentBounds();\n\n\t\tinitBaseListeners();\n\t\tonOpen?.();\n\t}\n\n\treturn {\n\t\topen,\n\t\tclose,\n\t\tresetPositioning,\n\t\tclampToParentBounds,\n\t\tisOpen,\n\t};\n}\n\nexport default {\n\tcreate,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/boardeditor/guipalette.ts",
    "content": "// src/client/scripts/esm/game/gui/boardeditor/guipalette.ts\n\n/**\n * Manages the piece palette in the board editor GUI.\n * Handles palette initialization, piece/color selection, and palette listener wiring.\n */\n\nimport type { Player } from '../../../../../../shared/chess/util/typeutil.js';\n\nimport icnconverter from '../../../../../../shared/chess/logic/icn/icnconverter.js';\nimport typeutil, {\n\trawTypes as r,\n\tplayers as p,\n} from '../../../../../../shared/chess/util/typeutil.js';\n\nimport svgcache from '../../../chess/rendering/svgcache.js';\nimport gameslot from '../../chess/gameslot.js';\nimport drawingtool from '../../boardeditor/tools/drawingtool.js';\nimport etoolmanager from '../../boardeditor/tools/etoolmanager.js';\n\n// Elements ---------------------------------------------------------------\n\nconst element_typesContainer = document.getElementById('editor-pieceTypes')!;\nconst element_neutralTypesContainer = document.getElementById('editor-neutralTypes')!;\nconst element_colorSelect = document.getElementById('editor-color-select')!;\n/** A map of each player's element container containing their colored pieces in the Palette. */\nconst element_playerContainers: Map<Player, Element> = new Map();\nconst element_playerTypes: Map<Player, Array<Element>> = new Map();\nconst element_neutralTypes: Array<Element> = [];\n\n// Constants -----------------------------------------------------------\n\n/** Player pieces in the order they will appear */\nconst coloredTypes = [\n\tr.KING,\n\tr.QUEEN,\n\tr.ROOK,\n\tr.BISHOP,\n\tr.KNIGHT,\n\tr.PAWN,\n\tr.CHANCELLOR,\n\tr.ARCHBISHOP,\n\tr.AMAZON,\n\tr.GUARD,\n\tr.CENTAUR,\n\tr.HAWK,\n\tr.KNIGHTRIDER,\n\tr.HUYGEN,\n\tr.ROSE,\n\tr.CAMEL,\n\tr.GIRAFFE,\n\tr.ZEBRA,\n\tr.ROYALCENTAUR,\n\tr.ROYALQUEEN,\n];\n\n/** Neutral pieces in the order they will appear (except void, which is included manually in initUI by default) */\nconst neutralTypes = [r.OBSTACLE];\n\n// State -------------------------------------------------------------------\n\n/**\n * Whether the UI has been initialized and all piece svgs appended to the editor menu.\n * Only needs to be done once.\n */\nlet initialized = false;\n\n// Functions ---------------------------------------------------------------\n\n/**\n * Initializes the palette UI, appending piece SVGs to the editor menu.\n * Only runs once; subsequent calls are no-ops.\n */\nasync function initUI(): Promise<void> {\n\tif (initialized) return;\n\tconst uniquePlayers = _getPlayersInOrder();\n\n\t// Colored pieces\n\tfor (const player of uniquePlayers) {\n\t\tconst svgs = await svgcache.getSVGElements(\n\t\t\tcoloredTypes.map((rawType) => {\n\t\t\t\treturn typeutil.buildType(rawType, player);\n\t\t\t}),\n\t\t);\n\t\tconst playerPieces = document.createElement('div');\n\t\telement_playerContainers.set(player, playerPieces);\n\t\telement_playerTypes.set(player, svgs);\n\t\tplayerPieces.classList.add('editor-types');\n\t\tif (player !== drawingtool.getColor()) playerPieces.classList.add('hidden');\n\n\t\t// Tooltips (i.e. \"Amazon (AM)\")\n\t\tfor (let i = 0; i < svgs.length; i++) {\n\t\t\tconst svg = svgs[i]!;\n\t\t\tsvg.classList.add('piece');\n\t\t\tconst pieceContainer = document.createElement('div');\n\n\t\t\tif (i % 4 === 0) pieceContainer.classList.add('tooltip-ur');\n\t\t\telse pieceContainer.classList.add('tooltip-u');\n\t\t\tconst localized_piece_name =\n\t\t\t\t// @ts-ignore\n\t\t\t\ttranslations.piecenames[typeutil.getRawTypeStr(coloredTypes[i]!)!];\n\t\t\tconst piece_abbreviation = icnconverter.piece_codes_raw[coloredTypes[i]!];\n\t\t\tconst modified_piece_abbreviation =\n\t\t\t\tplayer === p.WHITE\n\t\t\t\t\t? piece_abbreviation.toUpperCase()\n\t\t\t\t\t: piece_abbreviation.toLowerCase();\n\t\t\tpieceContainer.setAttribute(\n\t\t\t\t'data-tooltip',\n\t\t\t\t`${localized_piece_name} (${modified_piece_abbreviation})`,\n\t\t\t);\n\n\t\t\tpieceContainer.appendChild(svg);\n\t\t\tplayerPieces.appendChild(pieceContainer);\n\t\t}\n\t\telement_typesContainer.appendChild(playerPieces);\n\t}\n\n\t// Neutral pieces\n\tconst neutral_svgs = await svgcache.getSVGElements(\n\t\tneutralTypes.map((rawType) => {\n\t\t\treturn typeutil.buildType(rawType, p.NEUTRAL);\n\t\t}),\n\t);\n\tconst neutralPieces = document.createElement('div');\n\tneutralPieces.classList.add('editor-types');\n\n\tconst element_void = document.createElement('div');\n\telement_void.classList.add('piece');\n\telement_void.classList.add('void');\n\telement_void.id = '0';\n\n\t// Void tooltip\n\telement_void.classList.add('tooltip-ur');\n\t// @ts-ignore\n\tconst localized_void_name = translations.piecenames[typeutil.getRawTypeStr(r.VOID)!];\n\tconst void_abbreviation = icnconverter.piece_codes_raw[r.VOID];\n\telement_void.setAttribute('data-tooltip', `${localized_void_name} (${void_abbreviation})`);\n\n\telement_neutralTypes.push(element_void);\n\tneutralPieces.appendChild(element_void);\n\n\tfor (let i = 0; i < neutral_svgs.length; i++) {\n\t\tconst neutral_svg = neutral_svgs[i]!;\n\t\tneutral_svg.classList.add('piece');\n\t\tconst pieceContainer = document.createElement('div');\n\n\t\t// Neutral piece tooltips\n\t\tif (i % 4 === 3) pieceContainer.classList.add('tooltip-ur');\n\t\telse if (i % 4 === 2) pieceContainer.classList.add('tooltip-ul');\n\t\telse pieceContainer.classList.add('tooltip-u');\n\t\tconst localized_piece_name =\n\t\t\t// @ts-ignore\n\t\t\ttranslations.piecenames[typeutil.getRawTypeStr(neutralTypes[i]!)!];\n\t\tconst piece_abbreviation = icnconverter.piece_codes_raw[neutralTypes[i]!];\n\t\tconst modified_piece_abbreviation = piece_abbreviation.toLowerCase();\n\t\tpieceContainer.setAttribute(\n\t\t\t'data-tooltip',\n\t\t\t`${localized_piece_name} (${modified_piece_abbreviation})`,\n\t\t);\n\n\t\tpieceContainer.appendChild(neutral_svg);\n\t\telement_neutralTypes.push(neutral_svg);\n\t\tneutralPieces.appendChild(pieceContainer);\n\t}\n\telement_neutralTypesContainer.appendChild(neutralPieces);\n\n\tinitialized = true;\n}\n\n/** Adds/removes the 'active' class from the piece svgs in the Palette, changing their style. */\nfunction markPiece(type: number | null): void {\n\tconst placerToolActive = etoolmanager.getTool() === 'placer';\n\n\t_getActivePieceElements().forEach((element) => {\n\t\tconst element_type = Number.parseInt(element.id);\n\t\tif (element_type === type && placerToolActive) element.classList.add('active');\n\t\telse element.classList.remove('active');\n\t});\n}\n\n/** Updates which player's element container of their colored piece svgs are visible in the Palette. */\nfunction updatePieceColors(newColor: Player): void {\n\tif (!initialized) return;\n\n\t// Hide all player containers and remove their listeners\n\tfor (const [player, container] of element_playerContainers.entries()) {\n\t\tcontainer.classList.add('hidden');\n\t\telement_playerTypes.get(player)!.forEach((element) => {\n\t\t\telement.removeEventListener('click', callback_ChangePieceType);\n\t\t});\n\t}\n\n\t// Show the correct container and add its listeners\n\tconst newPlayerContainer = element_playerContainers.get(newColor);\n\tif (newPlayerContainer) {\n\t\tnewPlayerContainer.classList.remove('hidden');\n\t\telement_playerTypes.get(newColor)!.forEach((element) => {\n\t\t\telement.addEventListener('click', callback_ChangePieceType);\n\t\t});\n\t}\n\n\t// Update dot color and internal state\n\telement_colorSelect.style.backgroundColor = typeutil.strcolors[newColor];\n\tdrawingtool.setColor(newColor);\n\n\t// Update currentPieceType, if necessary\n\tif (typeutil.getColorFromType(drawingtool.getPiece()) !== p.NEUTRAL) {\n\t\tconst currentPieceType = typeutil.buildType(\n\t\t\ttypeutil.getRawType(drawingtool.getPiece()),\n\t\t\tnewColor,\n\t\t);\n\t\tdrawingtool.setPiece(currentPieceType);\n\t}\n\tmarkPiece(drawingtool.getPiece());\n}\n\n/** Swaps the color of pieces being drawn to the next color in the turn order. */\nfunction nextColor(): void {\n\tconst playersArray = _getPlayersInOrder();\n\tconst currentIndex = playersArray.indexOf(drawingtool.getColor());\n\tconst next = playersArray[(currentIndex + 1) % playersArray.length]!;\n\tupdatePieceColors(next);\n}\n\nfunction initListeners(): void {\n\t_getActivePieceElements().forEach((element) => {\n\t\telement.addEventListener('click', callback_ChangePieceType);\n\t});\n}\n\nfunction closeListeners(): void {\n\t_getActivePieceElements().forEach((element) => {\n\t\telement.removeEventListener('click', callback_ChangePieceType);\n\t});\n}\n\n// Helper Functions ---------------------------------------------------------\n\n/** Helper: Returns an array of players based on the current gamefile's turn order. */\nfunction _getPlayersInOrder(): Player[] {\n\tconst gamefile = gameslot.getGamefile()!;\n\t// Using a Set removes duplicates before converting to an array\n\treturn [...new Set(gamefile.basegame.gameRules.turnOrder)];\n}\n\n/** Helper: Returns an array of all piece elements that are currently clickable (active color + neutral). */\nfunction _getActivePieceElements(): Element[] {\n\tconst playerElements = element_playerTypes.get(drawingtool.getColor()) ?? [];\n\treturn [...playerElements, ...element_neutralTypes];\n}\n\n// Callbacks ---------------------------------------------------------------\n\nfunction callback_ChangePieceType(e: Event): void {\n\tconst target = e.currentTarget as HTMLElement;\n\tconst currentPieceType = Number.parseInt(target.id);\n\tif (isNaN(currentPieceType)) return console.error(`Invalid piece type: ${currentPieceType}`);\n\tdrawingtool.setPiece(currentPieceType);\n\tetoolmanager.setTool('placer');\n\tmarkPiece(currentPieceType);\n}\n\n// Exports ----------------------------------------------------------------\n\nexport default {\n\tinitUI,\n\tmarkPiece,\n\tupdatePieceColors,\n\tnextColor,\n\tinitListeners,\n\tcloseListeners,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/boardeditor/guipositionheader.ts",
    "content": "// src/client/scripts/esm/game/gui/boardeditor/guipositionheader.ts\n\n/**\n * Manages the active position name display, the dirty indicator,\n * and the enabled/disabled state of selection action buttons\n * in the board editor GUI.\n */\n\n// Elements ---------------------------------------------------------------\n\nconst element_activePositionNameDisplay = document.getElementById('active-position-name-display')!;\nconst element_dirtyIndicator = document.getElementById('position-dirty-indicator')!;\n\n/** The element containing all selection tool action buttons. */\nconst element_selectionActions = document.getElementsByClassName(\n\t'selection-actions',\n)[0]! as HTMLElement;\n/** These selection action buttons are always enabled. */\nconst alwaysActiveSelectionActions = [document.getElementById('select-all')!];\n\n// Functions ---------------------------------------------------------------\n\n/** Updates the displayed active position name. */\nfunction updateActivePositionElement(positionname: string | undefined): void {\n\tif (positionname === undefined) {\n\t\tpositionname = translations.editor.new_position;\n\t\telement_activePositionNameDisplay.classList.add('italic');\n\t} else {\n\t\telement_activePositionNameDisplay.classList.remove('italic');\n\t}\n\n\telement_activePositionNameDisplay.textContent = positionname;\n\telement_activePositionNameDisplay.title = positionname;\n}\n\n/** Shows or hides the dirty indicator dot next to the position name. */\nfunction updateDirtyIndicator(dirty: boolean): void {\n\tif (dirty) element_dirtyIndicator.classList.remove('hidden');\n\telse element_dirtyIndicator.classList.add('hidden');\n}\n\n/** Un-greys selection action buttons when a selection is made. */\nfunction onNewSelection(): void {\n\t// Remove 'disabled' class from all children\n\tArray.from(element_selectionActions.children).forEach((child) => {\n\t\t(child as HTMLElement).classList.remove('disabled');\n\t});\n}\n\n/** Greys out selection action buttons when the selection is cleared. */\nfunction onClearSelection(): void {\n\t// Add 'disabled' to all children except those included in the alwaysActiveSelectionActions array\n\tArray.from(element_selectionActions.children).forEach((child) => {\n\t\tif (!alwaysActiveSelectionActions.includes(child as HTMLElement)) {\n\t\t\t(child as HTMLElement).classList.add('disabled');\n\t\t}\n\t});\n}\n\n// Exports ----------------------------------------------------------------\n\nexport default {\n\tupdateActivePositionElement,\n\tupdateDirtyIndicator,\n\tonNewSelection,\n\tonClearSelection,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/boardeditor/guitoolbar.ts",
    "content": "// src/client/scripts/esm/game/gui/boardeditor/guitoolbar.ts\n\n/**\n * Manages the tool selection toolbar in the board editor GUI.\n * Handles marking the active tool and wiring up tool-change click listeners.\n */\n\nimport type { Tool } from '../../boardeditor/tools/etoolmanager.js';\n\nimport etoolmanager from '../../boardeditor/tools/etoolmanager.js';\n\n// Elements ---------------------------------------------------------------\n\nconst elements_tools = [\n\tdocument.getElementById('normal')!,\n\tdocument.getElementById('eraser')!,\n\tdocument.getElementById('specialrights')!,\n\tdocument.getElementById('selection-tool')!,\n];\n\n// Functions ---------------------------------------------------------------\n\n/** Adds/removes the 'active' class from the tools, changing their style. */\nfunction markTool(tool: Tool): void {\n\telements_tools.forEach((element) => {\n\t\tconst element_tool = element.getAttribute('data-tool');\n\t\tif (element_tool === tool) element.classList.add('active');\n\t\telse if (element_tool !== 'gamerules') element.classList.remove('active');\n\t});\n}\n\nfunction initListeners(): void {\n\telements_tools.forEach((element) => {\n\t\telement.addEventListener('click', callback_ChangeTool);\n\t});\n}\n\nfunction closeListeners(): void {\n\telements_tools.forEach((element) => {\n\t\telement.removeEventListener('click', callback_ChangeTool);\n\t});\n}\n\n// Callbacks ---------------------------------------------------------------\n\nfunction callback_ChangeTool(e: Event): void {\n\tconst target = e.currentTarget as HTMLElement;\n\tconst tool = target.getAttribute('data-tool');\n\tif (tool === null) throw new Error('Tool attribute is null');\n\tetoolmanager.setTool(tool);\n}\n\n// Exports ----------------------------------------------------------------\n\nexport default {\n\tmarkTool,\n\tinitListeners,\n\tcloseListeners,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/gui.ts",
    "content": "// src/client/scripts/esm/game/gui/gui.ts\n\n/**\n * This script adds event listeners for our main overlay html element that\n * contains all of our gui pages.\n *\n * We also prepare the board here whenever ANY gui page (non-game) is opened.\n */\n\nimport bd from '@naviary/bigdecimal';\n\nimport vectors from '../../../../../shared/util/math/vectors.js';\n\nimport toast from './toast.js';\nimport boardpos from '../rendering/boardpos.js';\nimport guititle from './guititle.js';\nimport loadbalancer from '../misc/loadbalancer.js';\n\n// Functions ------------------------------------------------------------------------------\n\n/**\n * Call when we first load the page, or leave any game. This prepares the board\n * for either the title screen or lobby (any screen that's not in a game)\n */\nfunction prepareForOpen(): void {\n\t// Randomize pan velocity direction for the title screen and lobby menus\n\trandomizePanVelDir();\n\tconst amount = bd.fromNumber(1.8); // Default: 1.8\n\tboardpos.setBoardScale(amount);\n\tloadbalancer.restartAFKTimer();\n}\n\n// Sets panVel to a random direction, and sets speed to titleBoardVel. Called when the title screen is initiated.\nfunction randomizePanVelDir(): void {\n\tconst randTheta = Math.random() * 2 * Math.PI;\n\tconst XYComponents = vectors.getXYComponents_FromAngle(randTheta);\n\tboardpos.setPanVel([XYComponents[0] * guititle.boardVel, XYComponents[1] * guititle.boardVel]);\n}\n\n/** Displays the status message on screen \"Feature is planned\". */\nfunction displayStatus_FeaturePlanned(): void {\n\ttoast.show(translations.planned_feature);\n}\n\nexport default {\n\tprepareForOpen,\n\tdisplayStatus_FeaturePlanned,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/guiclock.ts",
    "content": "// src/client/scripts/esm/game/gui/guiclock.ts\n\nimport type { Game } from '../../../../../shared/chess/logic/gamefile.js';\nimport type { ClockData } from '../../../../../shared/chess/logic/clock.js';\nimport type { SoundObject } from '../../audio/AudioManager.js';\nimport type { Player, PlayerGroup } from '../../../../../shared/chess/util/typeutil.js';\n\nimport clock from '../../../../../shared/chess/logic/clock.js';\nimport moveutil from '../../../../../shared/chess/util/moveutil.js';\nimport clockutil from '../../../../../shared/chess/util/clockutil.js';\nimport { players as p } from '../../../../../shared/chess/util/typeutil.js';\n\nimport gamesound from '../misc/gamesound.js';\nimport gameloader from '../chess/gameloader.js';\nimport { GameBus } from '../GameBus.js';\n\nconst element_timers: PlayerGroup<{ timer: HTMLElement }> = {\n\t[p.WHITE]: {\n\t\ttimer: document.getElementById('timer-white')!,\n\t},\n\t[p.BLACK]: {\n\t\ttimer: document.getElementById('timer-black')!,\n\t},\n};\n\n/** All variables related to the lowtime tick notification at 1 minute remaining. */\nconst lowtimeNotif: {\n\tplayersNotified: Set<Player>;\n\ttimeoutID?: ReturnType<typeof setTimeout>;\n\ttimeToStartFromEnd: number;\n\tclockMinsRequiredToUse: number;\n} = {\n\t/** Contains the players that have had the ticking sound play */\n\tplayersNotified: new Set(),\n\t/** The timer that, when ends, will play the lowtime ticking audio cue. */\n\ttimeoutID: undefined,\n\t/** The amount of milliseconds before losing on time at which the lowtime tick notification will be played. */\n\ttimeToStartFromEnd: 65615,\n\t/** The minimum start time required to give a lowtime notification at 1 minute remaining. */\n\tclockMinsRequiredToUse: 2,\n};\n/** All variables related to the 10s countdown when you're almost out of time. */\nconst countdown: {\n\tdrum: {\n\t\ttimeoutID?: ReturnType<typeof setTimeout>;\n\t};\n\ttick: {\n\t\ttimeoutID?: ReturnType<typeof setTimeout>;\n\t\tsound?: SoundObject;\n\t\ttimeToStartFromEnd: number;\n\t\tfadeInDuration: number;\n\t\tfadeOutDuration: number;\n\t};\n\tticking: {\n\t\ttimeoutID?: ReturnType<typeof setTimeout>;\n\t\tsound?: SoundObject;\n\t\ttimeToStartFromEnd: number;\n\t\tfadeInDuration: number;\n\t\tfadeOutDuration: number;\n\t};\n} = {\n\tdrum: {\n\t\ttimeoutID: undefined,\n\t},\n\ttick: {\n\t\t/**\n\t\t * The current sound object, if specified, that is playing our tick sound effects right before the 10s countdown.\n\t\t * This can be used to stop the sound from playing.\n\t\t */\n\t\tsound: undefined,\n\t\ttimeoutID: undefined,\n\t\ttimeToStartFromEnd: 15625,\n\t\tfadeInDuration: 300,\n\t\tfadeOutDuration: 100,\n\t},\n\tticking: {\n\t\t/**\n\t\t * The current sound object, if specified, that is playing our ticking sound effects during the 10s countdown.\n\t\t * This can be used to stop the sound from playing.\n\t\t */\n\t\tsound: undefined,\n\t\ttimeoutID: undefined,\n\t\ttimeToStartFromEnd: 10220,\n\t\tfadeInDuration: 300,\n\t\tfadeOutDuration: 100,\n\t},\n};\n\n// Events ---------------------------------------------------------------------------\n\nGameBus.addEventListener('game-unloaded', () => {\n\t// Clock data is unloaded with gamefile now, just need to reset gui. Not our problem ¯\\_(ツ)_/¯\n\tresetClocks();\n});\n\n// Functions -----------------------------------------------------------------------\n\nfunction hideClocks(): void {\n\tfor (const clockElements of Object.values(element_timers)) {\n\t\tclockElements.timer.classList.add('hidden');\n\t}\n}\n\nfunction showClocks(): void {\n\tfor (const clockElements of Object.values(element_timers)) {\n\t\tclockElements.timer.classList.remove('hidden');\n\t}\n}\n\n/**\n * Stops clock sounds and removes all borders\n */\nfunction stopClocks(basegame?: Game): void {\n\tcancelSoundEffectTimers();\n\n\tif (basegame && !basegame.untimed) updateTextContent(basegame.clocks); // Do this one last time so that when we lose on time, the clock doesn't freeze at one second remaining.\n\tfor (const clockElements of Object.values(element_timers)) {\n\t\tremoveBorder(clockElements.timer);\n\t}\n}\n\nfunction cancelSoundEffectTimers(): void {\n\t// Minute Tick\n\tclearTimeout(lowtimeNotif.timeoutID);\n\tlowtimeNotif.timeoutID = undefined;\n\n\t// 10-second Countdown\n\tclearTimeout(countdown.ticking.timeoutID);\n\tclearTimeout(countdown.tick.timeoutID);\n\tclearTimeout(countdown.drum.timeoutID);\n\tcountdown.ticking.timeoutID = undefined;\n\tcountdown.tick.timeoutID = undefined;\n\tcountdown.drum.timeoutID = undefined;\n\n\t// Stop any sounds currently playing\n\tcountdown.ticking.sound?.fadeOut(countdown.ticking.fadeOutDuration);\n\tcountdown.tick.sound?.fadeOut(countdown.tick.fadeOutDuration);\n\tcountdown.tick.sound = undefined;\n\tcountdown.ticking.sound = undefined;\n}\n\n/**\n * Resets all data so a new game can be loaded\n */\nfunction resetClocks(): void {\n\tstopClocks();\n\tlowtimeNotif.playersNotified = new Set();\n}\n\nfunction update(basegame: Game): void {\n\tif (basegame.untimed || basegame.gameConclusion || !moveutil.isGameResignable(basegame)) return;\n\tconst clocks = basegame.clocks!;\n\n\t// Update border color\n\tif (clocks.colorTicking !== undefined)\n\t\tupdateBorderColor(\n\t\t\tbasegame.clocks,\n\t\t\telement_timers[clocks.colorTicking]!.timer,\n\t\t\tclocks.currentTime[clocks.colorTicking]!,\n\t\t);\n\tupdateTextContent(basegame.clocks);\n}\n\nfunction edit(basegame: Game): void {\n\tif (basegame.untimed) return;\n\tupdateTextContent(basegame.clocks);\n\n\t// Remove colored border\n\tfor (const [playerStr, clockElements] of Object.entries(element_timers)) {\n\t\tconst player = Number(playerStr) as Player;\n\t\tif (player === basegame.clocks.colorTicking) continue;\n\t\tremoveBorder(clockElements.timer);\n\t}\n\n\trescheduleSoundEffects(basegame.clocks);\n}\n\nfunction rescheduleSoundEffects(clocks: ClockData): void {\n\tcancelSoundEffectTimers(); // Clear the previous timeouts\n\n\tif (clocks.colorTicking === undefined) return; // Don't reschedule sound effects if no clocks are ticking\n\tif (!gameloader.areInLocalGame() && clocks.colorTicking !== gameloader.getOurColor()) return; // Don't play the sound effect for our opponent.\n\n\tscheduleMinuteTick(clocks); // Lowtime notif at 1 minute left\n\tscheduleCountdown(clocks); // Schedule 10s drum countdown\n}\n\nfunction removeBorder(element: HTMLElement): void {\n\telement.style.outline = '';\n}\n\n/**\n * Changes the border color gradually\n */\nfunction updateBorderColor(\n\tclocks: ClockData,\n\telement: HTMLElement,\n\tcurrentTimeRemain: number,\n): void {\n\tconst percRemain = currentTimeRemain / (clocks.startTime.minutes * 60 * 1000);\n\n\t// Green => Yellow => Orange => Red\n\tconst perc = 1 - percRemain;\n\tlet r = 0,\n\t\tg = 0,\n\t\tb = 0;\n\tif (percRemain > 1 + 1 / 3) {\n\t\tg = 1;\n\t\tb = 1;\n\t} else if (percRemain > 1) {\n\t\tconst localPerc = (percRemain - 1) * 3;\n\t\tg = 1;\n\t\tb = localPerc;\n\t} else if (perc < 0.5) {\n\t\t// Green => Yellow\n\t\tconst localPerc = perc * 2;\n\t\tr = localPerc;\n\t\tg = 1;\n\t} else if (perc < 0.75) {\n\t\t// Yellow => Orange\n\t\tconst localPerc = (perc - 0.5) * 4;\n\t\tr = 1;\n\t\tg = 1 - localPerc * 0.5;\n\t} else {\n\t\t// Orange => Red\n\t\tconst localPerc = (perc - 0.75) * 4;\n\t\tr = 1;\n\t\tg = 0.5 - localPerc * 0.5;\n\t}\n\n\telement.style.outline = `3px solid rgb(${r * 255},${g * 255},${b * 255})`;\n}\n\n/**\n * Updates the clocks' text content in the document.\n */\nfunction updateTextContent(clocks: ClockData): void {\n\tfor (const [playerStr, clockElements] of Object.entries(element_timers)) {\n\t\tconst player = Number(playerStr) as Player;\n\t\tconst text = clockutil.getTextContentFromTimeRemain(clocks.currentTime[player]!);\n\t\tclockElements.timer.textContent = text;\n\t}\n}\n\n// The lowtime notification...\n/**\n * Reschedules the timer to play the ticking sound effect at 1 minute remaining.\n */\nfunction scheduleMinuteTick(clocks: ClockData): void {\n\tif (clocks.startTime.minutes < lowtimeNotif.clockMinsRequiredToUse) return; // 1 minute lowtime notif is not used in bullet games.\n\tif (lowtimeNotif.playersNotified.has(clocks.colorTicking!)) return;\n\tconst timeRemainAtTurnStart = clocks.timeRemainAtTurnStart!;\n\tconst timeRemain = timeRemainAtTurnStart - lowtimeNotif.timeToStartFromEnd; // Time remaining until sound it should start playing\n\tif (timeRemain < 0) return;\n\tlowtimeNotif.timeoutID = setTimeout(() => playMinuteTick(clocks.colorTicking!), timeRemain);\n}\n\nfunction playMinuteTick(color: Player): void {\n\tgamesound.playTick({ volume: 0.07 });\n\tlowtimeNotif.playersNotified.add(color);\n}\n\nfunction set(basegame: Game): void {\n\tif (basegame.untimed) return hideClocks();\n\telse showClocks();\n\tupdateTextContent(basegame.clocks);\n}\n\n// The 10s drum countdown...\n/** Reschedules the timer to play the 10-second countdown effect. */\nfunction scheduleCountdown(clocks: ClockData): void {\n\tscheduleDrum(clocks);\n\tscheduleTicking(clocks);\n\tscheduleTick(clocks);\n}\n\nfunction push(clocks: ClockData): void {\n\trescheduleSoundEffects(clocks);\n\n\t// Remove colored border\n\tfor (const [color, clockElements] of Object.entries(element_timers)) {\n\t\tconst player = Number(color) as Player;\n\t\tif (player === clocks.colorTicking) continue;\n\t\tremoveBorder(clockElements.timer);\n\t}\n}\n\nfunction scheduleDrum(clocks: ClockData): void {\n\t// We have to use this instead of reading the current clock values\n\t// because those aren't updated every frame when the page isn't focused!!\n\tconst playerTrueTimeRemaining = clock.getColorTickingTrueTimeRemaining(clocks)!;\n\tconst timeUntil10SecsRemain = playerTrueTimeRemaining - 10000;\n\tlet timeNextDrum = timeUntil10SecsRemain;\n\tlet secsRemaining = 10;\n\tif (timeNextDrum < 0) {\n\t\tconst addTimeNextDrum = -Math.floor(timeNextDrum / 1000) * 1000;\n\t\ttimeNextDrum += addTimeNextDrum;\n\t\tsecsRemaining -= addTimeNextDrum / 1000;\n\t}\n\t// console.log(\"Rescheduling drum countdown in \", timeNextDrum, \"ms\");\n\tcountdown.drum.timeoutID = setTimeout(\n\t\t() => playDrumAndQueueNext(clocks, secsRemaining),\n\t\ttimeNextDrum,\n\t);\n}\n\nfunction scheduleTicking(clocks: ClockData): void {\n\tif (clocks.timeAtTurnStart! < 10000) return;\n\t// We have to use this instead of reading the current clock values\n\t// because those aren't updated every frame when the page isn't focused!!\n\tconst playerTrueTimeRemaining = clock.getColorTickingTrueTimeRemaining(clocks)!;\n\tconst timeRemain = playerTrueTimeRemaining - countdown.ticking.timeToStartFromEnd;\n\tif (timeRemain > 0)\n\t\tcountdown.ticking.timeoutID = setTimeout(() => playTickingEffect(0), timeRemain);\n\telse {\n\t\tconst offset = -timeRemain;\n\t\tplayTickingEffect(offset);\n\t}\n}\n\n// Tick sound effect right BEFORE 10 seconds is hit\nfunction scheduleTick(clocks: ClockData): void {\n\t// We have to use this instead of reading the current clock values\n\t// because those aren't updated every frame when the page isn't focused!!\n\tconst playerTrueTimeRemaining = clock.getColorTickingTrueTimeRemaining(clocks)!;\n\tconst timeRemain = playerTrueTimeRemaining - countdown.tick.timeToStartFromEnd;\n\tif (timeRemain > 0) countdown.tick.timeoutID = setTimeout(() => playTickEffect(0), timeRemain);\n\telse {\n\t\tconst offset = -timeRemain;\n\t\tplayTickEffect(offset);\n\t}\n}\n\nfunction playDrumAndQueueNext(clocks: ClockData, secsRemaining: number): void {\n\tif (secsRemaining === undefined) return console.error('Cannot play drum without secsRemaining');\n\tgamesound.playDrum();\n\n\t// We have to use this instead of reading the current clock values\n\t// because those aren't updated every frame when the page isn't focused!!\n\tconst playerTrueTimeRemaining = clock.getColorTickingTrueTimeRemaining(clocks)!;\n\n\tif (playerTrueTimeRemaining < 1500) return;\n\n\t// Schedule next drum...\n\tconst newSecsRemaining = secsRemaining - 1;\n\tif (newSecsRemaining === 0) return; // Stop\n\tconst timeUntilNextDrum = playerTrueTimeRemaining - newSecsRemaining * 1000;\n\tcountdown.drum.timeoutID = setTimeout(\n\t\t() => playDrumAndQueueNext(clocks, newSecsRemaining),\n\t\ttimeUntilNextDrum,\n\t);\n}\n\nfunction playTickingEffect(offset: number): void {\n\tcountdown.ticking.sound = gamesound.playTicking({ volume: 0.18, offset });\n\tcountdown.ticking.sound?.fadeIn(0.18, countdown.ticking.fadeInDuration);\n}\n\nfunction playTickEffect(offset: number): void {\n\tcountdown.tick.sound = gamesound.playTick({ volume: 0, offset });\n\tcountdown.tick.sound?.fadeIn(0.07, countdown.tick.fadeInDuration);\n}\n\nexport default {\n\thideClocks,\n\tshowClocks,\n\tset,\n\tstopClocks,\n\tedit,\n\tpush,\n\tupdate,\n\trescheduleSoundEffects,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/guidrawoffer.ts",
    "content": "// src/client/scripts/esm/game/gui/guidrawoffer.ts\n\n/**\n * This script opens and closes our Draw Offer UI\n * on the bottom navigation bar.\n *\n * It does NOT calculate if extending an offer is legal,\n * nor does it keep track of our current offers!\n */\n\nimport gameslot from '../chess/gameslot.js';\nimport guiclock from './guiclock.js';\nimport drawoffers from '../misc/onlinegame/drawoffers.js';\nimport guigameinfo from './guigameinfo.js';\n\n// Variables -------------------------------------------------------------------\n\nconst element_draw_offer_ui = document.getElementById('draw_offer_ui')!;\nconst element_acceptDraw = document.getElementById('acceptdraw')!;\nconst element_declineDraw = document.getElementById('declinedraw')!;\nconst element_whosturn = document.getElementById('whosturn')!;\n\n/** Whether the player names and clocks have been hidden to give space for the draw offer UI */\nlet drawOfferUICramped: boolean = false;\n\n// Functions -------------------------------------------------------------------\n\n/** Reveals the draw offer UI on the bottom navigation bar */\nfunction open(): void {\n\telement_draw_offer_ui.classList.remove('hidden');\n\telement_whosturn.classList.add('hidden');\n\tinitDrawOfferListeners();\n\t// Do the names and clocks need to be hidden to make room for the draw offer UI?\n\tupdateVisibilityOfNamesAndClocksWithDrawOffer();\n}\n\n/** Hides the draw offer UI on the bottom navigation bar */\nfunction close(): void {\n\telement_draw_offer_ui.classList.add('hidden');\n\telement_whosturn.classList.remove('hidden');\n\tcloseDrawOfferListeners();\n\n\tif (!drawOfferUICramped) return;\n\t// We had hid the names and clocks to make room for the UI, reveal them here!\n\t// console.log(\"revealing\");\n\tguigameinfo.revealPlayerNames();\n\tguiclock.showClocks();\n\tdrawOfferUICramped = false; // Reset for next draw offer UI opening\n}\n\nfunction initDrawOfferListeners(): void {\n\telement_acceptDraw.addEventListener('click', drawoffers.callback_AcceptDraw);\n\telement_declineDraw.addEventListener('click', drawoffers.callback_declineDraw);\n}\n\nfunction closeDrawOfferListeners(): void {\n\telement_acceptDraw.removeEventListener('click', drawoffers.callback_AcceptDraw);\n\telement_declineDraw.removeEventListener('click', drawoffers.callback_declineDraw);\n}\n\n/**\n * Hides/reveals the player names and clocks depending on if the draw offer UI has\n * enough space to fit with them.\n * This is called when the UI is opened, AND on screen resize event!\n */\nfunction updateVisibilityOfNamesAndClocksWithDrawOffer(): void {\n\tif (!drawoffers.areWeAcceptingDraw()) return; // No open draw offer\n\n\tif (isDrawOfferUICramped()) {\n\t\t// Hide the player names and clocks\n\t\tif (drawOfferUICramped) return; // Already hidden\n\t\t// console.log(\"hiding\");\n\t\tdrawOfferUICramped = true;\n\t\tguigameinfo.hidePlayerNames();\n\t\tguiclock.hideClocks();\n\t} else {\n\t\t// We have space now, reveal them!\n\t\tif (!drawOfferUICramped) return; // Already revealed\n\t\t// console.log(\"revealing\");\n\t\tdrawOfferUICramped = false;\n\t\tguigameinfo.revealPlayerNames();\n\t\tguiclock.showClocks();\n\t}\n}\n\n/**\n * Returns true if the screen is small enough for the\n * draw offer UI to not fit with everything on the header bar.\n */\nfunction isDrawOfferUICramped(): boolean {\n\tif (gameslot.getGamefile()!.basegame.untimed) return false; // Clocks not visible, we definitely have room\n\tif (window.innerWidth > 560) return false; // Screen is wide, we have room\n\treturn true; // Cramped\n}\n\nexport default {\n\topen,\n\tclose,\n\tupdateVisibilityOfNamesAndClocksWithDrawOffer,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/guigameinfo.ts",
    "content": "// src/client/scripts/esm/game/gui/guigameinfo.ts\n\n/**\n * This script handles the game info bar, during a game,\n * displaying the clocks, and whos turn it currently is.\n */\n\nimport type { PlayerGroup } from '../../../../../shared/chess/util/typeutil.js';\nimport type { GameConclusion } from '../../../../../shared/chess/util/winconutil.js';\nimport type { MetaData, PlayerRatingChangeInfo, Rating } from '../../../../../shared/types.js';\nimport type { RatingItem, UsernameContainer, UsernameItem } from '../../util/usernamecontainer.js';\n\nimport metadatautil from '../../../../../shared/chess/util/metadatautil.js';\nimport gamefileutility from '../../../../../shared/chess/util/gamefileutility.js';\nimport { players as p } from '../../../../../shared/chess/util/typeutil.js';\n\nimport gameslot from '../chess/gameslot.js';\nimport onlinegame from '../misc/onlinegame/onlinegame.js';\nimport gameloader from '../chess/gameloader.js';\nimport enginegame from '../misc/enginegame.js';\nimport boardeditor from '../boardeditor/boardeditor.js';\nimport frametracker from '../rendering/frametracker.js';\nimport usernamecontainer from '../../util/usernamecontainer.js';\nimport clientmetadatautil from '../chess/clientmetadatautil.js';\n\n// Elements ---------------------------------------------------\n\nconst element_gameInfoBar = document.getElementById('game-info-bar')!;\n\nconst element_whosturn = document.getElementById('whosturn')!;\nconst element_playerWhiteContainer = document.querySelector('.player-container.left')!;\nconst element_playerBlackContainer = document.querySelector('.player-container.right')!;\nconst element_playerWhite = document.getElementById('playerwhite')!;\nconst element_playerBlack = document.getElementById('playerblack')!;\nconst element_practiceButtons = document.querySelector('.practice-engine-buttons')!;\nconst element_undoButton: HTMLButtonElement = document.getElementById(\n\t'undobutton',\n)! as HTMLButtonElement;\nconst element_restartButton: HTMLButtonElement = document.getElementById(\n\t'restartbutton',\n) as HTMLButtonElement;\n\n// Variables ---------------------------------------------------\n\nlet isOpen = false;\n/** Whether to show the practice mode game control buttons - undo move and restart. */\nlet showButtons = false;\n\n// Username container objects and their respective display options:\nlet usernamecontainer_white: UsernameContainer | undefined;\nlet usernamecontainer_black: UsernameContainer | undefined;\n\n// Functions\n\n/**\n *\n * @param metadata - The metadata of the gamefile, with its respective White and Black player names\n * @param {boolean} showGameControlButtons\n */\nfunction open(metadata: MetaData, showGameControlButtons?: boolean): void {\n\t// console.log(\"Opening game info bar\");\n\n\tif (showGameControlButtons) showButtons = showGameControlButtons;\n\telse showButtons = false;\n\n\tif (!usernamecontainer_white || !usernamecontainer_black) {\n\t\t// Generate username containers\n\t\tembedUsernameContainers(metadata);\n\t} // Else username containers already exist (\"N\" key toggled bar)\n\n\tupdateWhosTurn();\n\telement_gameInfoBar.classList.remove('hidden');\n\n\tif (showButtons) {\n\t\telement_practiceButtons.classList.remove('hidden');\n\t\tinitListeners_Gamecontrol();\n\t} else element_practiceButtons.classList.add('hidden');\n\n\tisOpen = true;\n}\n\nfunction embedUsernameContainers(gameMetadata: MetaData): void {\n\t// console.log(\"Embedding username containers\");\n\n\tconst { white, black, white_type, black_type } = getPlayerNamesForGame(gameMetadata);\n\n\tconst playerRatings: PlayerGroup<Rating> | undefined = onlinegame.areInOnlineGame()\n\t\t? onlinegame.getPlayerRatings()\n\t\t: undefined;\n\n\t// Set white username container\n\tconst username_item_white: UsernameItem = { value: white, openInNewWindow: true };\n\tconst change_white = gameMetadata.WhiteRatingDiff\n\t\t? Number(gameMetadata.WhiteRatingDiff)\n\t\t: undefined;\n\tconst rating_item_white: RatingItem | undefined = playerRatings?.[p.WHITE]\n\t\t? {\n\t\t\t\tvalue: playerRatings[p.WHITE]!.value + (change_white ?? 0),\n\t\t\t\tconfident: playerRatings[p.WHITE]!.confident,\n\t\t\t\tchange: change_white,\n\t\t\t}\n\t\t: undefined;\n\tusernamecontainer_white = usernamecontainer.createUsernameContainer(\n\t\twhite_type,\n\t\tusername_item_white,\n\t\trating_item_white,\n\t);\n\tusernamecontainer.embedUsernameContainerDisplayIntoParent(\n\t\tusernamecontainer_white.element,\n\t\telement_playerWhite,\n\t);\n\n\t// Set black username container\n\tconst username_item_black: UsernameItem = { value: black, openInNewWindow: true };\n\tconst change_black = gameMetadata.BlackRatingDiff\n\t\t? Number(gameMetadata.BlackRatingDiff)\n\t\t: undefined;\n\tconst rating_item_black: RatingItem | undefined = playerRatings?.[p.BLACK]\n\t\t? {\n\t\t\t\tvalue: playerRatings[p.BLACK]!.value + (change_black ?? 0),\n\t\t\t\tconfident: playerRatings[p.BLACK]!.confident,\n\t\t\t\tchange: change_black,\n\t\t\t}\n\t\t: undefined;\n\tusernamecontainer_black = usernamecontainer.createUsernameContainer(\n\t\tblack_type,\n\t\tusername_item_black,\n\t\trating_item_black,\n\t);\n\tusernamecontainer.embedUsernameContainerDisplayIntoParent(\n\t\tusernamecontainer_black.element,\n\t\telement_playerBlack,\n\t);\n\n\t// Need to set a timer to allow the document to repaint, because we need to read the updated element widths.\n\tsetTimeout(updateAlignmentUsernames, 0);\n}\n\n/**\n * Hides the game info bar.\n * Does NOT clear/erase the username containers.\n */\nfunction close(): void {\n\t// console.log(\"Closing game info bar\");\n\n\t// Restore the whosturn marker to original content\n\telement_whosturn.textContent = '';\n\n\t// Hide the whole bar\n\telement_gameInfoBar.classList.add('hidden');\n\n\t// Close button listeners\n\tcloseListeners_Gamecontrol();\n\telement_practiceButtons.classList.add('hidden');\n\n\tisOpen = false;\n}\n\n/** Erases the username containers, removing them from the document. */\nfunction clearUsernameContainers(): void {\n\t// console.log(\"Clearing username containers\");\n\n\t// Stop any running number animations\n\tusernamecontainer_white?.animationCancels.forEach((fn) => fn());\n\tusernamecontainer_white?.element.remove();\n\tusernamecontainer_white = undefined;\n\n\t// Stop any running number animations\n\tusernamecontainer_black?.animationCancels.forEach((fn) => fn());\n\tusernamecontainer_black?.element.remove();\n\tusernamecontainer_black = undefined;\n}\n\nfunction initListeners_Gamecontrol(): void {\n\telement_undoButton.addEventListener('click', undoMove);\n\telement_restartButton.addEventListener('click', restartGame);\n\t// For some reason we need this in order to stop the undo button from getting focused when clicked??\n\telement_undoButton.addEventListener('mousedown', preventFocus);\n}\n\nfunction closeListeners_Gamecontrol(): void {\n\telement_undoButton.removeEventListener('click', undoMove);\n\telement_restartButton.removeEventListener('click', restartGame);\n\telement_undoButton.removeEventListener('mousedown', preventFocus);\n}\n\nfunction undoMove(): void {\n\tconst event = new Event('guigameinfo-undoMove');\n\tdocument.dispatchEvent(event);\n}\n\nfunction restartGame(): void {\n\tconst event = new Event('guigameinfo-restart');\n\tdocument.dispatchEvent(event);\n}\n\n/**\n * Disables / Enables the \"Undo Move\" button\n */\nfunction update_GameControlButtons(undoingIsLegal: boolean): void {\n\tif (undoingIsLegal) {\n\t\telement_undoButton.classList.remove('opacity-0_5');\n\t\telement_undoButton.style.cursor = 'pointer';\n\t\telement_undoButton.disabled = false;\n\t} else {\n\t\telement_undoButton.classList.add('opacity-0_5');\n\t\telement_undoButton.style.cursor = 'not-allowed';\n\t\telement_undoButton.disabled = true; // Disables the 'click' event from firing when it is pressed\n\t}\n}\n\nfunction preventFocus(event: Event): void {\n\tevent.preventDefault();\n}\n\n/** Reveales the player names. Typically called after the draw offer UI is closed */\nfunction revealPlayerNames(): void {\n\telement_playerWhiteContainer.classList.remove('hidden');\n\telement_playerBlackContainer.classList.remove('hidden');\n}\n\n/** Hides the player names. Typically to make room for the draw offer UI */\nfunction hidePlayerNames(): void {\n\telement_playerWhiteContainer.classList.add('hidden');\n\telement_playerBlackContainer.classList.add('hidden');\n}\n\nfunction toggle(): void {\n\tif (isOpen) close();\n\telse open(gameslot.getGamefile()!.basegame.metadata, showButtons);\n\t// Flag next frame to be rendered, since the arrows indicators may change locations with the bars toggled.\n\tframetracker.onVisualChange();\n}\n\n/**\n * Given a metadata object, determines the names of the players to be displayed, as well as the type of player,\n * which determines the svg of the username container, and whether it should hyperlink or not.\n */\nfunction getPlayerNamesForGame(metadata: MetaData): {\n\twhite: string;\n\tblack: string;\n\twhite_type: 'player' | 'guest' | 'engine';\n\tblack_type: 'player' | 'guest' | 'engine';\n} {\n\tif (gameloader.getTypeOfGameWeIn() === 'local' || boardeditor.areInBoardEditor()) {\n\t\treturn {\n\t\t\twhite: translations.player_name_white_generic,\n\t\t\tblack: translations.player_name_black_generic,\n\t\t\twhite_type: 'guest',\n\t\t\tblack_type: 'guest',\n\t\t};\n\t} else if (onlinegame.areInOnlineGame()) {\n\t\tif (metadata.White === undefined || metadata.Black === undefined)\n\t\t\tthrow Error(\n\t\t\t\t'White or Black metadata not defined when getting player names for online game.',\n\t\t\t);\n\t\t// If you are a guest, then we want your name to be \"(You)\" instead of \"(Guest)\"\n\t\tconst whiteIsGuest =\n\t\t\tmetadata['White'] === metadatautil.GUEST_NAME_ICN_METADATA ||\n\t\t\tmetadata['White'] === clientmetadatautil.YOU_NAME_ICN_METADATA;\n\t\tconst blackIsGuest =\n\t\t\tmetadata['Black'] === metadatautil.GUEST_NAME_ICN_METADATA ||\n\t\t\tmetadata['Black'] === clientmetadatautil.YOU_NAME_ICN_METADATA;\n\n\t\tconst white =\n\t\t\tonlinegame.areWeColorInOnlineGame(p.WHITE) &&\n\t\t\tmetadata['White'] === metadatautil.GUEST_NAME_ICN_METADATA\n\t\t\t\t? translations.you_indicator\n\t\t\t\t: whiteIsGuest\n\t\t\t\t\t? translations.guest_indicator\n\t\t\t\t\t: metadata['White'];\n\t\tconst black =\n\t\t\tonlinegame.areWeColorInOnlineGame(p.BLACK) &&\n\t\t\tmetadata['Black'] === metadatautil.GUEST_NAME_ICN_METADATA\n\t\t\t\t? translations.you_indicator\n\t\t\t\t: blackIsGuest\n\t\t\t\t\t? translations.guest_indicator\n\t\t\t\t\t: metadata['Black'];\n\n\t\treturn {\n\t\t\twhite: white,\n\t\t\tblack: black,\n\t\t\twhite_type: whiteIsGuest ? 'guest' : 'player',\n\t\t\tblack_type: blackIsGuest ? 'guest' : 'player',\n\t\t};\n\t} else if (enginegame.areInEngineGame()) {\n\t\treturn {\n\t\t\twhite:\n\t\t\t\tmetadata.White === clientmetadatautil.YOU_NAME_ICN_METADATA\n\t\t\t\t\t? translations.you_indicator\n\t\t\t\t\t: metadata.White!,\n\t\t\tblack:\n\t\t\t\tmetadata.Black === clientmetadatautil.YOU_NAME_ICN_METADATA\n\t\t\t\t\t? translations.you_indicator\n\t\t\t\t\t: metadata.Black!,\n\t\t\twhite_type:\n\t\t\t\tmetadata.White === clientmetadatautil.YOU_NAME_ICN_METADATA ? 'guest' : 'engine',\n\t\t\tblack_type:\n\t\t\t\tmetadata.Black === clientmetadatautil.YOU_NAME_ICN_METADATA ? 'guest' : 'engine',\n\t\t};\n\t} else\n\t\tthrow Error(\n\t\t\t'Cannot get player names for game when not in a local, board editor, online, or engine game.',\n\t\t);\n}\n\n/**\n * Updates the text at the bottom of the screen displaying who's turn it is now.\n * Call this after flipping the gamefile's `whosTurn` property.\n */\nfunction updateWhosTurn(): void {\n\tconst { basegame } = gameslot.getGamefile()!;\n\n\t// In the scenario we forward the game to front after the game has adjudicated,\n\t// don't modify the game over text saying who won!\n\tif (gamefileutility.isGameOver(basegame)) return gameEnd(basegame.gameConclusion);\n\n\tconst color = basegame.whosTurn;\n\n\tif (color !== p.WHITE && color !== p.BLACK)\n\t\tthrow Error(\n\t\t\t`Cannot set the document element text showing whos turn it is when color is neither white nor black! ${color}`,\n\t\t);\n\n\tlet textContent = '';\n\tif (!gameloader.areInLocalGame()) {\n\t\tconst ourTurn = gameloader.isItOurTurn();\n\t\ttextContent = ourTurn ? translations.your_move : translations.their_move;\n\t} else\n\t\ttextContent = color === p.WHITE ? translations.white_to_move : translations.black_to_move;\n\n\telement_whosturn.textContent = textContent;\n}\n\n/** Updates the whosTurn text to say who won! */\nfunction gameEnd(conclusion?: GameConclusion): void {\n\tif (conclusion === undefined) throw Error(\"Should not call gameEnd when game isn't over.\");\n\n\tconst { victor, condition } = conclusion;\n\tconst resultTranslations = translations.results;\n\n\tconst { basegame } = gameslot.getGamefile()!;\n\n\t// prettier-ignore\n\tif (onlinegame.areInOnlineGame() && onlinegame.doWeHaveRole() || enginegame.areInEngineGame()) {\n\t\tconst ourRole = gameloader.getOurColor()!;\n\n\t\tif (ourRole === victor) element_whosturn.textContent = condition === 'checkmate' ? resultTranslations.you_checkmate\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t : condition === 'time' ? resultTranslations.you_time\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t : condition === 'resignation' ? resultTranslations.you_resignation\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t : condition === 'disconnect' ? resultTranslations.you_disconnect\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t : condition === 'royalcapture' ? resultTranslations.you_royalcapture\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t : condition === 'allroyalscaptured' ? resultTranslations.you_allroyalscaptured\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t : condition === 'allpiecescaptured' ? resultTranslations.you_allpiecescaptured\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t : condition === 'koth' ? resultTranslations.you_koth\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t : resultTranslations.you_generic;\n\t\telse if (victor === null) element_whosturn.textContent = condition === 'stalemate' ? resultTranslations.draw_stalemate\n                                                               : condition === 'repetition' ? resultTranslations.draw_repetition\n                                                               : condition === 'moverule' ? `${resultTranslations.draw_moverule[0]}${(basegame.gameRules.moveRule! / 2)}${resultTranslations.draw_moverule[1]}`\n                                                               : condition === 'insuffmat' ? resultTranslations.draw_insuffmat\n                                                               : condition === 'agreement' ? resultTranslations.draw_agreement\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t   : resultTranslations.draw_generic;\n\t\telse if (condition === 'aborted') element_whosturn.textContent = resultTranslations.aborted;\n\t\telse /* loss */ element_whosturn.textContent = condition === 'checkmate' ? resultTranslations.opponent_checkmate\n                                                     : condition === 'time' ? resultTranslations.opponent_time\n                                                     : condition === 'resignation' ? resultTranslations.opponent_resignation\n                                                     : condition === 'disconnect' ? resultTranslations.opponent_disconnect\n                                                     : condition === 'royalcapture' ? resultTranslations.opponent_royalcapture\n                                                     : condition === 'allroyalscaptured' ? resultTranslations.opponent_allroyalscaptured\n                                                     : condition === 'allpiecescaptured' ? resultTranslations.opponent_allpiecescaptured\n                                                     : condition === 'koth' ? resultTranslations.opponent_koth\n\t\t\t\t\t\t\t\t\t\t\t\t\t : resultTranslations.opponent_generic;\n\t} else { // Local game, OR spectating an online game\n\t\tif (condition === 'checkmate') element_whosturn.textContent = victor === p.WHITE ? resultTranslations.white_checkmate\n                                                                    : victor === p.BLACK ? resultTranslations.black_checkmate\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: `${resultTranslations.bug_generic} Ending: checkmate`;\n\t\telse if (condition === 'time') element_whosturn.textContent = victor === p.WHITE ? resultTranslations.white_time\n                                                                    : victor === p.BLACK ? resultTranslations.black_time\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: `${resultTranslations.bug_generic} Ending: time`;\n\t\telse if (condition === 'resignation') element_whosturn.textContent = victor === p.WHITE ? resultTranslations.white_resignation\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t   : victor === p.BLACK ? resultTranslations.black_resignation\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t   : `${resultTranslations.bug_generic} Ending: resignation`;\n\t\telse if (condition === 'disconnect') element_whosturn.textContent = victor === p.WHITE ? resultTranslations.white_disconnect\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t  : victor === p.BLACK ? resultTranslations.black_disconnect\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t  : `${resultTranslations.bug_generic} Ending: disconnect`;\n\t\telse if (condition === 'royalcapture') element_whosturn.textContent = victor === p.WHITE ? resultTranslations.white_royalcapture\n                                                                            : victor === p.BLACK ? resultTranslations.black_royalcapture\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: `${resultTranslations.bug_generic} Ending: royalcapture`;\n\t\telse if (condition === 'allroyalscaptured') element_whosturn.textContent = victor === p.WHITE ? resultTranslations.white_allroyalscaptured\n                                                                                 : victor === p.BLACK ? resultTranslations.black_allroyalscaptured\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t : `${resultTranslations.bug_generic} Ending: allroyalscaptured`;\n\t\telse if (condition === 'allpiecescaptured') element_whosturn.textContent = victor === p.WHITE ? resultTranslations.white_allpiecescaptured\n                                                                                 : victor === p.BLACK ? resultTranslations.black_allpiecescaptured\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t : `${resultTranslations.bug_generic} Ending: allpiecescaptured`;\n\t\telse if (condition === 'koth') element_whosturn.textContent = victor === p.WHITE ? resultTranslations.white_koth\n                                                                    : victor === p.BLACK ? resultTranslations.black_koth\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: `${resultTranslations.bug_generic} Ending: koth`;\n\t\telse if (condition === 'stalemate')\n\t\t\telement_whosturn.textContent = resultTranslations.draw_stalemate;\n\t\telse if (condition === 'repetition')\n\t\t\telement_whosturn.textContent = resultTranslations.draw_repetition;\n\t\telse if (condition === 'moverule')\n\t\t\telement_whosturn.textContent = `${resultTranslations.draw_moverule[0]}${basegame.gameRules.moveRule! / 2}${resultTranslations.draw_moverule[1]}`;\n\t\telse if (condition === 'insuffmat')\n\t\t\telement_whosturn.textContent = resultTranslations.draw_insuffmat;\n\t\telse if (condition === 'agreement')\n\t\t\telement_whosturn.textContent = resultTranslations.draw_agreement;\n\t\telse if (condition === 'aborted') element_whosturn.textContent = resultTranslations.aborted;\n\t\telse {\n\t\t\telement_whosturn.textContent = resultTranslations.bug_generic;\n\t\t\tconsole.error(\n\t\t\t\t`Victor: ${victor}\\nCondition: ${condition}`,\n\t\t\t);\n\t\t}\n\t}\n}\n\n/** Returns the height of the game info bar in the document, in virtual pixels. */\nfunction getHeightOfGameInfoBar(): number {\n\treturn element_gameInfoBar.getBoundingClientRect().height;\n}\n\n/**\n * Wide screen => Right-aligns black's username container\n * Narrow screen => Left-aligns black's username container and adds a fade effect on the right overflow\n * Fades either if they exceed the width of their parent.\n */\nfunction updateAlignmentUsernames(): void {\n\tif (!usernamecontainer_white || !usernamecontainer_black) return; // Not in a game\n\n\t// Player white\n\tif (usernamecontainer_white!.element.clientWidth > element_playerWhite.clientWidth) {\n\t\telement_playerWhite.classList.add('fade-element');\n\t} else {\n\t\telement_playerWhite.classList.remove('fade-element');\n\t}\n\n\t// Player black\n\tif (usernamecontainer_black!.element.clientWidth > element_playerBlack.clientWidth) {\n\t\telement_playerBlack.classList.remove('justify-content-right');\n\t\telement_playerBlack.classList.add('justify-content-left');\n\t\telement_playerBlack.classList.add('fade-element');\n\t} else {\n\t\telement_playerBlack.classList.add('justify-content-right');\n\t\telement_playerBlack.classList.remove('justify-content-left');\n\t\telement_playerBlack.classList.remove('fade-element');\n\t}\n}\n\n/**\n * This gets called when the client receives a \"gameratingchange\" message from a websocket\n * Displays the rating changes from the game in the existing username containers, while keeping all display options the same\n */\nfunction addRatingChangeToExistingUsernameContainers(\n\tratingChanges: PlayerGroup<PlayerRatingChangeInfo>,\n): void {\n\t// Add the WhiteRatingDiff and BlackRatingDiff metadata to the gamefile\n\tconst { basegame } = gameslot.getGamefile()!;\n\tbasegame.metadata.WhiteRatingDiff = metadatautil.getWhiteBlackRatingDiff(\n\t\tratingChanges[p.WHITE]!.change,\n\t);\n\tbasegame.metadata.BlackRatingDiff = metadatautil.getWhiteBlackRatingDiff(\n\t\tratingChanges[p.BLACK]!.change,\n\t);\n\n\t// Update username containers\n\tusernamecontainer.createEloChangeItem(\n\t\tusernamecontainer_white!,\n\t\tratingChanges[p.WHITE]!.newRating,\n\t\tratingChanges[p.WHITE]!.change,\n\t);\n\tusernamecontainer.createEloChangeItem(\n\t\tusernamecontainer_black!,\n\t\tratingChanges[p.BLACK]!.newRating,\n\t\tratingChanges[p.BLACK]!.change,\n\t);\n\n\t// Need to set a timer to allow the document to repaint, because we need to read the updated element widths.\n\tsetTimeout(updateAlignmentUsernames, 0);\n}\n\nexport default {\n\topen,\n\tclose,\n\tclearUsernameContainers,\n\tupdate_GameControlButtons,\n\trevealPlayerNames,\n\thidePlayerNames,\n\ttoggle,\n\tupdateWhosTurn,\n\tgameEnd,\n\tgetHeightOfGameInfoBar,\n\tupdateAlignmentUsernames,\n\taddRatingChangeToExistingUsernameContainers,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/guiloading.ts",
    "content": "// src/client/scripts/esm/game/gui/guiloading.ts\n\n/**\n * This script hides the loading animation when the page fully loads.\n * */\n\n// Loading Animation Before Page Load\nconst element_loadingAnimation = document.getElementById('loading-animation')!;\n\n/** THIS SHOULD MATCH THE transition time declared in the css stylesheet!! */\nconst durationOfFadeOutMillis = 400;\n\n/** Stops the loading screen animation. */\nfunction closeAnimation(): void {\n\tsetTimeout(() => {\n\t\telement_loadingAnimation.classList.add('hidden');\n\t}, durationOfFadeOutMillis);\n\n\telement_loadingAnimation.style.opacity = '0';\n}\n\nexport default {\n\tcloseAnimation,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/guinavigation.ts",
    "content": "// src/client/scripts/esm/game/gui/guinavigation.ts\n\nimport type { BDCoords } from '../../../../../shared/chess/util/coordutil.js';\nimport type { BoundingBox } from '../../../../../shared/util/math/bounds.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport bimath from '../../../../../shared/util/math/bimath.js';\nimport moveutil from '../../../../../shared/chess/util/moveutil.js';\nimport bdcoords from '../../../../../shared/chess/util/bdcoords.js';\nimport boardutil from '../../../../../shared/chess/util/boardutil.js';\n\nimport toast from './toast.js';\nimport stats from './stats.js';\nimport mouse from '../../util/mouse.js';\nimport space from '../misc/space.js';\nimport guipause from './guipause.js';\nimport gameslot from '../chess/gameslot.js';\nimport boardpos from '../rendering/boardpos.js';\nimport snapping from '../rendering/highlights/snapping.js';\nimport premoves from '../chess/premoves.js';\nimport selection from '../chess/selection.js';\nimport onlinegame from '../misc/onlinegame/onlinegame.js';\nimport Transition from '../rendering/transitions/Transition.js';\nimport annotations from '../rendering/highlights/annotations/annotations.js';\nimport edithistory from '../boardeditor/edithistory.js';\nimport { GameBus } from '../GameBus.js';\nimport frametracker from '../rendering/frametracker.js';\nimport movesequence from '../chess/movesequence.js';\nimport guiboardeditor from './boardeditor/guiboardeditor.js';\nimport { listener_document, listener_overlay } from '../chess/game.js';\n\n/**\n * This script handles the navigation bar, in a game,\n * along the top of the screen, containing the teleporation\n * buttons, rewind move, forward move, and pause buttons.\n */\n\nconst element_Navigation = document.getElementById('navigation-bar')!;\n\n// Navigation\nconst element_Recenter = document.getElementById('recenter')!;\nconst element_Expand = document.getElementById('expand')!;\nconst element_Back = document.getElementById('back')!;\nconst element_Annotations = document.getElementById('annotations')!;\nconst element_Erase = document.getElementById('erase')!;\nconst element_Collapse = document.getElementById('collapse')!;\n\n// const element_AnnotationsContainer = document.querySelector('.buttoncontainer.annotations')!;\nconst element_EraseContainer = document.querySelector('.buttoncontainer.erase')!;\nconst element_CollapseContainer = document.querySelector('.buttoncontainer.collapse')!;\n\nconst element_CoordsX = document.getElementById('x') as HTMLInputElement;\nconst element_CoordsY = document.getElementById('y') as HTMLInputElement;\n\nconst element_moveRewind = document.getElementById('move-left')!;\nconst element_moveForward = document.getElementById('move-right')!;\nconst element_undoEdit = document.getElementById('undo-edit')!;\nconst element_redoEdit = document.getElementById('redo-edit')!;\nconst element_pause = document.getElementById('pause')!;\n\n/**\n * A limit posed against teleporting too far.\n *\n * Don't want players to discover new zones quickly\n * without doing the work of zooming out :)\n * That would decrease the reward.\n *\n * FUTURE: I could allow teleporting up to 1e10000.\n * I roughly determined 1e75000 to be the bound for\n * no noticeable lag in websocket message size.\n * That would still prevent instantly exceeding that.\n */\nconst TELEPORT_LIMIT: bigint = 10n ** 30n; // 10^30 squares\n\nconst timeToHoldMillis = 250; // After holding the button this long, moves will fast-rewind or edits will fast undo/redo\nconst intervalToRepeat = 40; // Default 40. How quickly moves will fast-rewind or edits will fast undo/redo\nconst minimumRewindOrEditIntervalMillis = 20; // Rewinding, forwarding, undoing and redoing can never be spammed faster than this\nlet lastRewindOrEdit = 0;\n\nlet leftArrowTimeoutID: ReturnType<typeof setTimeout>; // setTimeout to BEGIN rewinding or undoing\nlet leftArrowIntervalID: ReturnType<typeof setTimeout>; // setInterval to CONTINUE rewinding or undoing\nlet touchIsInsideLeft = false;\n\nlet rightArrowTimeoutID: ReturnType<typeof setTimeout>; // setTimeout to BEGIN forwarding or redoing\nlet rightArrowIntervalID: ReturnType<typeof setTimeout>; // setInterval to CONTINUE forwarding or redoing\nlet touchIsInsideRight = false;\n\nlet rewindIsLocked = false;\nconst durationToLockRewindAfterMoveForwardingMillis = 750;\n\n/** Whether the navigation UI is visible (not hidden) */\nlet navigationOpen = true;\n\n/**\n * Whether the annotations button is enabled.\n * If so, all left click actions are treated as right clicks.\n */\nlet annotationsEnabled: boolean = false;\n\n// Events ----------------------------------------------------------------------------------\n\nGameBus.addEventListener('game-unloaded', () => {\n\t// Reset Annotations mode button state, without closing the Navigation Bar.\n\tannotationsEnabled = false;\n\tlistener_overlay.setTreatLeftasRight(false);\n\telement_Annotations.classList.remove('enabled');\n\n\thideCollapse();\n});\n\n// =================================================================================\n\n// Functions\n\nfunction isOpen(): boolean {\n\treturn navigationOpen;\n}\n\n/** Called when we push 'N' on the keyboard */\nfunction toggle(): void {\n\tif (navigationOpen) close();\n\telse open({ allowEditCoords: !onlinegame.areInOnlineGame() });\n\t// Flag next frame to be rendered, since the arrows indicators may change locations with the bars toggled.\n\tframetracker.onVisualChange();\n}\n\nfunction open({ allowEditCoords = true }: { allowEditCoords?: boolean }): void {\n\telement_Navigation.classList.remove('hidden');\n\tif (!guiboardeditor.isOpen()) {\n\t\t// Normal game => Show navigate move buttons\n\t\telement_moveRewind.classList.remove('hidden');\n\t\telement_moveForward.classList.remove('hidden');\n\t\telement_undoEdit.classList.add('hidden');\n\t\telement_redoEdit.classList.add('hidden');\n\t\tupdate_MoveButtons();\n\t} else {\n\t\t// Board editor => Show undo/redo edit buttons\n\t\telement_moveRewind.classList.add('hidden');\n\t\telement_moveForward.classList.add('hidden');\n\t\telement_undoEdit.classList.remove('hidden');\n\t\telement_redoEdit.classList.remove('hidden');\n\t\tupdate_EditButtons();\n\t}\n\tinitListeners_Navigation();\n\tinitCoordinates({ allowEditCoords });\n\tnavigationOpen = true;\n\tstats.updateStatsCSS();\n}\n\nfunction initCoordinates({ allowEditCoords }: { allowEditCoords: boolean }): void {\n\tif (allowEditCoords) {\n\t\telement_CoordsX.disabled = false;\n\t\telement_CoordsY.disabled = false;\n\t\telement_CoordsX.classList.remove('set-cursor-to-not-allowed');\n\t\telement_CoordsY.classList.remove('set-cursor-to-not-allowed');\n\t} else {\n\t\telement_CoordsX.disabled = true;\n\t\telement_CoordsY.disabled = true;\n\t\telement_CoordsX.classList.add('set-cursor-to-not-allowed');\n\t\telement_CoordsY.classList.add('set-cursor-to-not-allowed');\n\t}\n}\n\nfunction close(): void {\n\telement_Navigation.classList.add('hidden');\n\tcloseListeners_Navigation();\n\tnavigationOpen = false;\n\tstats.updateStatsCSS();\n}\n\n// =============================== Coordinate Fields ===============================\n\n// Update the division on the screen displaying your current coordinates\nfunction updateElement_Coords(): void {\n\tif (isCoordinateActive()) return; // Don't update the coordinates if the user is editing them\n\n\tconst boardPos = boardpos.getBoardPos();\n\tconst mouseTile = mouse.getTileMouseOver_Integer();\n\n\tconst xDisplayCoord = mouseTile ? mouseTile[0] : space.roundCoord(boardPos[0]);\n\tconst yDisplayCoord = mouseTile ? mouseTile[1] : space.roundCoord(boardPos[1]);\n\n\t// If the number is too big to fit in the input box, display it in exponential notation instead.\n\tdisplayBigIntInInput(element_CoordsX, xDisplayCoord, 3);\n\tdisplayBigIntInInput(element_CoordsY, yDisplayCoord, 3);\n}\n\n/**\n * Displays a BigInt in an input element. If it overflows,\n * it's displayed in exponential notation instead.\n * @param inputElement The input element to display the number in.\n * @param bigint The BigInt value to display.\n * @param precision The precision for the exponential notation.\n */\nfunction displayBigIntInInput(\n\tinputElement: HTMLInputElement,\n\tbigint: bigint,\n\tprecision: number,\n): void {\n\t// First, try to display the full number by setting the .value\n\tinputElement.value = bigint.toString();\n\n\t// Check for overflow.\n\tif (inputElement.scrollWidth > inputElement.clientWidth + 1) {\n\t\t// Needs the +1 due to floating point stuff. Else sometimes at random font sizes this is true when it shouldn't be.\n\t\t// Format it and set the .value again.\n\t\tinputElement.value = bimath.formatBigIntExponential(bigint, precision);\n\t}\n}\n\n/**\n * Parses a string representation (either standard or e-notation) into a BigInt.\n * This is the inverse of {@link formatBigIntExponential}.\n * @param value The string to parse. Can be \"12345\" or \"1.23e8\".\n * @returns The resulting BigInt.\n */\nfunction parseStringToBigInt(value: string): bigint {\n\tconst trimmedValue = value.trim();\n\tif (trimmedValue === '') throw Error();\n\n\t// Use case-insensitive check for 'e'\n\tconst eIndex = trimmedValue.toLowerCase().indexOf('e');\n\n\t// Case 1: No scientific notation, just a plain integer string.\n\tif (eIndex === -1) return BigInt(trimmedValue);\n\n\t// Case 2: Scientific notation is present.\n\tconst mantissaStr = trimmedValue.substring(0, eIndex);\n\tconst exponentStr = trimmedValue.substring(eIndex + 1);\n\n\tif (mantissaStr === '' || exponentStr === '') throw Error(); // Malformed e-notation: missing mantissa or exponent\n\n\tconst exponent = parseInt(exponentStr, 10);\n\t// Check if exponent is a valid integer number\n\tif (isNaN(exponent) || !Number.isInteger(exponent)) throw Error();\n\n\t// Since BigInts are whole numbers, a negative exponent would result in a fraction.\n\tif (exponent < 0) throw Error();\n\n\tconst isNegative = mantissaStr.startsWith('-');\n\tconst absMantissaStr = isNegative ? mantissaStr.substring(1) : mantissaStr;\n\n\tconst decimalIndex = absMantissaStr.indexOf('.');\n\tlet allDigits: string;\n\tlet fractionalDigitsCount = 0;\n\n\tif (decimalIndex === -1) {\n\t\t// e.g., \"123e5\"\n\t\tallDigits = absMantissaStr;\n\t} else {\n\t\t// e.g., \"1.23\" -> allDigits = \"123\", fractionalDigitsCount = 2\n\t\tconst integerPart = absMantissaStr.substring(0, decimalIndex);\n\t\tconst fractionalPart = absMantissaStr.substring(decimalIndex + 1);\n\n\t\tallDigits = integerPart + fractionalPart;\n\t\tfractionalDigitsCount = fractionalPart.length;\n\t}\n\n\t// The number of zeros to append is the exponent minus the number of digits\n\t// we already have after the decimal point.\n\tconst zerosToAppend = exponent - fractionalDigitsCount;\n\n\tconst zeros = '0'.repeat(zerosToAppend);\n\tconst finalNumberString = `${isNegative ? '-' : ''}${allDigits}${zeros}`;\n\n\treturn BigInt(finalNumberString);\n}\n\n// =================================================================================\n\n/**\n * Returns true if one of the coordinate fields is active (currently editing)\n */\nfunction isCoordinateActive(): boolean {\n\treturn element_CoordsX === document.activeElement || element_CoordsY === document.activeElement;\n}\n\nfunction initListeners_Navigation(): void {\n\telement_Recenter.addEventListener('click', recenter);\n\telement_Expand.addEventListener('click', callback_Expand);\n\telement_Back.addEventListener('click', callback_Back);\n\telement_Annotations.addEventListener('click', callback_Annotations);\n\telement_Erase.addEventListener('click', callback__Collapse);\n\telement_Collapse.addEventListener('click', callback__Collapse);\n\telement_pause.addEventListener('click', callback_Pause);\n\n\telement_CoordsX.addEventListener('change', callback_CoordsXChange);\n\telement_CoordsY.addEventListener('change', callback_CoordsYChange);\n\n\tif (!guiboardeditor.isOpen()) {\n\t\telement_moveRewind.addEventListener('click', callback_MoveRewind);\n\t\telement_moveRewind.addEventListener('mousedown', callback_MoveRewindMouseDown);\n\t\telement_moveRewind.addEventListener('mouseleave', callback_MoveRewindMouseLeave);\n\t\telement_moveRewind.addEventListener('mouseup', callback_MoveRewindMouseUp);\n\t\telement_moveRewind.addEventListener('touchstart', callback_MoveRewindTouchStart);\n\t\telement_moveRewind.addEventListener('touchmove', callback_MoveRewindTouchMove);\n\t\telement_moveRewind.addEventListener('touchend', callback_MoveRewindTouchEnd);\n\t\telement_moveRewind.addEventListener('touchcancel', callback_MoveRewindTouchEnd);\n\t\telement_moveForward.addEventListener('click', callback_MoveForward);\n\t\telement_moveForward.addEventListener('mousedown', callback_MoveForwardMouseDown);\n\t\telement_moveForward.addEventListener('mouseleave', callback_MoveForwardMouseLeave);\n\t\telement_moveForward.addEventListener('mouseup', callback_MoveForwardMouseUp);\n\t\telement_moveForward.addEventListener('touchstart', callback_MoveForwardTouchStart);\n\t\telement_moveForward.addEventListener('touchmove', callback_MoveForwardTouchMove);\n\t\telement_moveForward.addEventListener('touchend', callback_MoveForwardTouchEnd);\n\t\telement_moveForward.addEventListener('touchcancel', callback_MoveForwardTouchEnd);\n\t} else {\n\t\telement_undoEdit.addEventListener('click', callback_UndoEdit);\n\t\telement_undoEdit.addEventListener('mousedown', callback_UndoEditMouseDown);\n\t\telement_undoEdit.addEventListener('mouseleave', callback_UndoEditMouseLeave);\n\t\telement_undoEdit.addEventListener('mouseup', callback_UndoEditMouseUp);\n\t\telement_undoEdit.addEventListener('touchstart', callback_UndoEditTouchStart);\n\t\telement_undoEdit.addEventListener('touchmove', callback_UndoEditTouchMove);\n\t\telement_undoEdit.addEventListener('touchend', callback_UndoEditTouchEnd);\n\t\telement_undoEdit.addEventListener('touchcancel', callback_UndoEditTouchEnd);\n\t\telement_redoEdit.addEventListener('click', callback_RedoEdit);\n\t\telement_redoEdit.addEventListener('mousedown', callback_RedoEditMouseDown);\n\t\telement_redoEdit.addEventListener('mouseleave', callback_RedoEditMouseLeave);\n\t\telement_redoEdit.addEventListener('mouseup', callback_RedoEditMouseUp);\n\t\telement_redoEdit.addEventListener('touchstart', callback_RedoEditTouchStart);\n\t\telement_redoEdit.addEventListener('touchmove', callback_RedoEditTouchMove);\n\t\telement_redoEdit.addEventListener('touchend', callback_RedoEditTouchEnd);\n\t\telement_redoEdit.addEventListener('touchcancel', callback_RedoEditTouchEnd);\n\t}\n}\n\nfunction closeListeners_Navigation(): void {\n\telement_Recenter.removeEventListener('click', recenter);\n\telement_Expand.removeEventListener('click', callback_Expand);\n\telement_Back.removeEventListener('click', callback_Back);\n\telement_Annotations.removeEventListener('click', callback_Annotations);\n\telement_Erase.removeEventListener('click', callback__Collapse);\n\telement_Collapse.removeEventListener('click', callback__Collapse);\n\telement_Back.removeEventListener('click', callback_Pause);\n\n\telement_CoordsX.removeEventListener('change', callback_CoordsXChange);\n\telement_CoordsY.removeEventListener('change', callback_CoordsYChange);\n\n\tif (!guiboardeditor.isOpen()) {\n\t\telement_moveRewind.removeEventListener('click', callback_MoveRewind);\n\t\telement_moveRewind.removeEventListener('mousedown', callback_MoveRewindMouseDown);\n\t\telement_moveRewind.removeEventListener('mouseleave', callback_MoveRewindMouseLeave);\n\t\telement_moveRewind.removeEventListener('mouseup', callback_MoveRewindMouseUp);\n\t\telement_moveRewind.removeEventListener('touchstart', callback_MoveRewindTouchStart);\n\t\telement_moveRewind.removeEventListener('touchmove', callback_MoveRewindTouchMove);\n\t\telement_moveRewind.removeEventListener('touchend', callback_MoveRewindTouchEnd);\n\t\telement_moveRewind.removeEventListener('touchcancel', callback_MoveRewindTouchEnd);\n\t\telement_moveForward.removeEventListener('click', callback_MoveForward);\n\t\telement_moveForward.removeEventListener('mousedown', callback_MoveForwardMouseDown);\n\t\telement_moveForward.removeEventListener('mouseleave', callback_MoveForwardMouseLeave);\n\t\telement_moveForward.removeEventListener('mouseup', callback_MoveForwardMouseUp);\n\t\telement_moveForward.removeEventListener('touchstart', callback_MoveForwardTouchStart);\n\t\telement_moveForward.removeEventListener('touchmove', callback_MoveForwardTouchMove);\n\t\telement_moveForward.removeEventListener('touchend', callback_MoveForwardTouchEnd);\n\t\telement_moveForward.removeEventListener('touchcancel', callback_MoveForwardTouchEnd);\n\t} else {\n\t\telement_undoEdit.removeEventListener('click', callback_UndoEdit);\n\t\telement_undoEdit.removeEventListener('mousedown', callback_UndoEditMouseDown);\n\t\telement_undoEdit.removeEventListener('mouseleave', callback_UndoEditMouseLeave);\n\t\telement_undoEdit.removeEventListener('mouseup', callback_UndoEditMouseUp);\n\t\telement_undoEdit.removeEventListener('touchstart', callback_UndoEditTouchStart);\n\t\telement_undoEdit.removeEventListener('touchmove', callback_UndoEditTouchMove);\n\t\telement_undoEdit.removeEventListener('touchend', callback_UndoEditTouchEnd);\n\t\telement_undoEdit.removeEventListener('touchcancel', callback_UndoEditTouchEnd);\n\t\telement_redoEdit.removeEventListener('click', callback_RedoEdit);\n\t\telement_redoEdit.removeEventListener('mousedown', callback_RedoEditMouseDown);\n\t\telement_redoEdit.removeEventListener('mouseleave', callback_RedoEditMouseLeave);\n\t\telement_redoEdit.removeEventListener('mouseup', callback_RedoEditMouseUp);\n\t\telement_redoEdit.removeEventListener('touchstart', callback_RedoEditTouchStart);\n\t\telement_redoEdit.removeEventListener('touchmove', callback_RedoEditTouchMove);\n\t\telement_redoEdit.removeEventListener('touchend', callback_RedoEditTouchEnd);\n\t\telement_redoEdit.removeEventListener('touchcancel', callback_RedoEditTouchEnd);\n\t}\n}\n\n/** Called when the field is FINISHED being edited, not on every keystroke. */\nfunction callback_CoordsXChange(): void {\n\telement_CoordsX.blur();\n\tcallback_CoordsChange(0);\n}\n\n/** Called when the field is FINISHED being edited, not on every keystroke. */\nfunction callback_CoordsYChange(): void {\n\telement_CoordsY.blur();\n\tcallback_CoordsChange(1);\n}\n\nfunction callback_CoordsChange(index: 0 | 1): void {\n\tconst target: HTMLInputElement = index === 0 ? element_CoordsX : element_CoordsY;\n\n\tconst boardPos = boardpos.getBoardPos();\n\tlet teleportX: BigDecimal = boardPos[0];\n\tlet teleportY: BigDecimal = boardPos[1];\n\n\tlet proposed: bigint;\n\ttry {\n\t\tproposed = parseStringToBigInt(target.value);\n\t} catch (_e) {\n\t\tconsole.log(`Entered: ${target.value}`);\n\t\ttoast.show(translations['coords-invalid'], { error: true });\n\t\treturn;\n\t}\n\n\tif (bimath.abs(proposed) > TELEPORT_LIMIT) {\n\t\ttoast.show(translations['coords-exceeded'], { error: true });\n\t\treturn;\n\t}\n\n\tif (index === 0) teleportX = bd.fromBigInt(proposed);\n\telse teleportY = bd.fromBigInt(proposed);\n\n\tconst newPos: BDCoords = [teleportX, teleportY];\n\tboardpos.setBoardPos(newPos);\n}\n\nfunction callback_Back(): void {\n\tTransition.undoTransition();\n}\n\nfunction callback_Expand(): void {\n\tconst box: Partial<BoundingBox> =\n\t\tboardutil.getBoundingBoxOfAllPieces(gameslot.getGamefile()!.boardsim.pieces) ?? {};\n\n\t// Add the square annotation highlights, too.\n\n\t// THIS ROUNDS RAY intersections to the nearest integer coordinate, so the resulting area may be imperfect!!!!!\n\t// I don't think it matters to much.\n\tconst annoteSnapPoints = snapping\n\t\t.getAnnoteSnapPoints(false)\n\t\t.map((point) => bdcoords.coordsToBigInt(point));\n\n\t// Expand the box to include all annote snap points\n\tfor (const snapPoint of annoteSnapPoints) {\n\t\tif (box.left === undefined || snapPoint[0] < box.left) box.left = snapPoint[0];\n\t\tif (box.right === undefined || snapPoint[0] > box.right) box.right = snapPoint[0];\n\t\tif (box.bottom === undefined || snapPoint[1] < box.bottom) box.bottom = snapPoint[1];\n\t\tif (box.top === undefined || snapPoint[1] > box.top) box.top = snapPoint[1];\n\t}\n\n\t// If any sides are still undefined, set them to default values\n\tconst definedBox: BoundingBox =\n\t\tbox.left === undefined ||\n\t\tbox.right === undefined ||\n\t\tbox.bottom === undefined ||\n\t\tbox.top === undefined\n\t\t\t? { left: 1n, right: 8n, bottom: 1n, top: 8n }\n\t\t\t: (box as BoundingBox);\n\n\tTransition.zoomToCoordsBox(definedBox);\n}\n\nfunction recenter(): void {\n\tTransition.zoomToCoordsBox(gameslot.getGamefile()!.boardsim.startSnapshot.box); // If you know the bounding box, you don't need a coordinate list\n}\n\n// Annotations Buttons ======================================\n\nfunction callback_Annotations(): void {\n\tannotationsEnabled = !annotationsEnabled;\n\tlistener_overlay.setTreatLeftasRight(annotationsEnabled);\n\telement_Annotations.classList.toggle('enabled');\n}\n\n/** Returns whether the annotations button on the navigation bar on mobile devices is enabled (glowing RED) */\nfunction isAnnotationsButtonEnabled(): boolean {\n\treturn annotationsEnabled;\n}\n\nfunction callback__Collapse(): void {\n\tannotations.Collapse();\n}\n\ndocument.addEventListener('ray-count-change', (e) => {\n\tconst rayCount = e.detail;\n\tif (rayCount > 0) showCollapse();\n\telse hideCollapse();\n});\n\n/** Replaces eraser svg with collapse svg. */\nfunction showCollapse(): void {\n\telement_EraseContainer.classList.add('hidden');\n\telement_CollapseContainer.classList.remove('hidden');\n}\n\n/** Replaces collapse svg with eraser svg. */\nfunction hideCollapse(): void {\n\telement_EraseContainer.classList.remove('hidden');\n\telement_CollapseContainer.classList.add('hidden');\n}\n\n// =====================================================================\n\n/**\n * Returns true if the coords input box is currently not allowed to be edited.\n * This was set at the time they were opened.\n */\nfunction areCoordsAllowedToBeEdited(): boolean {\n\treturn !element_CoordsX.disabled;\n}\n\n/** Returns the height of the navigation bar in the document, in virtual pixels. */\nfunction getHeightOfNavBar(): number {\n\treturn element_Navigation.getBoundingClientRect().height;\n}\n\nfunction callback_Pause(): void {\n\tguipause.open();\n}\n\n/** Tests if the arrow keys have been pressed outisde of the board editor, signaling to rewind/forward the game. */\nfunction update(): void {\n\tif (!guiboardeditor.isOpen()) {\n\t\ttestIfRewindMove();\n\t\ttestIfForwardMove();\n\t} else {\n\t\ttestIfUndoEdit();\n\t\ttestIfRedoEdit();\n\t}\n}\n\n// Move Buttons =====================================================\n\nfunction callback_MoveRewind(): void {\n\tif (rewindIsLocked) return;\n\tif (!isItOkayToRewindOrForward()) return;\n\tlastRewindOrEdit = Date.now();\n\trewindMove();\n}\n\nfunction callback_MoveForward(): void {\n\tif (!isItOkayToRewindOrForward()) return;\n\tlastRewindOrEdit = Date.now();\n\tforwardMove();\n}\n\nfunction isItOkayToRewindOrForward(): boolean {\n\tconst timeSincelastRewindOrEdit = Date.now() - lastRewindOrEdit;\n\treturn timeSincelastRewindOrEdit >= minimumRewindOrEditIntervalMillis; // True if enough time has passed!\n}\n\n/**\n * Makes the rewind/forward move buttons transparent if we're at\n * the very beginning or end of the game.\n */\nfunction update_MoveButtons(): void {\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst decrementingLegal = moveutil.isDecrementingLegal(gamefile.boardsim);\n\tconst incrementingLegal = moveutil.isIncrementingLegal(gamefile.boardsim);\n\n\tif (decrementingLegal) element_moveRewind.classList.remove('opacity-0_5');\n\telse element_moveRewind.classList.add('opacity-0_5');\n\n\tif (incrementingLegal) element_moveForward.classList.remove('opacity-0_5');\n\telse element_moveForward.classList.add('opacity-0_5');\n}\n\n// Mouse\n\nfunction callback_MoveRewindMouseDown(): void {\n\tleftArrowTimeoutID = setTimeout(() => {\n\t\tleftArrowIntervalID = setInterval(() => {\n\t\t\tcallback_MoveRewind();\n\t\t}, intervalToRepeat);\n\t}, timeToHoldMillis);\n}\n\nfunction callback_MoveRewindMouseLeave(): void {\n\tclearTimeout(leftArrowTimeoutID);\n\tclearInterval(leftArrowIntervalID);\n}\n\nfunction callback_MoveRewindMouseUp(): void {\n\tclearTimeout(leftArrowTimeoutID);\n\tclearInterval(leftArrowIntervalID);\n}\n\nfunction callback_MoveForwardMouseDown(): void {\n\trightArrowTimeoutID = setTimeout(() => {\n\t\trightArrowIntervalID = setInterval(() => {\n\t\t\tcallback_MoveForward();\n\t\t}, intervalToRepeat);\n\t}, timeToHoldMillis);\n}\n\nfunction callback_MoveForwardMouseLeave(): void {\n\tclearTimeout(rightArrowTimeoutID);\n\tclearInterval(rightArrowIntervalID);\n}\n\nfunction callback_MoveForwardMouseUp(): void {\n\tclearTimeout(rightArrowTimeoutID);\n\tclearInterval(rightArrowIntervalID);\n}\n\n// Fingers\n\nfunction callback_MoveRewindTouchStart(): void {\n\ttouchIsInsideLeft = true;\n\tleftArrowTimeoutID = setTimeout(() => {\n\t\tif (!touchIsInsideLeft) return;\n\t\tleftArrowIntervalID = setInterval(() => {\n\t\t\tcallback_MoveRewind();\n\t\t}, intervalToRepeat);\n\t}, timeToHoldMillis);\n}\n\nfunction callback_MoveRewindTouchMove(event: TouchEvent): void {\n\tif (!touchIsInsideLeft) return;\n\tconst touch = event.touches[0]!;\n\tconst rect = element_moveRewind.getBoundingClientRect();\n\tif (\n\t\ttouch.clientX > rect.left &&\n\t\ttouch.clientX < rect.right &&\n\t\ttouch.clientY > rect.top &&\n\t\ttouch.clientY < rect.bottom\n\t)\n\t\treturn;\n\n\ttouchIsInsideLeft = false;\n\tclearTimeout(leftArrowTimeoutID);\n\tclearInterval(leftArrowIntervalID);\n}\n\nfunction callback_MoveRewindTouchEnd(): void {\n\ttouchIsInsideLeft = false;\n\tclearTimeout(leftArrowTimeoutID);\n\tclearInterval(leftArrowIntervalID);\n}\n\nfunction callback_MoveForwardTouchStart(): void {\n\ttouchIsInsideRight = true;\n\trightArrowTimeoutID = setTimeout(() => {\n\t\tif (!touchIsInsideRight) return;\n\t\trightArrowIntervalID = setInterval(() => {\n\t\t\tcallback_MoveForward();\n\t\t}, intervalToRepeat);\n\t}, timeToHoldMillis);\n}\n\nfunction callback_MoveForwardTouchMove(event: TouchEvent): void {\n\tevent = event || window.event;\n\tif (!touchIsInsideRight) return;\n\tconst touch = event.touches[0]!;\n\tconst rect = element_moveForward.getBoundingClientRect();\n\tif (\n\t\ttouch.clientX > rect.left &&\n\t\ttouch.clientX < rect.right &&\n\t\ttouch.clientY > rect.top &&\n\t\ttouch.clientY < rect.bottom\n\t)\n\t\treturn;\n\n\ttouchIsInsideRight = false;\n\tclearTimeout(rightArrowTimeoutID);\n\tclearInterval(rightArrowIntervalID);\n}\n\nfunction callback_MoveForwardTouchEnd(): void {\n\ttouchIsInsideRight = false;\n\tclearTimeout(rightArrowTimeoutID);\n\tclearInterval(rightArrowIntervalID);\n}\n\n/**\n * Locks the rewind button for a brief moment. Typically called after forwarding the moves to the front.\n * This is so if our opponent moves while we're rewinding, there's a brief pause.\n */\nfunction lockRewind(): void {\n\trewindIsLocked = true;\n\tlockLayers++;\n\tsetTimeout(() => {\n\t\tlockLayers--;\n\t\tif (lockLayers > 0) return;\n\t\trewindIsLocked = false;\n\t}, durationToLockRewindAfterMoveForwardingMillis);\n}\nlet lockLayers = 0;\n\n/** Tests if the left arrow key has been pressed, signaling to rewind the game. */\nfunction testIfRewindMove(): void {\n\tif (!listener_document.isKeyDown('ArrowLeft')) return;\n\tif (rewindIsLocked) return;\n\trewindMove();\n}\n\n/** Tests if the right arrow key has been pressed, signaling to forward the game. */\nfunction testIfForwardMove(): void {\n\tif (!listener_document.isKeyDown('ArrowRight')) return;\n\tforwardMove();\n}\n\n/** Rewinds the currently-loaded gamefile by 1 move. Unselects any piece, updates the rewind/forward move buttons. */\nfunction rewindMove(): void {\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh();\n\n\tconst hadAtleastOnePremove = premoves.hasAtleastOnePremove();\n\tpremoves.cancelPremoves(gamefile, mesh);\n\t// If we had premoves to cancel, just cancel them, don't rewind a move this time.\n\tif (hadAtleastOnePremove) return;\n\n\tif (!moveutil.isDecrementingLegal(gamefile.boardsim)) return stats.showMoves();\n\n\tframetracker.onVisualChange();\n\n\tmovesequence.navigateMove(gamefile, mesh, false);\n\n\tselection.unselectPiece();\n}\n\n/** Forwards the currently-loaded gamefile by 1 move. Unselects any piece, updates the rewind/forward move buttons. */\nfunction forwardMove(): void {\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh();\n\n\tpremoves.cancelPremoves(gamefile, mesh);\n\n\tif (!moveutil.isIncrementingLegal(gamefile.boardsim)) return stats.showMoves();\n\n\tmovesequence.navigateMove(gamefile, mesh, true);\n}\n\n// Edit Buttons =====================================================\n\nfunction isItOkayToUndoEditOrRedoEdit(): boolean {\n\tconst timeSincelastRewindOrEdit = Date.now() - lastRewindOrEdit;\n\treturn timeSincelastRewindOrEdit >= minimumRewindOrEditIntervalMillis; // True if enough time has passed!\n}\n\n/**\n * Makes the undo/redo move buttons transparent if we're at\n * the very beginning or end of the edits.\n */\nfunction update_EditButtons(): void {\n\tif (edithistory.canUndo()) element_undoEdit.classList.remove('opacity-0_5');\n\telse element_undoEdit.classList.add('opacity-0_5');\n\n\tif (edithistory.canRedo()) element_redoEdit.classList.remove('opacity-0_5');\n\telse element_redoEdit.classList.add('opacity-0_5');\n}\n\n// Mouse\n\nfunction callback_UndoEditMouseDown(): void {\n\tleftArrowTimeoutID = setTimeout(() => {\n\t\tleftArrowIntervalID = setInterval(() => {\n\t\t\tcallback_UndoEdit();\n\t\t}, intervalToRepeat);\n\t}, timeToHoldMillis);\n}\n\nfunction callback_UndoEditMouseLeave(): void {\n\tclearTimeout(leftArrowTimeoutID);\n\tclearInterval(leftArrowIntervalID);\n}\n\nfunction callback_UndoEditMouseUp(): void {\n\tclearTimeout(leftArrowTimeoutID);\n\tclearInterval(leftArrowIntervalID);\n}\n\nfunction callback_RedoEditMouseDown(): void {\n\trightArrowTimeoutID = setTimeout(() => {\n\t\trightArrowIntervalID = setInterval(() => {\n\t\t\tcallback_RedoEdit();\n\t\t}, intervalToRepeat);\n\t}, timeToHoldMillis);\n}\n\nfunction callback_RedoEditMouseLeave(): void {\n\tclearTimeout(rightArrowTimeoutID);\n\tclearInterval(rightArrowIntervalID);\n}\n\nfunction callback_RedoEditMouseUp(): void {\n\tclearTimeout(rightArrowTimeoutID);\n\tclearInterval(rightArrowIntervalID);\n}\n\n// Fingers\n\nfunction callback_UndoEditTouchStart(): void {\n\ttouchIsInsideLeft = true;\n\tleftArrowTimeoutID = setTimeout(() => {\n\t\tif (!touchIsInsideLeft) return;\n\t\tleftArrowIntervalID = setInterval(() => {\n\t\t\tcallback_UndoEdit();\n\t\t}, intervalToRepeat);\n\t}, timeToHoldMillis);\n}\n\nfunction callback_UndoEditTouchMove(event: TouchEvent): void {\n\tif (!touchIsInsideLeft) return;\n\tconst touch = event.touches[0]!;\n\tconst rect = element_moveRewind.getBoundingClientRect();\n\tif (\n\t\ttouch.clientX > rect.left &&\n\t\ttouch.clientX < rect.right &&\n\t\ttouch.clientY > rect.top &&\n\t\ttouch.clientY < rect.bottom\n\t)\n\t\treturn;\n\n\ttouchIsInsideLeft = false;\n\tclearTimeout(leftArrowTimeoutID);\n\tclearInterval(leftArrowIntervalID);\n}\n\nfunction callback_UndoEditTouchEnd(): void {\n\ttouchIsInsideLeft = false;\n\tclearTimeout(leftArrowTimeoutID);\n\tclearInterval(leftArrowIntervalID);\n}\n\nfunction callback_RedoEditTouchStart(): void {\n\ttouchIsInsideRight = true;\n\trightArrowTimeoutID = setTimeout(() => {\n\t\tif (!touchIsInsideRight) return;\n\t\trightArrowIntervalID = setInterval(() => {\n\t\t\tcallback_RedoEdit();\n\t\t}, intervalToRepeat);\n\t}, timeToHoldMillis);\n}\n\nfunction callback_RedoEditTouchMove(event: TouchEvent): void {\n\tevent = event || window.event;\n\tif (!touchIsInsideRight) return;\n\tconst touch = event.touches[0]!;\n\tconst rect = element_moveForward.getBoundingClientRect();\n\tif (\n\t\ttouch.clientX > rect.left &&\n\t\ttouch.clientX < rect.right &&\n\t\ttouch.clientY > rect.top &&\n\t\ttouch.clientY < rect.bottom\n\t)\n\t\treturn;\n\n\ttouchIsInsideRight = false;\n\tclearTimeout(rightArrowTimeoutID);\n\tclearInterval(rightArrowIntervalID);\n}\n\nfunction callback_RedoEditTouchEnd(): void {\n\ttouchIsInsideRight = false;\n\tclearTimeout(rightArrowTimeoutID);\n\tclearInterval(rightArrowIntervalID);\n}\n\n/** Tests if the left arrow key has been pressed, signaling to undo an edit. */\nfunction testIfUndoEdit(): void {\n\tif (!listener_document.isKeyDown('ArrowLeft')) return;\n\tcallback_UndoEdit();\n}\n\n/** Tests if the right arrow key has been pressed, signaling to redo and edit. */\nfunction testIfRedoEdit(): void {\n\tif (!listener_document.isKeyDown('ArrowRight')) return;\n\tcallback_RedoEdit();\n}\n\n/** Undoes one edit */\nfunction callback_UndoEdit(): void {\n\tif (!isItOkayToUndoEditOrRedoEdit()) return;\n\tlastRewindOrEdit = Date.now();\n\tedithistory.undo();\n}\n\n/** Redoes one edit. */\nfunction callback_RedoEdit(): void {\n\tif (!isItOkayToUndoEditOrRedoEdit()) return;\n\tlastRewindOrEdit = Date.now();\n\tedithistory.redo();\n}\n\nexport default {\n\tisOpen,\n\topen,\n\tclose,\n\tupdateElement_Coords,\n\tupdate_MoveButtons,\n\tupdate_EditButtons,\n\tcallback_Pause,\n\tcallback_Expand,\n\tlockRewind,\n\tupdate,\n\tisCoordinateActive,\n\trecenter,\n\ttoggle,\n\tisAnnotationsButtonEnabled,\n\tareCoordsAllowedToBeEdited,\n\tgetHeightOfNavBar,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/guipause.ts",
    "content": "// src/client/scripts/esm/game/gui/guipause.ts\n\n/**\n * This script handles our Pause menu.\n */\n\nimport moveutil from '../../../../../shared/chess/util/moveutil.js';\n\nimport toast from './toast.js';\nimport arrows from '../rendering/arrows/arrows.js';\nimport docutil from '../../util/docutil.js';\nimport guititle from './guititle.js';\nimport gameslot from '../chess/gameslot.js';\nimport boardpos from '../rendering/boardpos.js';\nimport boarddrag from '../rendering/boarddrag.js';\nimport { Mouse } from '../input.js';\nimport onlinegame from '../misc/onlinegame/onlinegame.js';\nimport drawoffers from '../misc/onlinegame/drawoffers.js';\nimport gameloader from '../chess/gameloader.js';\nimport perspective from '../rendering/perspective.js';\nimport guipractice from './guipractice.js';\nimport { GameBus } from '../GameBus.js';\nimport frametracker from '../rendering/frametracker.js';\nimport draganimation from '../rendering/dragging/draganimation.js';\nimport checkmatepractice from '../chess/checkmatepractice.js';\nimport { listener_document } from '../chess/game.js';\n\n// Elements ------------------------------------------------------------------------------\n\nconst element_pauseUI: HTMLElement = document.getElementById('pauseUI')!;\nconst element_resume: HTMLElement = document.getElementById('resume')!;\nconst element_pointers: HTMLElement = document.getElementById('togglepointers')!;\nconst element_copygame: HTMLElement = document.getElementById('copygame')!;\nconst element_pastegame: HTMLElement = document.getElementById('pastegame')!;\nconst element_mainmenu: HTMLButtonElement = document.getElementById(\n\t'mainmenu',\n) as HTMLButtonElement;\nconst element_practicemenu: HTMLElement = document.getElementById('practicemenu')!;\nconst element_offerDraw: HTMLElement = document.getElementById('offerdraw')!;\nconst element_perspective: HTMLElement = document.getElementById('toggleperspective')!;\n\n// Constants ---------------------------------------------------------------------------\n\n/** Amount of milliseconds to freeze the Main Menu button after the text on it changes */\nconst MAIN_MENU_BUTTON_CHANGE_FREEZE_DURATION_MILLIS: number = 1000;\n\n// Variables -----------------------------------------------------------------------------\n\n// Pause UI\nlet isPaused: boolean = false;\n/** This is true if the main menu button says \"Resign Game\" or \"Abort Game\". In all other cases, this is false. */\nlet is_main_menu_button_used_as_resign_or_abort_button: boolean = false;\n\n// Events -----------------------------------------------------------------------------------\n\nGameBus.addEventListener('game-concluded', () => {\n\tupdateTextOfMainMenuButton(true);\n});\n\n// Functions --------------------------------------------------------------------------------\n\n/** Returns *true* if the game is currently paused. */\nfunction areWePaused(): boolean {\n\treturn isPaused;\n}\n\n/** Returns the perspective toggle button element. */\nfunction getelement_perspective(): HTMLElement {\n\treturn element_perspective;\n}\n\n/** Opens the pause menu. */\nfunction open(): void {\n\tisPaused = true;\n\tupdatePerspectiveButtonTransparency();\n\tupdateTextOfMainMenuButton();\n\tupdatePasteButtonTransparency();\n\tif (checkmatepractice.areInCheckmatePractice()) {\n\t\t// Hide the draw offer button and show the Practice Menu button\n\t\telement_offerDraw.classList.add('hidden');\n\t\telement_practicemenu.classList.remove('hidden');\n\t} else {\n\t\t// Show the draw offer button and hide the Practice Menu button\n\t\telement_offerDraw.classList.remove('hidden');\n\t\telement_practicemenu.classList.add('hidden');\n\t\tupdateDrawOfferButton();\n\t}\n\telement_pauseUI.classList.remove('hidden');\n\tinitListeners();\n\n\tboardpos.eraseMomentum();\n\tboarddrag.cancelBoardDrag();\n\tdraganimation.dropPiece();\n}\n\n/** Toggles the pause menu open or closed. */\nfunction toggle(): void {\n\tif (!isPaused) open();\n\telse callback_Resume();\n}\n\n/** Updates the paste button's transparency depending on whether pasting is legal. */\nfunction updatePasteButtonTransparency(): void {\n\tconst gamefile = gameslot.getGamefile();\n\tif (!gamefile) return;\n\n\tconst moves = gamefile.boardsim.moves;\n\n\tconst legalInPrivateMatch =\n\t\tonlinegame.areInOnlineGame() && onlinegame.getIsPrivate() && moves.length === 0;\n\n\tif (onlinegame.areInOnlineGame() && !legalInPrivateMatch)\n\t\telement_pastegame.classList.add('opacity-0_5');\n\telse element_pastegame.classList.remove('opacity-0_5');\n}\n\n/** Updates the perspective button's transparency depending on whether a mouse is supported. */\nfunction updatePerspectiveButtonTransparency(): void {\n\tif (docutil.isMouseSupported()) element_perspective.classList.remove('opacity-0_5');\n\telse element_perspective.classList.add('opacity-0_5');\n}\n\n/**\n * Update the draw offer button's text content to either say \"Offer Draw\"\n * or \"Accept Draw\", and update its transparency depending on whether it's legal.\n */\nfunction updateDrawOfferButton(): void {\n\tif (!isPaused) return; // Not paused, no point in updating button, because it's updated as soon as we pause the game\n\t// Should it say \"offer draw\" or \"accept draw\"?\n\tif (drawoffers.areWeAcceptingDraw()) {\n\t\telement_offerDraw.innerText = translations.accept_draw; // \"Accept Draw\"\n\t\telement_offerDraw.classList.remove('opacity-0_5');\n\t\treturn;\n\t} else element_offerDraw.innerText = translations.offer_draw; // \"Offer Draw\"\n\n\t// Update transparency\n\tif (drawoffers.isOfferingDrawLegal()) element_offerDraw.classList.remove('opacity-0_5');\n\telse element_offerDraw.classList.add('opacity-0_5');\n}\n\n/** Called when we receive an opponent's move, to update the pause menu buttons. */\nfunction onReceiveOpponentsMove(): void {\n\tupdateTextOfMainMenuButton(true);\n\tupdateDrawOfferButton();\n}\n\n/**\n * Updates the text content of the Main Menu button to either say \"Main Menu\",\n * \"Abort Game\", or \"Resign Game\", whichever is relevant in the situation.\n * @param freezeMainMenuButtonUponChange - If true, and the main menu changes from \"Abort\" to \"Resign\" or from \"Resign\"/\"Abort\" to \"Main Menu\",\n * we will disable it and grey it out for 1 second so the player doesn't accidentally click resign when they wanted to abort or \"Main Menu\" when they wanted to resign.\n * This should only be true when called from onReceiveOpponentsMove() or onReceiveGameConclusion(), not on open()\n */\nfunction updateTextOfMainMenuButton(freezeMainMenuButtonUponChange?: true): void {\n\tif (!isPaused) return;\n\tconst gamefile = gameslot.getGamefile();\n\tif (!gamefile) return;\n\n\tif (\n\t\t!onlinegame.areInOnlineGame() ||\n\t\tonlinegame.hasServerConcludedGame() ||\n\t\tonlinegame.hasPlayerPressedAbortOrResignButton()\n\t) {\n\t\t// If the text currently says \"Abort Game\" or \"Resign Game\", freeze the button for 1 second in case the user clicked it RIGHT after it switched text! They may have tried to abort or resign and actually not want to exit to main menu.\n\t\tif (\n\t\t\tfreezeMainMenuButtonUponChange &&\n\t\t\telement_mainmenu.textContent !== translations.main_menu\n\t\t)\n\t\t\tfreezeMainMenuButton();\n\t\telement_mainmenu.textContent = translations.main_menu;\n\t\tis_main_menu_button_used_as_resign_or_abort_button = false;\n\t\treturn;\n\t}\n\n\tis_main_menu_button_used_as_resign_or_abort_button = true;\n\tif (moveutil.isGameResignable(gamefile.basegame)) {\n\t\t// If the text currently says \"Abort Game\", freeze the button for 1 second in case the user clicked it RIGHT after it switched text! They may have tried to abort and actually not want to resign.\n\t\tif (\n\t\t\tfreezeMainMenuButtonUponChange &&\n\t\t\telement_mainmenu.textContent !== translations.resign_game\n\t\t)\n\t\t\tfreezeMainMenuButton();\n\t\telement_mainmenu.textContent = translations.resign_game;\n\t\treturn;\n\t}\n\n\telement_mainmenu.textContent = translations.abort_game;\n}\n\n/** Temporarily disable the main menu button for a certain number of milliseconds */\nfunction freezeMainMenuButton(): void {\n\telement_mainmenu.disabled = true;\n\telement_mainmenu.classList.add('opacity-0_5');\n\tsetTimeout(() => {\n\t\telement_mainmenu.disabled = false;\n\t\telement_mainmenu.classList.remove('opacity-0_5');\n\t}, MAIN_MENU_BUTTON_CHANGE_FREEZE_DURATION_MILLIS);\n}\n\n/** Initializes event listeners for the pause menu buttons. */\nfunction initListeners(): void {\n\telement_resume.addEventListener('click', callback_Resume);\n\telement_pointers.addEventListener('click', callback_ToggleArrows);\n\telement_copygame.addEventListener('click', callback_CopyGame);\n\telement_pastegame.addEventListener('click', callback_PasteGame);\n\telement_mainmenu.addEventListener('click', callback_MainMenu);\n\telement_practicemenu.addEventListener('click', callback_PracticeMenu);\n\telement_offerDraw.addEventListener('click', callback_OfferDraw);\n\telement_perspective.addEventListener('click', callback_Perspective);\n}\n\n/** Removes event listeners for the pause menu buttons. */\nfunction closeListeners(): void {\n\telement_resume.removeEventListener('click', callback_Resume);\n\telement_pointers.removeEventListener('click', callback_ToggleArrows);\n\telement_copygame.removeEventListener('click', callback_CopyGame);\n\telement_pastegame.removeEventListener('click', callback_PasteGame);\n\telement_mainmenu.removeEventListener('click', callback_MainMenu);\n\telement_practicemenu.removeEventListener('click', callback_PracticeMenu);\n\telement_offerDraw.removeEventListener('click', callback_OfferDraw);\n\telement_perspective.removeEventListener('click', callback_Perspective);\n}\n\n/** Called when the copy game button is clicked. */\nfunction callback_CopyGame(_event: Event): void {\n\tdocument.dispatchEvent(new Event('copy-game'));\n}\n\n/** Called when the paste game button is clicked. */\nfunction callback_PasteGame(_event: Event): void {\n\tdocument.dispatchEvent(new Event('paste-game'));\n}\n\n/** Called when the resume button is clicked. */\nfunction callback_Resume(): void {\n\tif (!isPaused) return;\n\tisPaused = false;\n\telement_pauseUI.classList.add('hidden');\n\tcloseListeners();\n\tframetracker.onVisualChange();\n}\n\n/** Called when the main menu button is clicked. */\nfunction callback_MainMenu(): void {\n\tcallback_Resume();\n\n\tif (is_main_menu_button_used_as_resign_or_abort_button) onlinegame.onAbortOrResignButtonPress();\n\t// Unload and exit game immediately if the button text says \"Main Menu\"\n\telse {\n\t\t// Let the onlinegame script know that the player willingly presses the \"Main Menu\" button.\n\t\t// This can happen if the server has informed him that game has ended or if the player has already pressed the \"Resign\" or \"Abort\" during this game.\n\t\tif (onlinegame.areInOnlineGame()) onlinegame.onMainMenuButtonPress();\n\n\t\tgameloader.unloadGame();\n\t\tguititle.open();\n\t}\n}\n\n/** Called when the practice menu button is clicked. */\nfunction callback_PracticeMenu(): void {\n\tcallback_Resume();\n\tgameloader.unloadGame();\n\n\tguipractice.open();\n}\n\n/** Called when the Offer Draw button is clicked in the pause menu */\nfunction callback_OfferDraw(): void {\n\t// Are we accepting a draw?\n\tif (drawoffers.areWeAcceptingDraw()) {\n\t\tdrawoffers.callback_AcceptDraw();\n\t\tcallback_Resume();\n\t\treturn;\n\t}\n\n\t// Not accepting. Is it legal to extend, then?\n\tif (drawoffers.isOfferingDrawLegal()) {\n\t\tdrawoffers.extendOffer();\n\t\tcallback_Resume();\n\t\treturn;\n\t}\n\n\ttoast.show(\"Can't offer draw.\");\n}\n\n/** Called when the toggle arrows button is clicked. */\nfunction callback_ToggleArrows(): void {\n\tarrows.toggleArrows();\n\tconst mode = arrows.getMode();\n\t// prettier-ignore\n\tconst text = mode === 0 ? translations.arrows_off\n               : mode === 1 ? translations.arrows_defense\n\t\t\t   : mode === 2 ? translations.arrows_all\n\t\t\t   : translations.arrows_all_hippogonals;\n\telement_pointers.textContent = text;\n\tif (!isPaused) toast.show(translations.toggled + ' ' + text);\n}\n\n/** Called when the perspective button is clicked. */\nfunction callback_Perspective(): void {\n\t// This prevents toggling perspective ON in the pause menu immediately erasing all annotations.\n\tlistener_document.claimMouseClick(Mouse.LEFT);\n\tperspective.toggle();\n}\n\n// Exports ---------------------------------------------------------------------------------\n\nexport default {\n\tareWePaused,\n\tgetelement_perspective,\n\topen,\n\ttoggle,\n\tupdateDrawOfferButton,\n\tonReceiveOpponentsMove,\n\tcallback_Resume,\n\tcallback_ToggleArrows,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/guiplay.ts",
    "content": "// src/client/scripts/esm/game/gui/guiplay.ts\n\n/**\n * This script handles our Play page, containing our invite creation menu.\n */\n\nimport type { TimeControl } from '../../../../../shared/types.js';\nimport type { InviteOptions } from '../misc/invites.js';\n\nimport variant from '../../../../../shared/chess/variants/variant.js';\nimport timeutil from '../../../../../shared/util/timeutil.js';\nimport { players as p } from '../../../../../shared/chess/util/typeutil.js';\nimport { VariantLeaderboards } from '../../../../../shared/chess/variants/validleaderboard.js';\n\nimport toast from './toast.js';\nimport invites from '../misc/invites.js';\nimport docutil from '../../util/docutil.js';\nimport guititle from './guititle.js';\nimport gameloader from '../chess/gameloader.js';\nimport LocalStorage from '../../util/LocalStorage.js';\nimport hydrochess_card from '../chess/engines/enginecards/hydrochess_card.js';\nimport usernamecontainer from '../../util/usernamecontainer.js';\nimport { engineDictionary } from '../chess/engines/engine.js';\n\n// Elements --------------------------------------------------------------------\n\nconst element_menuExternalLinks = document.getElementById('menu-external-links')!;\n\nconst element_PlaySelection = document.getElementById('play-selection')!;\nconst element_playName = document.getElementById('play-name')!;\nconst element_playBack = document.getElementById('play-back')!;\nconst element_online = document.getElementById('online')!;\nconst element_local = document.getElementById('local')!;\nconst element_computer = document.getElementById('computer')!;\nconst element_createInvite = document.getElementById('create-invite') as HTMLButtonElement;\n\nconst element_optionCardColor = document.getElementById('option-card-color')!;\nconst element_optionCardPrivate = document.getElementById('option-card-private')!;\nconst element_optionCardRated = document.getElementById('option-card-rated')!;\nconst element_optionCardClock = document.getElementById('option-card-clock')!;\nconst element_optionVariant = document.getElementById('option-variant') as HTMLSelectElement;\nconst element_optionClock = document.getElementById('option-clock') as HTMLSelectElement;\nconst element_optionColor = document.getElementById('option-color') as HTMLSelectElement;\nconst element_optionPrivate = document.getElementById('option-private') as HTMLSelectElement;\nconst element_optionRated = document.getElementById('option-rated') as HTMLSelectElement;\nconst element_optionRatedYes = document.getElementById('option-rated-yes') as HTMLOptionElement;\n\nconst element_optionCardStrength = document.getElementById('option-card-strength');\nconst element_optionDifficulty = document.getElementById('option-difficulty') as HTMLSelectElement;\n\nconst element_joinPrivate = document.getElementById('join-private')!;\nconst element_inviteCode = document.getElementById('invite-code')!;\nconst element_copyInviteCode = document.getElementById('copy-button')!;\nconst element_joinPrivateMatch = document.getElementById('join-button') as HTMLButtonElement;\nconst element_textboxPrivate = document.getElementById('textbox-private') as HTMLInputElement;\n\n// Constants --------------------------------------------------------------------\n\n/** Selection option indices for some time controls. */\nconst TIME_CONTROL_IDXS = {\n\t'10M': 5,\n\tINFINITE: 12,\n} as const;\n\n// Variables --------------------------------------------------------------------\n\n/** Whether the play screen is open */\nlet pageIsOpen: boolean = false;\n\n/** Whether we've selected \"online\", \"local\", or a \"computer\" game. */\nlet modeSelected: 'online' | 'local' | 'computer';\n\n/**\n * Whether the create invite button is currently locked.\n * When we create an invite, the button is disabled until we hear back from the server.\n */\nlet createInviteButtonIsLocked: boolean = false;\n/**\n * Whether the *virtual* accept invite button is currently locked.\n * When we click invites to accept them. We have to temporarily disable\n * accepting invites so that we have spam protection and don't get the\n * \"You are already in a game\" server error.\n */\nlet acceptInviteButtonIsLocked: boolean = false;\n\n// Events --------------------------------------------------------------------------------\n\ndocument.addEventListener('socket-closed', () => {\n\t/**\n\t * This unlocks the create invite and *virtual* accept invite buttons,\n\t * because we can't hope to receive their reply anytime soon, which\n\t * replyto number is what we look for to unlock these buttons,\n\t * we would never be able to click them again otherwise.\n\t */\n\tunlockCreateInviteButton();\n\tunlockAcceptInviteButton();\n});\n\n// Functions --------------------------------------------------------------------------------\n\n/** Whether or not the play page is currently open, and the invites are visible. */\nfunction isOpen(): boolean {\n\treturn pageIsOpen;\n}\n\n/** Returns whether we've selected \"online\", \"local\", or a \"computer\" game. */\nfunction getModeSelected(): typeof modeSelected {\n\treturn modeSelected;\n}\n\nfunction hideElement_joinPrivate(): void {\n\telement_joinPrivate.classList.add('hidden');\n}\nfunction showElement_joinPrivate(): void {\n\telement_joinPrivate.classList.remove('hidden');\n}\nfunction hideElement_inviteCode(): void {\n\telement_inviteCode.classList.add('hidden');\n}\nfunction showElement_inviteCode(): void {\n\telement_inviteCode.classList.remove('hidden');\n}\n\nfunction open(): void {\n\tpageIsOpen = true;\n\telement_PlaySelection.classList.remove('hidden');\n\telement_menuExternalLinks.classList.remove('hidden');\n\tchangePlayMode('online');\n\tinitListeners();\n\tinvites.subscribeToInvites(); // Subscribe to the invites list subscription service!\n}\n\nfunction close(): void {\n\tpageIsOpen = false;\n\telement_PlaySelection.classList.add('hidden');\n\telement_menuExternalLinks.classList.add('hidden');\n\telement_textboxPrivate.value = ''; // clear invite code\n\thideElement_inviteCode();\n\tcloseListeners();\n\t// This will auto-cancel our existing invite\n\t// IT ALSO clears the existing invites in the document!\n\tinvites.unsubFromInvites();\n}\n\nfunction initListeners(): void {\n\telement_playBack.addEventListener('click', callback_playBack);\n\telement_online.addEventListener('click', callback_online);\n\telement_local.addEventListener('click', callback_local);\n\telement_computer.addEventListener('click', callback_computer);\n\telement_createInvite.addEventListener('click', callback_createInvite);\n\telement_optionVariant.addEventListener('change', callback_updateOptions);\n\telement_optionColor.addEventListener('change', callback_updateOptions);\n\telement_optionClock.addEventListener('change', callback_updateOptions);\n\telement_optionPrivate.addEventListener('change', callback_updateOptions);\n\telement_optionRated.addEventListener('change', callback_updateOptions);\n\telement_joinPrivateMatch.addEventListener('click', callback_joinPrivate);\n\telement_copyInviteCode.addEventListener('click', callback_copyInviteCode);\n\telement_textboxPrivate.addEventListener('keyup', callback_textboxPrivateEnter);\n}\n\nfunction closeListeners(): void {\n\telement_playBack.removeEventListener('click', callback_playBack);\n\telement_online.removeEventListener('click', callback_online);\n\telement_local.removeEventListener('click', callback_local);\n\telement_computer.removeEventListener('click', callback_computer);\n\telement_createInvite.removeEventListener('click', callback_createInvite);\n\telement_optionVariant.removeEventListener('change', callback_updateOptions);\n\telement_optionColor.removeEventListener('change', callback_updateOptions);\n\telement_optionClock.removeEventListener('change', callback_updateOptions);\n\telement_optionPrivate.removeEventListener('change', callback_updateOptions);\n\telement_optionRated.removeEventListener('change', callback_updateOptions);\n\telement_joinPrivateMatch.removeEventListener('click', callback_joinPrivate);\n\telement_copyInviteCode.removeEventListener('click', callback_copyInviteCode);\n\telement_textboxPrivate.removeEventListener('keyup', callback_textboxPrivateEnter);\n}\n\nfunction changePlayMode(mode: typeof modeSelected): void {\n\tif (modeSelected === mode) return; // No change\n\n\t// online / local / computer\n\tif (mode === 'online' && createInviteButtonIsLocked) disableCreateInviteButton(); // Disable it immediately, it's still locked from the last time we clicked it (we quickly clicked \"Local\" then \"Online\" again before we heard back from the server)\n\tif (mode !== 'online' && invites.doWeHave()) element_createInvite.click(); // Simulate clicking to cancel our invite, BEFORE we switch modes (because if the mode is local it will just start the game)\n\n\tmodeSelected = mode;\n\tif (mode === 'online') {\n\t\telement_playName.textContent = translations.menu_online;\n\t\telement_online.classList.add('selected');\n\t\telement_local.classList.remove('selected');\n\t\telement_online.classList.remove('not-selected');\n\t\telement_local.classList.add('not-selected');\n\t\telement_computer.classList.remove('selected');\n\t\telement_computer.classList.add('not-selected');\n\t\telement_createInvite.textContent = translations.invites.create_invite;\n\t\telement_optionCardColor.classList.remove('hidden');\n\t\telement_optionCardRated.classList.remove('hidden');\n\t\telement_optionCardPrivate.classList.remove('hidden');\n\t\t// Patches bugs on some browsers where invite creations are sometimes sent with a blank \"\" private field.\n\t\tif (!element_optionPrivate.value) element_optionPrivate.value = 'public';\n\t\tconst localStorageClock = LocalStorage.loadItem('preferred_online_clock_invite_value');\n\t\telement_optionCardClock.classList.remove('hidden');\n\t\telement_optionClock.selectedIndex =\n\t\t\tlocalStorageClock !== undefined ? localStorageClock : TIME_CONTROL_IDXS['10M']; // 10m+4s\n\t\telement_joinPrivate.classList.remove('hidden');\n\t\tconst localStorageRated = LocalStorage.loadItem('preferred_rated_invite_value');\n\t\telement_optionRated.value = localStorageRated !== undefined ? localStorageRated : 'casual'; // Casual\n\t\tcallback_updateOptions(); // update displayed dropdown options, e.g. disable ranked if necessary\n\t\tif (element_optionCardStrength) element_optionCardStrength.classList.add('hidden');\n\t\t// In non-engine modes, all variants remain available.\n\t\tfor (const option of element_optionVariant.options) {\n\t\t\toption.hidden = false;\n\t\t}\n\t} else if (mode === 'local') {\n\t\t// Enabling the button doesn't necessarily unlock it. It's enabled for \"local\" so that we\n\t\t// can click \"Start Game\" at any point. But it will be re-disabled if we click \"online\" rapidly,\n\t\t// because it was still locked from us still waiting for the server's repsponse to our last create/cancel command.\n\t\t// add choose col\n\t\tenableCreateInviteButton();\n\t\telement_playName.textContent = translations.menu_local;\n\t\telement_online.classList.remove('selected');\n\t\telement_local.classList.add('selected');\n\t\telement_online.classList.add('not-selected');\n\t\telement_local.classList.remove('not-selected');\n\t\telement_computer.classList.remove('selected');\n\t\telement_computer.classList.add('not-selected');\n\t\telement_createInvite.textContent = translations.invites.start_game;\n\t\telement_optionCardColor.classList.add('hidden');\n\t\telement_optionCardRated.classList.add('hidden');\n\t\telement_optionCardPrivate.classList.add('hidden');\n\t\telement_optionCardClock.classList.remove('hidden');\n\t\tconst localStorageClock = LocalStorage.loadItem('preferred_local_clock_invite_value');\n\t\telement_optionClock.selectedIndex =\n\t\t\tlocalStorageClock !== undefined ? localStorageClock : TIME_CONTROL_IDXS.INFINITE; // Infinite Time\n\t\telement_joinPrivate.classList.add('hidden');\n\t\telement_inviteCode.classList.add('hidden');\n\t\tif (element_optionCardStrength) element_optionCardStrength.classList.add('hidden');\n\t\t// In non-engine modes, all variants remain available.\n\t\tfor (const option of element_optionVariant.options) {\n\t\t\toption.hidden = false;\n\t\t}\n\t} else if (mode === 'computer') {\n\t\t// For now, until engines become stronger, time is not customizable.\n\t\tenableCreateInviteButton();\n\t\telement_playName.textContent = translations.menu_computer;\n\t\telement_online.classList.remove('selected');\n\t\telement_local.classList.remove('selected');\n\t\telement_online.classList.add('not-selected');\n\t\telement_local.classList.add('not-selected');\n\t\telement_computer.classList.add('selected');\n\t\telement_computer.classList.remove('not-selected');\n\t\telement_createInvite.textContent = translations.invites.start_game;\n\t\telement_optionCardColor.classList.remove('hidden');\n\t\telement_optionCardRated.classList.add('hidden');\n\t\telement_optionCardPrivate.classList.add('hidden');\n\t\telement_optionCardClock.classList.remove('hidden');\n\t\tconst localStorageClock = LocalStorage.loadItem('preferred_computer_clock_invite_value');\n\t\telement_optionClock.selectedIndex =\n\t\t\tlocalStorageClock !== undefined ? localStorageClock : TIME_CONTROL_IDXS.INFINITE; // Infinite Time\n\t\telement_joinPrivate.classList.add('hidden');\n\t\telement_inviteCode.classList.add('hidden');\n\t\tif (element_optionCardStrength) element_optionCardStrength.classList.remove('hidden');\n\t\t// Restrict the variant dropdown to the variants that HydroChess officially supports.\n\t\tfor (const option of element_optionVariant.options) {\n\t\t\t// Keep options whose value is in the supported set; hide the rest.\n\t\t\toption.hidden = !hydrochess_card.SUPPORTED_VARIANTS.has(option.value);\n\t\t}\n\t\tconst selectedVariant = element_optionVariant.value;\n\t\tif (!hydrochess_card.SUPPORTED_VARIANTS.has(selectedVariant)) {\n\t\t\telement_optionVariant.value = 'Classical';\n\t\t}\n\t}\n}\n\nfunction callback_playBack(): void {\n\tclose();\n\tguititle.open();\n}\n\nfunction callback_online(): void {\n\tchangePlayMode('online');\n}\n\nfunction callback_local(): void {\n\tchangePlayMode('local');\n}\n\nfunction callback_computer(): void {\n\tchangePlayMode('computer');\n}\n\n// Also starts local games\nfunction callback_createInvite(): void {\n\tconst inviteOptions = getInviteOptions();\n\n\tif (modeSelected === 'local') {\n\t\tclose(); // Close the invite creation screen\n\t\t// Actually load the game\n\t\tgameloader.startLocalGame({\n\t\t\tvariant: inviteOptions.variant,\n\t\t\ttimeControl: inviteOptions.clock,\n\t\t});\n\t} else if (modeSelected === 'online') {\n\t\tif (invites.doWeHave()) invites.cancel();\n\t\telse invites.create(inviteOptions);\n\t} else if (modeSelected === 'computer') {\n\t\tclose(); // Close the invite creation screen\n\t\tconst variantName = variant.getVariantName(inviteOptions.variant);\n\t\tconst ourColor = inviteOptions.color ?? (Math.random() > 0.5 ? p.WHITE : p.BLACK);\n\t\tconst { strengthLevel } = getEngineDifficultyConfig();\n\t\tconst currentEngine = 'hydrochess';\n\t\tgameloader.startEngineGame({\n\t\t\tevent: `Casual computer ${variantName} infinite chess game`,\n\t\t\ttimeControl: inviteOptions.clock,\n\t\t\tvariant: inviteOptions.variant,\n\t\t\tyouAreColor: ourColor,\n\t\t\tcurrentEngine,\n\t\t\tengineConfig: {\n\t\t\t\tengineTimeLimitPerMoveMillis:\n\t\t\t\t\tengineDictionary[currentEngine].defaultTimeLimitPerMoveMillis,\n\t\t\t\tstrengthLevel,\n\t\t\t},\n\t\t});\n\t}\n}\n\n/**\n * Returns an object containing the values of each of\n * the invite options on the invite creation screen.\n */\nfunction getInviteOptions(): InviteOptions {\n\tconst strcolor = element_optionColor.value;\n\tconst color = strcolor === 'White' ? p.WHITE : strcolor === 'Black' ? p.BLACK : null;\n\tconst selectedVariant = element_optionVariant.value;\n\tif (!variant.isVariantValid(selectedVariant))\n\t\tthrow Error(`Invite option variant \"${selectedVariant}\" is not a valid variant.`);\n\treturn {\n\t\tvariant: selectedVariant,\n\t\tclock: element_optionClock.value as TimeControl,\n\t\tcolor,\n\t\tprivate: element_optionPrivate.value as 'public' | 'private',\n\t\trated: element_optionRated.value as 'casual' | 'rated',\n\t};\n}\n\nfunction getEngineDifficultyConfig(): { strengthLevel: number } {\n\tif (!element_optionDifficulty) {\n\t\treturn { strengthLevel: 3 };\n\t}\n\tconst value = element_optionDifficulty.value;\n\tswitch (value) {\n\t\tcase 'easy':\n\t\t\treturn { strengthLevel: 1 };\n\t\tcase 'medium':\n\t\t\treturn { strengthLevel: 2 };\n\t\tcase 'hard':\n\t\tdefault:\n\t\t\treturn { strengthLevel: 3 };\n\t}\n}\n\n// Call whenever the Variant, Clock, Color or Private inputs change, or play mode changes\nfunction callback_updateOptions(): void {\n\t// save prefered clock option\n\tsavePreferredClockOption(element_optionClock.selectedIndex);\n\tsavePreferredRatedOption(element_optionRated.value);\n\n\t// check if rated games should be enabled in online mode\n\tif (modeSelected !== 'online') return;\n\tconst variantValue = element_optionVariant.value;\n\tconst clockValue = element_optionClock.value;\n\tconst colorValue = element_optionColor.value;\n\tconst privateValue = element_optionPrivate.value;\n\t// conditions for enabling Rated games:\n\tif (\n\t\tvariantValue in VariantLeaderboards &&\n\t\tclockValue !== '-' &&\n\t\t(colorValue === 'Random' || privateValue === 'private')\n\t) {\n\t\telement_optionRatedYes.disabled = false;\n\t} else {\n\t\telement_optionRated.value = 'casual';\n\t\telement_optionRatedYes.disabled = true;\n\t}\n}\n\nfunction savePreferredClockOption(clockIndex: number): void {\n\tconst localOrOnline = modeSelected;\n\t// For search results: preferred_local_clock_invite_value preferred_online_clock_invite_value\n\tLocalStorage.saveItem(\n\t\t`preferred_${localOrOnline}_clock_invite_value`,\n\t\tclockIndex,\n\t\ttimeutil.getTotalMilliseconds({ days: 7 }),\n\t);\n}\n\nfunction savePreferredRatedOption(ratedValue: string): void {\n\tLocalStorage.saveItem(\n\t\t`preferred_rated_invite_value`,\n\t\tratedValue,\n\t\ttimeutil.getTotalMilliseconds({ years: 1 }),\n\t);\n}\n\nfunction callback_joinPrivate(): void {\n\tconst code = element_textboxPrivate.value.toLowerCase();\n\n\tif (code.length !== 5) return toast.show(translations.invite_error_digits);\n\n\telement_joinPrivateMatch.disabled = true; // Re-enable when the code is changed\n\n\tconst isPrivate = true;\n\tinvites.accept(code, isPrivate);\n}\n\nfunction callback_textboxPrivateEnter(event: KeyboardEvent): void {\n\t// 13 is the key code for Enter key\n\tif (event.keyCode === 13) {\n\t\tif (!element_joinPrivateMatch.disabled) callback_joinPrivate();\n\t} else element_joinPrivateMatch.disabled = false; // Re-enable when the code is changed\n}\n\nfunction callback_copyInviteCode(): void {\n\tif (!modeSelected.includes('online')) return;\n\tif (!invites.doWeHave()) return;\n\n\t// Copy our private invite code.\n\n\tconst code = invites.gelement_iCodeCode().textContent;\n\n\tdocutil.copyToClipboard(code);\n\ttoast.show(translations.invite_copied);\n}\n\nfunction initListeners_Invites(): void {\n\tconst invites = document.querySelectorAll('.invite');\n\n\tinvites.forEach((element) => {\n\t\telement.addEventListener('mouseenter', callback_inviteMouseEnter);\n\t\telement.addEventListener('mouseleave', callback_inviteMouseLeave);\n\t\telement.addEventListener('click', callback_inviteClicked);\n\t});\n}\n\nfunction closeListeners_Invites(): void {\n\tconst invites = document.querySelectorAll('.invite');\n\n\tinvites.forEach((element) => {\n\t\telement.removeEventListener('mouseenter', callback_inviteMouseEnter);\n\t\telement.removeEventListener('mouseleave', callback_inviteMouseLeave);\n\t\telement.removeEventListener('click', callback_inviteClicked);\n\t});\n}\n\nfunction callback_inviteMouseEnter(event: Event): void {\n\t(event.target as HTMLElement).classList.add('hover');\n}\n\nfunction callback_inviteMouseLeave(event: Event): void {\n\t(event.target as HTMLElement).classList.remove('hover');\n}\n\nfunction callback_inviteClicked(event: Event): void {\n\tif (usernamecontainer.wasEventClickInsideUsernameContainer(event as MouseEvent)) {\n\t\t// console.log('Clicked on a username embed, ignoring click');\n\t\treturn;\n\t}\n\n\tinvites.click((event as MouseEvent).currentTarget as HTMLElement);\n}\n\n/**\n * Locks the create invite button to disable it.\n * When we hear the response from the server, we will re-enable it.\n */\nfunction lockCreateInviteButton(): void {\n\tcreateInviteButtonIsLocked = true;\n\t// ONLY ACTUALLY disabled the button if we're on the \"online\" screen\n\tif (modeSelected !== 'online') return;\n\telement_createInvite.disabled = true;\n\t// console.log('Locked create invite button.');\n}\n\n/**\n * Unlocks the create invite button to re-enable it.\n * We have heard a response from the server, and are allowed\n * to try to cancel/create an invite again.\n */\nfunction unlockCreateInviteButton(): void {\n\tcreateInviteButtonIsLocked = false;\n\telement_createInvite.disabled = false;\n\t// console.log('Unlocked create invite button.');\n}\n\nfunction disableCreateInviteButton(): void {\n\telement_createInvite.disabled = true;\n}\nfunction enableCreateInviteButton(): void {\n\telement_createInvite.disabled = false;\n}\nfunction setElement_CreateInviteTextContent(text: string): void {\n\telement_createInvite.textContent = text;\n}\n\n/** Whether the Create Invite button is locked. */\nfunction isCreateInviteButtonLocked(): boolean {\n\treturn createInviteButtonIsLocked;\n}\n\n/**\n * Locks the *virtual* accept invite button to disable clicking other people's invites.\n * When we hear the response from the server, we will re-enable this.\n */\nfunction lockAcceptInviteButton(): void {\n\tacceptInviteButtonIsLocked = true;\n\t// console.log('Locked accept invite button.');\n}\n\n/**\n * Unlocks the accept invite button to re-enable it.\n * We have heard a response from the server, and are allowed\n * to try to cancel/create an invite again.\n */\nfunction unlockAcceptInviteButton(): void {\n\tacceptInviteButtonIsLocked = false;\n\t// console.log('Unlocked accept invite button.');\n}\n\n/**\n * Whether the *virtual* Accept Invite button is locked.\n * If it's locked, this means we temporarily cannot click other people's invites.\n */\nfunction isAcceptInviteButtonLocked(): boolean {\n\treturn acceptInviteButtonIsLocked;\n}\n\n// Exports ------------------------------------------------------------\n\nexport default {\n\tisOpen,\n\thideElement_joinPrivate,\n\tshowElement_joinPrivate,\n\thideElement_inviteCode,\n\tshowElement_inviteCode,\n\tgetModeSelected,\n\topen,\n\tclose,\n\tsetElement_CreateInviteTextContent,\n\tinitListeners_Invites,\n\tcloseListeners_Invites,\n\tlockCreateInviteButton,\n\tunlockCreateInviteButton,\n\tisCreateInviteButtonLocked,\n\tlockAcceptInviteButton,\n\tunlockAcceptInviteButton,\n\tisAcceptInviteButtonLocked,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/guipractice.ts",
    "content": "// src/client/scripts/esm/game/gui/guipractice.ts\n\n/*\n * This script handles our Practice page, containing\n * our practice selection menu.\n */\n\nimport typeutil from '../../../../../shared/chess/util/typeutil.js';\nimport icnconverter from '../../../../../shared/chess/logic/icn/icnconverter.js';\nimport validcheckmates from '../../../../../shared/chess/util/validcheckmates.js';\nimport { players as p } from '../../../../../shared/chess/util/typeutil.js';\n\nimport style from './style.js';\nimport guititle from './guititle.js';\nimport svgcache from '../../chess/rendering/svgcache.js';\nimport validatorama from '../../util/validatorama.js';\nimport checkmatepractice from '../chess/checkmatepractice.js';\n\n// Variables ----------------------------------------------------------------------------\n\nconst element_menuExternalLinks: HTMLElement = document.getElementById('menu-external-links')!;\n\nconst element_practiceSelection: HTMLElement = document.getElementById('practice-selection')!;\nconst element_practiceBack: HTMLElement = document.getElementById('practice-back')!;\nconst element_practicePlay: HTMLElement = document.getElementById('practice-play')!;\nconst element_progress: HTMLElement = document.querySelector('.checkmate-progress')!;\nconst element_progressBar: HTMLElement = document.querySelector('.checkmate-progress-bar')!;\nconst element_checkmateList: HTMLElement = document.querySelector('.checkmate-list')!;\nconst element_checkmates: HTMLElement = document.getElementById('checkmates')!;\n\nconst element_checkmateBadgeBronze = document.getElementById('checkmate-badge-bronze');\nconst element_checkmateBadgeBronzeImage = document.querySelector(\n\t'#checkmate-badge-bronze img',\n) as HTMLElement;\nconst elements_checkmateBadgeBronzeShine = document.querySelectorAll(\n\t'#checkmate-badge-bronze .shine-clockwise, #checkmate-badge-bronze .shine-anticlockwise',\n);\nconst element_checkmateBadgeSilver = document.getElementById('checkmate-badge-silver');\nconst element_checkmateBadgeSilverImage = document.querySelector(\n\t'#checkmate-badge-silver img',\n) as HTMLElement;\nconst elements_checkmateBadgeSilverShine = document.querySelectorAll(\n\t'#checkmate-badge-silver .shine-clockwise, #checkmate-badge-silver .shine-anticlockwise',\n);\nconst element_checkmateBadgeGold = document.getElementById('checkmate-badge-gold');\nconst element_checkmateBadgeGoldImage = document.querySelector(\n\t'#checkmate-badge-gold img',\n) as HTMLElement;\nconst elements_checkmateBadgeGoldShine = document.querySelectorAll(\n\t'#checkmate-badge-gold .shine-clockwise, #checkmate-badge-gold .shine-anticlockwise',\n);\n\nlet checkmateSelectedID: string = validcheckmates.validCheckmates.easy[0]!; // id of selected checkmate\nlet indexSelected: number = 0; // index of selected checkmate among its brothers and sisters\nlet generatedHTML: boolean = false;\n/** Whether the svgs of all the pieces in the checkmates list have been appended to the doc */\nlet generatedIcons: boolean = false;\n\n/** Variables for controlling the scrolling of the checkmate list */\nconst SCROLL: {\n\tmouseIsDown: boolean;\n\tmouseMovedAfterClick: boolean;\n\tscrollTop: number;\n\tstartY: number;\n\tlastY: number;\n\tvelocity: number;\n\tmomentumInterval: ReturnType<typeof setInterval> | undefined;\n\tfriction: number;\n} = {\n\tmouseIsDown: false,\n\tmouseMovedAfterClick: true,\n\tscrollTop: 0,\n\tstartY: 0,\n\tlastY: 0,\n\tvelocity: 0,\n\tmomentumInterval: undefined,\n\tfriction: 0.9,\n};\n\n/** Whether the practice page is open */\nlet isOpen: boolean = false;\n\n// Functions ------------------------------------------------------------------------\n\n// Set an event listener, for when the theme changes, to re-generate the icons, as their color may change\ndocument.addEventListener('theme-change', () => {\n\tremovePieceIcons(); // Remove the existing icons\n\tif (isOpen) addPieceIcons(); // Regenerate the icons so they can update their color, if the new theme has different color arguments\n});\n\n/**\n * Returns the last selected checkmate practce. Useful\n * for knowing which one we just beat.\n */\nfunction getCheckmateSelectedID(): string {\n\treturn checkmateSelectedID;\n}\n\nfunction open(): void {\n\tisOpen = true;\n\telement_practiceSelection.classList.remove('hidden');\n\telement_menuExternalLinks.classList.remove('hidden');\n\tif (!generatedHTML) createPracticeHTML();\n\tif (!generatedIcons) addPieceIcons();\n\tchangeCheckmateSelected(checkmateSelectedID);\n\tcheckmatepractice.updateCompletedCheckmates();\n\tinitListeners();\n}\n\nfunction close(): void {\n\tisOpen = false;\n\tclearScrollMomentumInterval();\n\telement_practiceSelection.classList.add('hidden');\n\telement_menuExternalLinks.classList.add('hidden');\n\tcloseListeners();\n}\n\n/**\n * On first practice page load, generate list of checkmate HTML elements to be shown on page\n */\nfunction createPracticeHTML(): void {\n\tfor (const [difficulty, checkmates] of Object.entries(validcheckmates.validCheckmates)) {\n\t\tcheckmates.forEach((checkmateID: string) => {\n\t\t\tconst piecelist: RegExpMatchArray | null = checkmateID.match(/[0-9]+[a-zA-Z]+/g);\n\t\t\tif (!piecelist) return;\n\n\t\t\tconst checkmatePuzzle = document.createElement('div');\n\t\t\tcheckmatePuzzle.className = 'checkmate unselectable';\n\t\t\tcheckmatePuzzle.id = checkmateID;\n\n\t\t\tconst completionMark = document.createElement('div');\n\t\t\tcompletionMark.className = 'completion-mark';\n\n\t\t\tconst piecelistW = document.createElement('div');\n\t\t\tpiecelistW.className = 'piecelistW';\n\n\t\t\tconst versusText = document.createElement('div');\n\t\t\tversusText.className = 'checkmate-child versus';\n\t\t\tversusText.textContent = translations.versus;\n\n\t\t\tconst piecelistB = document.createElement('div');\n\t\t\tpiecelistB.className = 'piecelistB';\n\n\t\t\tconst checkmateDifficulty = document.createElement('div');\n\t\t\tcheckmateDifficulty.className = 'checkmate-difficulty';\n\t\t\t// @ts-ignore\n\t\t\tcheckmateDifficulty.textContent = translations[difficulty];\n\n\t\t\tfor (const entry of piecelist) {\n\t\t\t\tconst amount: number = parseInt(entry.match(/[0-9]+/)![0]); // number of pieces to be placed\n\t\t\t\tconst shortPiece: string = entry.match(/[a-zA-Z]+/)![0]; // piecetype to be placed\n\t\t\t\tconst longPiece = icnconverter.getTypeFromAbbr(shortPiece);\n\n\t\t\t\tfor (let j = 0; j < amount; j++) {\n\t\t\t\t\tconst pieceDiv = document.createElement('div');\n\t\t\t\t\tpieceDiv.className = `checkmatepiece ${longPiece}`;\n\n\t\t\t\t\tconst containerDiv = document.createElement('div');\n\t\t\t\t\t// prettier-ignore\n\t\t\t\t\tconst collation = (j === 0 ? \"\" : (shortPiece === \"Q\" || shortPiece === \"AM\" ? \" collated\" : \" collated-strong\"));\n\t\t\t\t\tcontainerDiv.className = `checkmate-child checkmatepiececontainer${collation}`;\n\t\t\t\t\tcontainerDiv.appendChild(pieceDiv);\n\n\t\t\t\t\tif (typeutil.getColorFromType(longPiece) === p.WHITE)\n\t\t\t\t\t\tpiecelistW.appendChild(containerDiv);\n\t\t\t\t\telse piecelistB.appendChild(containerDiv);\n\t\t\t\t}\n\t\t\t}\n\t\t\tcheckmatePuzzle.appendChild(completionMark);\n\t\t\tcheckmatePuzzle.appendChild(piecelistW);\n\t\t\tcheckmatePuzzle.appendChild(versusText);\n\t\t\tcheckmatePuzzle.appendChild(piecelistB);\n\t\t\tcheckmatePuzzle.appendChild(checkmateDifficulty);\n\t\t\telement_checkmates.appendChild(checkmatePuzzle);\n\t\t});\n\t}\n\tgeneratedHTML = true;\n}\n\nasync function addPieceIcons(): Promise<void> {\n\t// let sprites = await svgcache.getSVGElements();\n\tconst spritenames = new Set<number>();\n\tconst sprites: { [pieceType: string]: SVGElement } = {};\n\tfor (const checkmate of element_checkmates.children) {\n\t\tfor (const piece of checkmate\n\t\t\t.getElementsByClassName('piecelistW')[0]!\n\t\t\t.getElementsByClassName('checkmatepiececontainer')) {\n\t\t\tconst actualpiece = piece.getElementsByClassName('checkmatepiece')[0]!;\n\t\t\tspritenames.add(Number(actualpiece.className.split(' ')[1]!));\n\t\t}\n\t\tconst pieceBlack = checkmate\n\t\t\t.getElementsByClassName('piecelistB')[0]!\n\t\t\t.getElementsByClassName('checkmatepiececontainer')[0]!;\n\t\tconst actualpieceBlack = pieceBlack.getElementsByClassName('checkmatepiece')[0]!;\n\t\tspritenames.add(Number(actualpieceBlack.className.split(' ')[1]!));\n\t}\n\tconst spriteSVGs = await svgcache.getSVGElements([...spritenames]);\n\tfor (const svg of spriteSVGs) {\n\t\tsprites[svg.id] = svg;\n\t}\n\tfor (const checkmate of element_checkmates.children) {\n\t\tfor (const piece of checkmate\n\t\t\t.getElementsByClassName('piecelistW')[0]!\n\t\t\t.getElementsByClassName('checkmatepiececontainer')) {\n\t\t\tconst actualpiece = piece.getElementsByClassName('checkmatepiece')[0]!;\n\t\t\tactualpiece.appendChild(sprites[actualpiece.className.split(' ')[1]!]!.cloneNode(true));\n\t\t}\n\t\tconst pieceBlack = checkmate\n\t\t\t.getElementsByClassName('piecelistB')[0]!\n\t\t\t.getElementsByClassName('checkmatepiececontainer')[0]!;\n\t\tconst actualpieceBlack = pieceBlack.getElementsByClassName('checkmatepiece')[0]!;\n\t\tconst spriteBlack = sprites[actualpieceBlack.className.split(' ')[1]!]!.cloneNode(true);\n\t\tactualpieceBlack.appendChild(spriteBlack);\n\t}\n\tgeneratedIcons = true;\n}\n\n/**\n * Removes the piece icons from the checkmate lists.\n * Called when the theme changes.\n */\nfunction removePieceIcons(): void {\n\tfor (const checkmate of element_checkmates.children) {\n\t\tfor (const piece of checkmate\n\t\t\t.getElementsByClassName('piecelistW')[0]!\n\t\t\t.getElementsByClassName('checkmatepiececontainer')) {\n\t\t\tconst actualpiece = piece.getElementsByClassName('checkmatepiece')[0]!;\n\t\t\twhile (actualpiece.firstChild) {\n\t\t\t\tactualpiece.removeChild(actualpiece.firstChild);\n\t\t\t}\n\t\t}\n\t\tconst pieceBlack = checkmate\n\t\t\t.getElementsByClassName('piecelistB')[0]!\n\t\t\t.getElementsByClassName('checkmatepiececontainer')[0]!;\n\t\tconst actualpieceBlack = pieceBlack.getElementsByClassName('checkmatepiece')[0]!;\n\t\twhile (actualpieceBlack.firstChild) {\n\t\t\tactualpieceBlack.removeChild(actualpieceBlack.firstChild);\n\t\t}\n\t}\n\tgeneratedIcons = false; // Reset the icon generation flag\n}\n\nfunction initListeners(): void {\n\telement_practiceBack.addEventListener('click', callback_practiceBack);\n\telement_practicePlay.addEventListener('click', callback_practicePlay);\n\tdocument.addEventListener('keydown', callback_keyPress);\n\n\tdocument.addEventListener('mouseup', callback_mouseUp);\n\tdocument.addEventListener('mousemove', callback_mouseMove);\n\telement_checkmateList.addEventListener('mousedown', callback_mouseDown);\n\tfor (const element of element_checkmates.children) {\n\t\t(element as HTMLElement).addEventListener('mouseup', callback_mouseUp);\n\t\telement.addEventListener('dblclick', callback_practicePlay); // Simulate clicking \"Play\"\n\t}\n}\n\nfunction closeListeners(): void {\n\telement_practiceBack.removeEventListener('click', callback_practiceBack);\n\telement_practicePlay.removeEventListener('click', callback_practicePlay);\n\tdocument.removeEventListener('keydown', callback_keyPress);\n\n\tdocument.removeEventListener('mouseup', callback_mouseUp);\n\tdocument.removeEventListener('mousemove', callback_mouseMove);\n\telement_checkmateList.removeEventListener('mousedown', callback_mouseDown);\n\tfor (const element of element_checkmates.children) {\n\t\t(element as HTMLElement).removeEventListener('mouseup', callback_mouseUp);\n\t\telement.removeEventListener('dblclick', callback_practicePlay); // Simulate clicking \"Play\"\n\t}\n}\n\n// Scrolling list with the left mouse button ------------------------------------------------\n\nfunction callback_mouseDown(event: MouseEvent): void {\n\tSCROLL.mouseIsDown = true;\n\tSCROLL.mouseMovedAfterClick = false;\n\tSCROLL.startY = event.pageY - element_checkmateList.offsetTop;\n\tSCROLL.scrollTop = element_checkmateList.scrollTop;\n\n\tSCROLL.velocity = 0;\n\tclearScrollMomentumInterval();\n}\n\nfunction callback_mouseUp(event: MouseEvent): void {\n\tSCROLL.mouseIsDown = false;\n\tif (!(event.currentTarget as HTMLElement).id) return; // mouse not on checkmate target\n\tif (SCROLL.mouseMovedAfterClick) {\n\t\tapplyMomentum();\n\t\treturn;\n\t}\n\tchangeCheckmateSelected((event.currentTarget as HTMLElement).id);\n\tindexSelected = style.getElementIndexWithinItsParent(event.currentTarget as HTMLElement);\n}\n\nfunction callback_mouseMove(event: MouseEvent): void {\n\tSCROLL.mouseMovedAfterClick = true;\n\tif (!SCROLL.mouseIsDown) return;\n\tevent.preventDefault();\n\tconst y = event.pageY - element_checkmateList.offsetTop;\n\tconst walkY = y - SCROLL.startY;\n\telement_checkmateList.scrollTop = SCROLL.scrollTop - walkY;\n\n\tSCROLL.velocity = event.pageY - SCROLL.lastY;\n\tSCROLL.lastY = event.pageY;\n}\n\nfunction applyMomentum(): void {\n\tSCROLL.momentumInterval = setInterval(() => {\n\t\tif (Math.abs(SCROLL.velocity) < 0.5) {\n\t\t\tclearScrollMomentumInterval();\n\t\t\treturn;\n\t\t}\n\t\telement_checkmateList.scrollTop -= SCROLL.velocity;\n\t\tSCROLL.velocity *= SCROLL.friction;\n\t}, 16); // Approx. 60fps\n}\n\nfunction clearScrollMomentumInterval(): void {\n\tclearInterval(SCROLL.momentumInterval);\n\tSCROLL.momentumInterval = undefined;\n}\n\n// End of scrolling ---------------------------------------------------------------------\n\nfunction changeCheckmateSelected(checkmateid: string): void {\n\tfor (const element of element_checkmates.children) {\n\t\tif (checkmateid === element.id) {\n\t\t\telement.classList.add('selected');\n\t\t\tcheckmateSelectedID = checkmateid;\n\t\t\telement.scrollIntoView({ behavior: 'instant', block: 'nearest' });\n\t\t} else {\n\t\t\telement.classList.remove('selected');\n\t\t}\n\t}\n}\n\n/**\n * Updates each checkmate practice element's 'beaten' class, along with the progress bar on top.\n * Checkmates that have the 'beaten' class are green with a checkmark on the left.\n * @param completedCheckmates - A list of checkmate strings we have beaten: `[ \"2Q-1k\", \"3R-1k\", \"2CH-1k\"]`\n */\nfunction updateCheckmatesBeaten(completedCheckmates: string[]): void {\n\tlet numCompleted = 0;\n\tfor (const element of element_checkmates.children) {\n\t\t// What is the id string of this checkmate?\n\t\tconst id_string = element.id; // \"2Q-1k\"\n\t\t// If this id is inside our list of beaten checkmates, add the beaten class\n\t\tif (completedCheckmates.includes(id_string)) {\n\t\t\telement.classList.add('beaten');\n\t\t\tnumCompleted++;\n\t\t} else element.classList.remove('beaten');\n\t}\n\n\t// Update the progress and progress bar\n\tconst numTotal = Object.values(validcheckmates.validCheckmates).flat().length;\n\telement_progress.textContent = `${numCompleted} / ${numTotal}`;\n\tconst percentageBeaten = (100 * numCompleted) / numTotal;\n\telement_progressBar.style.background = `linear-gradient(to right, rgba(0, 163, 0, 0.3) ${percentageBeaten}%, transparent ${percentageBeaten}%)`;\n\n\t// Update the badges\n\tupdateBadges(numCompleted, numTotal);\n}\n\n/**\n * Updates the styling of the badges on the progress bar,\n * to grey-out the unearned ones and shine the earned ones.\n * And also update their tooltips.\n * @param numCompleted - Number of checkmates completed\n * @param numTotal - Total number of checkmates\n */\nfunction updateBadges(numCompleted: number, numTotal: number): void {\n\tconst areLoggedIn = validatorama.areWeLoggedIn();\n\n\t// Configuration for each badge type\n\tconst badgeConfigs = [\n\t\t{\n\t\t\telement: element_checkmateBadgeBronze,\n\t\t\timage: element_checkmateBadgeBronzeImage,\n\t\t\tshines: elements_checkmateBadgeBronzeShine,\n\t\t\tthreshold: 0.5,\n\t\t\tearnedKey: 'checkmate_bronze',\n\t\t\tunearnedKey: 'checkmate_bronze_unearned',\n\t\t},\n\t\t{\n\t\t\telement: element_checkmateBadgeSilver,\n\t\t\timage: element_checkmateBadgeSilverImage,\n\t\t\tshines: elements_checkmateBadgeSilverShine,\n\t\t\tthreshold: 0.75,\n\t\t\tearnedKey: 'checkmate_silver',\n\t\t\tunearnedKey: 'checkmate_silver_unearned',\n\t\t},\n\t\t{\n\t\t\telement: element_checkmateBadgeGold,\n\t\t\timage: element_checkmateBadgeGoldImage,\n\t\t\tshines: elements_checkmateBadgeGoldShine,\n\t\t\tthreshold: 1,\n\t\t\tearnedKey: 'checkmate_gold',\n\t\t\tunearnedKey: 'checkmate_gold_unearned',\n\t\t},\n\t] as const;\n\n\tbadgeConfigs.forEach((config) => {\n\t\tif (!config.element || !config.image) return;\n\n\t\tconst isEarned = numCompleted >= config.threshold * numTotal && areLoggedIn;\n\t\tconst tooltip = isEarned\n\t\t\t? translations[config.earnedKey]\n\t\t\t: areLoggedIn\n\t\t\t\t? translations[config.unearnedKey]\n\t\t\t\t: translations.checkmate_logged_out;\n\n\t\tconfig.element.setAttribute('data-tooltip', tooltip); // Update tooltip\n\t\tconfig.image.classList.toggle('unearned', !isEarned); // Update badge appearance\n\t\tconfig.shines?.forEach((shine) => shine.classList.toggle('hidden', !isEarned)); // Update shine elements\n\t});\n}\n\nfunction callback_practiceBack(_event: Event): void {\n\tclose();\n\tguititle.open();\n}\n\nfunction callback_practicePlay(): void {\n\tclose();\n\tcheckmatepractice.startCheckmatePractice(checkmateSelectedID);\n}\n\n/** If enter is pressed, click Play. Or if arrow keys are pressed, move up and down selection */\nfunction callback_keyPress(event: KeyboardEvent): void {\n\tif (event.key === 'Enter') callback_practicePlay();\n\telse if (event.key === 'ArrowDown') moveDownSelection(event);\n\telse if (event.key === 'ArrowUp') moveUpSelection(event);\n}\n\nfunction moveDownSelection(event: Event): void {\n\tevent.preventDefault();\n\tif (indexSelected >= element_checkmates.children.length - 1) return;\n\tclearScrollMomentumInterval();\n\tindexSelected++;\n\tconst newSelectionElement = element_checkmates.children[indexSelected]!;\n\tchangeCheckmateSelected(newSelectionElement.id);\n}\n\nfunction moveUpSelection(event: Event): void {\n\tevent.preventDefault();\n\tif (indexSelected <= 0) return;\n\tclearScrollMomentumInterval();\n\tindexSelected--;\n\tconst newSelectionElement = element_checkmates.children[indexSelected]!;\n\tchangeCheckmateSelected(newSelectionElement.id);\n}\n\n// Exports ------------------------------------------------------------------------\n\nexport default {\n\tgetCheckmateSelectedID,\n\topen,\n\tupdateCheckmatesBeaten,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/guipromotion.ts",
    "content": "// src/client/scripts/esm/game/gui/guipromotion.ts\n\n/**\n * This script handles our promotion menu, when\n * pawns reach the promotion line.\n */\n\nimport type { Player, PlayerGroup, RawType } from '../../../../../shared/chess/util/typeutil.js';\n\nimport typeutil from '../../../../../shared/chess/util/typeutil.js';\nimport { players as p } from '../../../../../shared/chess/util/typeutil.js';\n\nimport svgcache from '../../chess/rendering/svgcache.js';\nimport selection from '../chess/selection.js';\nimport { Mouse } from '../input.js';\nimport { GameBus } from '../GameBus.js';\nimport { listener_overlay } from '../chess/game.js';\n\n// Variables --------------------------------------------------------------------\n\nconst PromotionGUI: {\n\tbase: HTMLElement;\n\tplayers: PlayerGroup<HTMLElement>;\n} = {\n\tbase: document.getElementById('promote')!,\n\tplayers: {\n\t\t[p.WHITE]: document.getElementById('promotewhite')!,\n\t\t[p.BLACK]: document.getElementById('promoteblack')!,\n\t},\n};\n\nlet selectionOpen = false; // True when promotion GUI visible. Do not listen to navigational controls in the mean time\n\n// Events -----------------------------------------------------------------------\n\nGameBus.addEventListener('piece-unselected', () => {\n\tclose();\n});\nGameBus.addEventListener('game-unloaded', () => {\n\tresetUI();\n});\n\n// Functions --------------------------------------------------------------------\n\n// Prevent right-clicking on the promotion UI\nPromotionGUI.base.addEventListener('contextmenu', (event) => event.preventDefault());\n\nfunction isUIOpen(): boolean {\n\treturn selectionOpen;\n}\n\nfunction open(color: Player): void {\n\tselectionOpen = true;\n\tPromotionGUI.base.classList.remove('hidden');\n\tif (!(color in PromotionGUI.players))\n\t\tthrow new Error(`Promotion UI does not support color \"${color}\"`);\n\tPromotionGUI.players[color]!.classList.remove('hidden');\n}\n\n/** Closes the promotion UI */\nfunction close(): void {\n\t// console.error('Closing promotion UI');\n\tselectionOpen = false;\n\tfor (const element of Object.values(PromotionGUI.players)) {\n\t\telement.classList.add('hidden');\n\t}\n\tPromotionGUI.base.classList.add('hidden');\n}\n\n/**\n * Inits the promotion UI. Hides promotions not allowed, reveals promotions allowed.\n * @param promotionsAllowed - An object that contains the information about what promotions are allowed.\n * It contains 2 properties, `white` and `black`, both of which are arrays which may look like `['queens', 'bishops']`.\n */\nasync function initUI(promotionsAllowed: PlayerGroup<RawType[]> | undefined): Promise<void> {\n\tif (promotionsAllowed === undefined) return;\n\n\tif (Object.values(PromotionGUI.players).some((element) => element.childElementCount > 0)) {\n\t\tthrow new Error(\n\t\t\t'Must reset promotion UI before initiating it, or promotions leftover from the previous game will bleed through.',\n\t\t);\n\t}\n\n\tfor (const [playerString, rawtypes] of Object.entries(promotionsAllowed)) {\n\t\tconst player = Number(playerString) as Player;\n\t\tif (!(player in PromotionGUI.players)) {\n\t\t\tconsole.error(`Player ${player} has a promotion but not promotion UI`);\n\t\t\tcontinue;\n\t\t}\n\t\tconst svgs = await svgcache.getSVGElements(\n\t\t\trawtypes.map((rawPromotion) => typeutil.buildType(rawPromotion, player)),\n\t\t);\n\t\tsvgs.forEach((svg) => {\n\t\t\tsvg.classList.add('promotepiece');\n\t\t\tsvg.addEventListener('click', callback_promote);\n\t\t\tPromotionGUI.players[player]!.appendChild(svg);\n\t\t});\n\t}\n}\n\n/** Resets the promotion UI by clearing all promotion options. */\nfunction resetUI(): void {\n\tfor (const playerPromo of Object.values(PromotionGUI.players)) {\n\t\twhile (playerPromo.firstChild) {\n\t\t\tconst svg = playerPromo.firstChild;\n\t\t\tsvg.removeEventListener('click', callback_promote);\n\t\t\tplayerPromo.removeChild(svg);\n\t\t}\n\t}\n}\n\nfunction callback_promote(event: Event): void {\n\tconst type = Number((event.currentTarget as HTMLElement).id);\n\t// TODO: Dispatch a custom 'promote-selected' event!\n\t// That way this script doesn't depend on selection.js\n\tselection.promoteToType(type);\n\tclose();\n}\n\n/** Closes the UI if the mouse clicks outside it. */\nfunction update(): void {\n\tif (!selectionOpen) return;\n\tif (\n\t\t!listener_overlay.isMouseDown(Mouse.LEFT) &&\n\t\t!listener_overlay.isMouseDown(Mouse.RIGHT) &&\n\t\t!listener_overlay.isMouseDown(Mouse.MIDDLE)\n\t)\n\t\treturn;\n\t// Atleast one mouse button was clicked-down OUTSIDE of the promotion UI\n\tselection.unselectPiece(); // Already closes\n}\n\nexport default {\n\tisUIOpen,\n\topen,\n\tclose,\n\tinitUI,\n\tresetUI,\n\tupdate,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/guititle.ts",
    "content": "// src/client/scripts/esm/game/gui/guititle.ts\n\n/**\n * This script handles our Title Screen\n */\n\nimport guiplay from './guiplay.js';\nimport guipractice from './guipractice.js';\nimport guiboardeditor from './boardeditor/guiboardeditor.js';\nimport languagedropdown from '../../components/header/dropdowns/languagedropdown.js';\n\n// Variables ----------------------------------------------------------------------------\n\n// Title Screen\nconst boardVel = 0.6; // Speed at which board slowly moves while on title screen\n\nconst titleElement = document.getElementById('title')!; // Visible when on the title screen\nconst element_play = document.getElementById('play')!;\nconst element_practice = document.getElementById('practice')!;\nconst element_guide = document.getElementById('rules')!;\nconst element_boardEditor = document.getElementById('board-editor')!;\nconst element_menuExternalLinks = document.getElementById('menu-external-links')!;\n\n// Functions ----------------------------------------------------------------------------\n\n// Call when title screen is loaded\nfunction open(): void {\n\ttitleElement.classList.remove('hidden');\n\telement_menuExternalLinks.classList.remove('hidden');\n\tinitListeners();\n}\n\nfunction close(): void {\n\ttitleElement.classList.add('hidden');\n\telement_menuExternalLinks.classList.add('hidden');\n\tcloseListeners();\n}\n\nfunction initListeners(): void {\n\telement_play.addEventListener('click', callback_Play);\n\telement_practice.addEventListener('click', callback_Practice);\n\telement_guide.addEventListener('click', callback_Guide);\n\t// element_boardEditor.addEventListener('click', gui.displayStatus_FeaturePlanned);\n\t// ENABLE WHEN board editor is ready\n\telement_boardEditor.addEventListener('click', callback_BoardEditor);\n}\n\nfunction closeListeners(): void {\n\telement_play.removeEventListener('click', callback_Play);\n\telement_practice.removeEventListener('click', callback_Practice);\n\telement_guide.removeEventListener('click', callback_Guide);\n\t// element_boardEditor.removeEventListener('click', gui.displayStatus_FeaturePlanned);\n\t// ENABLE WHEN board editor is ready\n\telement_boardEditor.removeEventListener('click', callback_BoardEditor);\n}\n\nfunction callback_Play(_event: Event): void {\n\tclose();\n\tguiplay.open();\n}\n\nfunction callback_Practice(_event: Event): void {\n\tclose();\n\tguipractice.open();\n}\n\nfunction callback_Guide(_event: Event): void {\n\t// Navigate to the guide page\n\twindow.location.href = languagedropdown.addLngQueryParamToLink(`/guide`);\n}\n\nfunction callback_BoardEditor(_event: Event): void {\n\tclose();\n\tguiboardeditor.open();\n}\n\nexport default {\n\tboardVel,\n\topen,\n\tclose,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/loadingscreen.ts",
    "content": "// src/client/scripts/esm/game/gui/loadingscreen.ts\n\n/**\n * This script manages the spinny pawn loading animation\n * while a game is loading both the LOGICAL and\n * GRAPHICAL (spritesheet) aspects.\n */\n\nimport themes from '../../../../../shared/components/header/themes.js';\n\nimport style from './style.js';\nimport thread from '../../util/thread.js';\nimport preferences from '../../components/header/preferences.js';\n\nconst loadingScreen: HTMLElement = document.querySelector('.game-loading-screen') as HTMLElement;\n\n/** Lower = loading checkerboard closer to black */\nconst darknessLevel = 0.22;\n/** Percentage of the viewport minimum. 0-100 */\nconst widthOfTiles = 16;\n\nconst element_spinnyPawn = document.querySelector('.game-loading-screen .spinny-pawn');\nconst element_loadingError = document.querySelector('.game-loading-screen .loading-error');\nconst element_loadingErrorText = document.querySelector('.game-loading-screen .loading-error-text');\n\n(function init(): void {\n\tinitColorOfLoadingBackground();\n\tdocument.addEventListener('theme-change', initColorOfLoadingBackground);\n})();\n\nfunction initColorOfLoadingBackground(): void {\n\tconst theme = preferences.getTheme();\n\tconst lightTiles = themes.getPropertyOfTheme(theme, 'lightTiles');\n\tlightTiles[3] = 1;\n\tconst darkTiles = themes.getPropertyOfTheme(theme, 'darkTiles');\n\tdarkTiles[3] = 1;\n\n\tfor (let i = 0; i < 3; i++) {\n\t\t// Darken the color\n\t\tlightTiles[i]! *= darknessLevel;\n\t\tdarkTiles[i]! *= darknessLevel;\n\t}\n\n\tconst lightTilesCSS = style.arrayToCssColor(lightTiles);\n\tconst darkTilesCSS = style.arrayToCssColor(darkTiles);\n\n\tloadingScreen!.style.background = `repeating-conic-gradient(${darkTilesCSS} 0% 25%, ${lightTilesCSS} 0% 50%) 50% / ${widthOfTiles}vmin ${widthOfTiles}vmin`;\n}\n\nasync function open(): Promise<void> {\n\tloadingScreen.classList.remove('transparent');\n\t// This gives the document a chance to repaint, as otherwise our javascript\n\t// will continue to run until the next animation frame, which could be a long time.\n\t// FOR SOME REASON sometimes it occasionally still doesn't repaint unless this is ~10??? Idk why\n\tawait thread.sleep(10);\n}\n\nasync function close(): Promise<void> {\n\tloadingScreen.classList.add('transparent');\n\n\t// Hide the error text and show the spinny pawn\n\telement_spinnyPawn!.classList.remove('hidden');\n\telement_loadingError!.classList.add('hidden');\n\t// This gives the document a chance to repaint, as otherwise our javascript\n\t// will continue to run until the next animation frame, which could be a long time.\n\tawait thread.sleep(0);\n}\n\nasync function onError(): Promise<void> {\n\t// const type = event.type; // Event type: \"error\"/\"abort\"\n\t// const target = event.target; // Element that triggered the event\n\t// const elementType = target?.tagName.toLowerCase();\n\t// const sourceURL = target?.src || target?.href; // URL of the resource that failed to load\n\t// console.error(`Event ${type} ocurred loading ${elementType} at ${sourceURL}.`);\n\n\telement_spinnyPawn!.classList.add('hidden');\n\n\t// Show the ERROR text\n\telement_loadingError!.classList.remove('hidden');\n\t// const lostNetwork = !navigator.onLine;\n\t// element_loadingErrorText!.textContent = lostNetwork ? translations['lost_network'] : translations['failed_to_load'];\n\telement_loadingErrorText!.textContent = translations.failed_to_load;\n\n\t// This gives the document a chance to repaint, as otherwise our javascript\n\t// will continue to run until the next animation frame, which could be a long time.\n\tawait thread.sleep(0);\n}\n\nexport default {\n\topen,\n\tclose,\n\tonError,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/stats.ts",
    "content": "// src/client/scripts/esm/game/gui/stats.ts\n\n/**\n * This script renders the stats in the corner of the screen (Similar to Minecraft's f3 menu):\n *\n * Move number\n * FPS\n */\n\nimport moveutil from '../../../../../shared/chess/util/moveutil.js';\n\nimport config from '../config.js';\nimport gameslot from '../chess/gameslot.js';\nimport guinavigation from './guinavigation.js';\n\n// Elements -------------------------------------------------------------\n\n/** The entire stats element container. */\nconst element_Statuses = document.getElementById('stats')!;\n\n/** The FPS text element. */\nconst elementStatusFPS = document.getElementById('status-fps')!;\n/** The Move Number text element. */\nconst elementStatusMoves = document.getElementById('status-moves')!;\n\n// Variables -------------------------------------------------------------\n\n/**\n * Weight of visibility for the move number stat.\n * When it is 0, the move number is hidden.\n */\nlet visibilityWeight = 0;\n\n/** Whether FPS display is enabled. */\nlet fps = false;\n\n// Move Number -------------------------------------------------------------\n\n/**\n * Temporarily displays the move number in the corner of the screen.\n * @param [durationSecs] The duration to show the move number. Default: 2.5\n */\nfunction showMoves(durationSecs: number = 2.5): void {\n\tif (config.VIDEO_MODE) return;\n\n\tvisibilityWeight++;\n\n\tupdateTextContentOfMoves();\n\tsetTimeout(hideMoves, durationSecs * 1000);\n\n\tif (visibilityWeight === 1) elementStatusMoves.classList.remove('hidden');\n}\n\nfunction hideMoves(): void {\n\tvisibilityWeight--;\n\tif (visibilityWeight === 0) elementStatusMoves.classList.add('hidden');\n}\n\nfunction updateTextContentOfMoves(): void {\n\tconst currentPly = gameslot.getGamefile()!.boardsim.state.local.moveIndex + 1;\n\tconst totalPlyCount = moveutil.getPlyCount(gameslot.getGamefile()!.boardsim.moves);\n\n\telementStatusMoves.textContent = `${translations.move_counter} ${currentPly}/${totalPlyCount}`;\n}\n\nfunction updateStatsCSS(): void {\n\telement_Statuses.style = `top: ${guinavigation.getHeightOfNavBar()}px`;\n}\n\n// FPS ----------------------------------------------------------------------\n\nfunction toggleFPS(): void {\n\tfps = !fps;\n\tif (fps) showFPS();\n\telse hideFPS();\n}\n\nfunction showFPS(): void {\n\tif (config.VIDEO_MODE) return;\n\telementStatusFPS.classList.remove('hidden');\n}\n\nfunction hideFPS(): void {\n\telementStatusFPS.classList.add('hidden');\n}\n\nfunction updateFPS(fps: number): void {\n\tif (!fps) return;\n\tconst truncated = fps | 0; // Bitwise operation that quickly rounds towards zero\n\telementStatusFPS.textContent = `FPS: ${truncated}`;\n}\n\n// Exports ------------------------------------------------------------------\n\nexport default {\n\tshowMoves,\n\tupdateStatsCSS,\n\ttoggleFPS,\n\tupdateFPS,\n\tupdateTextContentOfMoves,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/style.ts",
    "content": "// src/client/scripts/esm/game/gui/style.ts\n\n/**\n * Utility function for html elements and styles.\n *\n * It also keeps track of our javascript-inserted css in the style element of the html document\n * for things like the color of the navigation bar when theme changes.\n */\n\nimport type { Color } from '../../../../../shared/util/math/math';\n\n// Types -------------------------------------------------------------\n\n/** HSL Color representation */\ninterface HSLColor {\n\t/** Hue (0 - 360) */\n\th: number;\n\t/** Saturation (0.0 - 1.0) */\n\ts: number;\n\t/** Lightness (0.0 - 1.0) */\n\tl: number;\n}\n\n// Constants -------------------------------------------------------------\n\n/** SVG default namespace */\nconst SVG_NS = 'http://www.w3.org/2000/svg';\n\n// Elements  -------------------------------------------------------------\n\nconst element_style = document.getElementById('style')!; // The in-html-doc style element containing css stylings\n\n// Variables  -------------------------------------------------------------\n\n// What things require styling that our javascript changes?\n// * The navigation bar, when the theme changes.\nlet navigationStyle: string;\n\n// Functions -------------------------------------------------------------\n\nfunction setNavStyle(cssStyle: string): void {\n\tnavigationStyle = cssStyle;\n\t// Update the style element\n\telement_style.innerHTML = navigationStyle; // Other styles can be appended here later\n}\n\n/**\n * Finds the index of an element within its parent.\n * @param element - The element to find the index of.\n * @returns - The index of the element within its parent, or -1 if not found.\n */\nfunction getElementIndexWithinItsParent(element: Element): number {\n\tif (!element || !element.parentNode) return -1;\n\n\t// Get the parent node\n\tconst parent = element.parentNode;\n\n\t// Convert the parent's children to an array and find the index of the element\n\tconst children = Array.prototype.slice.call(parent.children);\n\treturn children.indexOf(element);\n}\n\n/**\n * Gets the child element at the specified index of a parent element.\n * @param parent - The parent element.\n * @param index - The index of the child element.\n * @returns The child element at the specified index, or null if not found.\n */\nfunction getChildByIndexInParent(parent: Element, index: number): Element | null {\n\tif (parent && parent.children && index >= 0 && index < parent.children.length) {\n\t\treturn parent.children[index]!;\n\t}\n\treturn null;\n}\n\n/**\n * Converts an array of [r, g, b, a], range 0-1, into a valid CSS rgba color string.\n * @param colorArray - An array containing [r, g, b, a] values, where r, g, b are in the range [0, 1].\n * @returns A CSS rgba color string.\n */\nfunction arrayToCssColor(colorArray: Color): string {\n\tif (colorArray.length !== 4)\n\t\tthrow new Error('Array must have exactly 4 elements: [r, g, b, a].');\n\n\tconst [r, g, b, a] = colorArray.map((value, index) => {\n\t\tif (index < 3) {\n\t\t\tif (value < 0 || value > 1) throw new Error('RGB values must be between 0 and 1.');\n\t\t\treturn Math.round(value * 255);\n\t\t} else {\n\t\t\tif (value < 0 || value > 1) throw new Error('Alpha value must be between 0 and 1.');\n\t\t\treturn value;\n\t\t}\n\t});\n\n\treturn `rgba(${r}, ${g}, ${b}, ${a})`;\n}\n\n/**\n * Converts RGB components to an HSL Color.\n * @param r - Red (0-255)\n * @param g - Green (0-255)\n * @param b - Blue (0-255)\n * @returns HSLColor object\n */\nfunction rgbToHsl(r: number, g: number, b: number): HSLColor {\n\tconst rN = r / 255;\n\tconst gN = g / 255;\n\tconst bN = b / 255;\n\n\tconst max = Math.max(rN, gN, bN);\n\tconst min = Math.min(rN, gN, bN);\n\n\tlet h = 0;\n\tlet s = 0;\n\tconst l = (max + min) / 2;\n\n\tif (max !== min) {\n\t\tconst d = max - min;\n\t\ts = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n\n\t\tswitch (max) {\n\t\t\tcase rN:\n\t\t\t\th = (gN - bN) / d + (gN < bN ? 6 : 0);\n\t\t\t\tbreak;\n\t\t\tcase gN:\n\t\t\t\th = (bN - rN) / d + 2;\n\t\t\t\tbreak;\n\t\t\tcase bN:\n\t\t\t\th = (rN - gN) / d + 4;\n\t\t\t\tbreak;\n\t\t}\n\t\th /= 6;\n\t}\n\n\treturn { h: h * 360, s, l };\n}\n\n/**\n * Converts numeric RGB components into a CSS rgb() color string.\n * @param r - Red channel (0-255)\n * @param g - Green channel (0-255)\n * @param b - Blue channel (0-255)\n * @returns A CSS color string, e.g., \"rgb(255, 100, 50)\"\n */\nfunction rgbToCssString(r: number, g: number, b: number): string {\n\treturn `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;\n}\n\n/**\n * Converts an HSLColor object into a CSS hsl() color string.\n * @param hsl The HSLColor object to convert.\n * @returns A CSS color string, e.g., \"hsl(360, 100%, 50%)\"\n */\nfunction hslToCssString(hsl: HSLColor): string {\n\tconst h = Math.round(hsl.h);\n\tconst s = Math.round(hsl.s * 100);\n\tconst l = Math.round(hsl.l * 100);\n\treturn `hsl(${h}, ${s}%, ${l}%)`;\n}\n\nexport default {\n\tSVG_NS,\n\n\tsetNavStyle,\n\tarrayToCssColor,\n\tgetElementIndexWithinItsParent,\n\tgetChildByIndexInParent,\n\trgbToHsl,\n\trgbToCssString,\n\thslToCssString,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/gui/toast.ts",
    "content": "// src/client/scripts/esm/game/gui/toast.ts\n\n/**\n * This script displays the toast (status message) on the bottom of the page.\n */\n\n// Types --------------------------------------------------------\n\ninterface ToastOptions {\n\t/** Whether the toast indicates an error. The backdrop will be red. */\n\terror?: boolean;\n\t/** Overrides the default duration of the toast. */\n\tdurationMillis?: number;\n\t/** Multiplies the duration of the toast. */\n\tdurationMultiplier?: number;\n}\n\n// Elements ----------------------------------------------------------\n\nconst statusMessage = document.getElementById('toastmessage')!;\nconst statusText = document.getElementById('toast')!;\n\n// Constants ---------------------------------------------------------\n\n/** Base duration for toasts, in milliseconds. */\nconst DURATION_BASE = 900;\n/** Duration multiplier per character in toasts, in milliseconds. */\nconst DURATION_MULTIPLIER = 45;\n\n/** Duration of the toasts' fade-out animation, in milliseconds. */\nconst FADE_DURATION = 1000;\n\n// Variables ---------------------------------------------------------\n\n/**\n * Weight of visibility for the toast.\n * When it is 0, it is hidden.\n */\nlet visibilityWeight = 0;\n\n// Functions ---------------------------------------------------------\n\nfunction show(text: string, options: ToastOptions = {}): void {\n\t// Safety net in case `text` was provided by an undefined translation of the `any` type:\n\tif (typeof text !== 'string') {\n\t\tconsole.warn('Unable to show toast: Not a string.');\n\t\treturn;\n\t}\n\n\tconst { error = false, durationMillis, durationMultiplier = 1 } = options;\n\n\tconst duration =\n\t\tdurationMillis ?? (DURATION_BASE + text.length * DURATION_MULTIPLIER) * durationMultiplier;\n\n\tvisibilityWeight++;\n\n\tfadeAfter(duration);\n\n\tstatusText.textContent = text;\n\tstatusText.classList.remove('fade-out-1s');\n\tstatusMessage.classList.remove('hidden');\n\n\tif (error) {\n\t\tstatusText.classList.remove('ok');\n\t\tstatusText.classList.add('error');\n\t\tconsole.error(text);\n\t} else {\n\t\tstatusText.classList.remove('error');\n\t\tstatusText.classList.add('ok');\n\t}\n}\n\n/**\n * Fades the current toast after the provided time,\n * if no new messages have been displayed in the meantime.\n */\nfunction fadeAfter(ms: number): void {\n\tsetTimeout(() => {\n\t\tif (visibilityWeight === 1) {\n\t\t\tstatusText.classList.add('fade-out-1s');\n\t\t\thideAfter(FADE_DURATION);\n\t\t} else visibilityWeight--; // This layer has been overwritten!\n\t}, ms);\n}\n\n/**\n * Hides the current toast after the provided time,\n * if no new messages have been displayed in the meantime.\n */\nfunction hideAfter(ms: number): void {\n\tsetTimeout(() => {\n\t\tvisibilityWeight--;\n\t\tif (visibilityWeight > 0) return; // Only one left, hide!\n\t\tstatusMessage.classList.add('hidden');\n\t\tstatusText.classList.remove('fade-out-1s');\n\t}, ms);\n}\n\n/** Shows a toast message stating to please wait to perform this task. */\nfunction showPleaseWaitForTask(): void {\n\tshow(translations.please_wait, { durationMultiplier: 0.5 });\n}\n\n// Exports -----------------------------------------------------------\n\nexport default {\n\tshow,\n\tshowPleaseWaitForTask,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/input.ts",
    "content": "// src/client/scripts/esm/game/input.ts\n\n/**\n * This script can attach input listeners to individual elements.\n *\n * Types of inputs it can hear: Keyboard, mouse, touch.\n *\n * It also can detect simulated mouse clicks via the mouse or finger,\n * and simulated double click drags!\n */\n\nimport type { DoubleCoords } from '../../../../shared/chess/util/coordutil.js';\n\nimport docutil from '../util/docutil.js';\n\n/**\n * A list of all keyboard shortcuts that don't have a built in event in javascript.\n * This includes: Undo, Redo, Select All\n * This does NOT include: Copy, Cut, Paste (these have built in events).\n */\nconst manual_shortcuts: string[] = ['KeyZ', 'KeyY', 'KeyA'];\n\nconst Mouse = {\n\tLEFT: 0,\n\tMIDDLE: 1,\n\tRIGHT: 2,\n} as const;\n\n// Maps buttons to string names\nconst MouseNames = {\n\t[Mouse.LEFT]: 'Left',\n\t[Mouse.MIDDLE]: 'Middle',\n\t[Mouse.RIGHT]: 'Right',\n} as const;\n\ntype MouseButton = (typeof Mouse)[keyof typeof Mouse];\n\n/** Information about a key that was pressed down this frame. */\ninterface KeyDownInfo {\n\t/** The key code that was pressed. */\n\tkeyCode: string;\n\t/** Whether a meta key (Ctrl or Cmd) was held when the key was pressed. */\n\tmetaKey: boolean;\n\t/** Whether the Shift key was held when the key was pressed. */\n\tshiftKey: boolean;\n}\n\ninterface InputListener {\n\t/** Whether this input listener has experience at least one input event the past frame. */\n\tatleastOneInput: () => boolean;\n\t/** Whether the given mouse button experienced a click-down this frame. */\n\tisMouseDown(_button: MouseButton): boolean;\n\t/** Removes the mouse down so that other scripts don't also use it. Also removes the pointer down. */\n\tclaimMouseDown(_button: MouseButton): void;\n\t/** Removes the pointer down so that other scripts don't also use it. */\n\tclaimPointerDown(_pointerId: string): void;\n\t/** Removes the simulated mouse click so that other scripts don't also use it. */\n\tclaimMouseClick(_button: MouseButton): void;\n\t/**\n\t * Resets the simulated mouse click on mouse-down so that\n\t * when it released it DOESN'T count as a click.\n\t */\n\tcancelMouseClick(_button: MouseButton): void;\n\t/** Whether the given mouse button is currently held down. */\n\tisMouseHeld(_button: MouseButton): boolean;\n\t/** Returns true if the most recent pointer for a specific mouse button action is a touch (not mouse). */\n\tisMouseTouch(_button: MouseButton): boolean;\n\t/** Returns true if the given pointer is a touch (not mouse). */\n\tisPointerTouch(_pointerId: string): boolean;\n\t/** Returns the id of the LOGICAL pointer that most recently performed an action on the specified mouse button. */\n\tgetMouseId(_button: MouseButton): string | undefined;\n\t/** Returns the id of the PHYSICAL pointer that most recently performed an action on the specified mouse button. */\n\tgetMousePhysicalId(_button: MouseButton): string | undefined;\n\t/** Returns the last known pointer position that trigerred a simulated event for the given mouse button. */\n\tgetMousePosition(_button: MouseButton): DoubleCoords | undefined;\n\t/** Whether the given mouse button simulated a full CLICK this frame. */\n\tisMouseClicked(_button: MouseButton): boolean;\n\t/** Whether the given mouse button experience a double-click-down this frame. */\n\tisMouseDoubleClickDragged(_button: MouseButton): boolean;\n\t/**\n\t * Toggles all-left click actions being treated as right-click actions.\n\t * This is useful for allowing fingers to right click.\n\t */\n\tsetTreatLeftasRight(_value: boolean): void;\n\t/** Returns the position of the given LOGICAL pointer id, if it still exists. */\n\tgetPointerPos(_pointerId?: string): DoubleCoords | undefined;\n\t/** Returns the position of the given PHYSICAL pointer id, if it still exists. */\n\tgetPhysicalPointerPos(_pointerId?: string): DoubleCoords | undefined;\n\t/** Returns the PHYSICAL pointer id this pointer is attached to. */\n\tgetPhysicalPointerIdOfPointer(_pointerId: string): string | undefined;\n\t/**\n\t * Returns the delta movement of the given PHYSICAL pointer id over the\n\t * past frame, if it still exists. The mouse pointer's id is 'mouse'.\n\t */\n\tgetPhysicalPointerDelta(_physicalPointerId: string): DoubleCoords | undefined;\n\t/**\n\t * Returns undefined if the pointer doesn't exist (finger has since lifted), or mouse isn't supported.\n\t * The mouse pointer's id is 'mouse'.\n\t */\n\tgetPointerVel(_pointerId: string): DoubleCoords | undefined;\n\t/** Returns the ids of all existing LOGICAL pointers for the given button action. */\n\tgetAllPointers(_button: MouseButton): string[];\n\t/** Returns the ids of all existing touch LOGICAL pointers, regardless of what button action they were for. */\n\tgetAllTouchPointers(): string[];\n\t/** Returns the ids of all existing PHYSICAL pointers. */\n\tgetAllPhysicalPointers(): string[];\n\t/**\n\t * Whether the given LOGICAL pointer is currently being held down.\n\t * Which also happens to be true if the pointer still EXISTS.\n\t */\n\tisPointerHeld(_pointerId: string): boolean;\n\t/** Whether the given LOGICAL pointer still exists (held down). */\n\tpointerExists(_pointerId: string): boolean;\n\t/** Returns a list of all LOGICAL pointers that were pressed down this frame for the given button action. */\n\tgetPointersDown(_button: MouseButton): string[];\n\t/** Returns a list of all touch LOGICAL pointers that were pressed down this frame, regardless of what button action they were for. */\n\tgetTouchPointersDown(): string[];\n\t/** Returns the number of pointers that were pressed down this frame. */\n\tgetPointersDownCount(): number;\n\t/** Returns whether the provided LOGICAL pointer belongs to the provided PHYSICAL pointer. */\n\tdoesPointerBelongToPhysicalPointer(\n\t\t_logicalPointerId: string,\n\t\t_physicalPointerId: string,\n\t): boolean;\n\t/** Returns how much the wheel has scrolled this frame. */\n\tgetWheelDelta(): number;\n\t/**\n\t * Whether the provided keyboard key was pressed down this frame.\n\t * @param keyCode - The key code to check\n\t * @param requireMetaKey - If true, only returns true if a meta key (Ctrl/Cmd) was also held.\n\t * @param requireShiftKey - If true, only returns true if the Shift key was also held.\n\t */\n\tisKeyDown(_keyCode: string, _requireMetaKey?: boolean, _requireShiftKey?: boolean): boolean;\n\t/** Whether the provided keyboard key is currently being held down. */\n\tisKeyHeld(_keyCode: string): boolean;\n\t/** Removes the key-down event for the given key code so that other scripts don't also use it. */\n\tclaimKey(_keyCode: string): void;\n\t/** Call when done with the input listener. This closes all its event listeners. */\n\tremoveEventListeners(): void;\n\t/** The element this input listener is attached to. */\n\telement: HTMLElement | typeof document;\n}\n\ntype PointerHistory = { pos: DoubleCoords; time: number }[];\n\n/** Options for simulated clicks */\nconst CLICK_THRESHOLDS = {\n\tMOUSE: {\n\t\t/** The maximum distance the mouse can move before a click is not registered. */\n\t\tMOVE_VPIXELS: 6, // Default: 8\n\t\t/** The maximum time the mouse can be held down before a click is not registered. */\n\t\tTIME_MILLIS: 400, // Default: 400\n\t\t/** The maximum time between first click down and second click up to register a double click drag. */\n\t\tDOUBLE_CLICK_TIME_MILLIS: 450, // Default: 500\n\t},\n\tTOUCH: {\n\t\t/** {@link CLICK_THRESHOLDS.MOUSE.MOVE_VPIXELS}, but for fingers (less strict, the 2nd tap can be further away) */\n\t\tMOVE_VPIXELS: 17, // Default: 20\n\t\t/** {@link CLICK_THRESHOLDS.MOUSE.TIME_MILLIS}, but for fingers (more strict, they must lift quicker) */\n\t\tTIME_MILLIS: 120,\n\t\t/** {@link CLICK_THRESHOLDS.MOUSE.DOUBLE_CLICK_TIME_MILLIS}, but for fingers (more strict, they must lift quicker) */\n\t\tDOUBLE_CLICK_TIME_MILLIS: 250, // Default: 220\n\t},\n} as const;\n\n/** The window of milliseconds to store mouse position history for velocity calculations. */\nconst MOUSE_POS_HISTORY_WINDOW_MILLIS = 80;\n\n/**\n * Physical Pointers are assigned one trackable POSITION.\n * But they may be linked to multiple Logical Pointers if\n * it supports multiple mouse buttons (touches do not).\n */\ntype PhysicalPointer = {\n\t/** The unique id of the pointer. */\n\tid: string;\n\t/** Whether the Physical Pointer is derived from a touch (finger). */\n\tisTouch: boolean;\n\t/** The position of the pointer relative to the top-left corner of the listener's element. */\n\tposition: DoubleCoords;\n\t/** How many pixels the pointer has moved since last frame. */\n\tdelta: DoubleCoords;\n\t/** Used for calculating velocity */\n\tpositionHistory: PointerHistory;\n\tvelocity: DoubleCoords;\n};\n\n/**\n * Logical Pointers represent one BUTTON ACTION continuously held down.\n * Multiple Logical Pointers may be attached to one Physical Pointer.\n * These are deleted as soon as the button is lifted up.\n */\ntype LogicalPointer = {\n\t/** The unique id of the pointer. May be identical to its Physical Pointer's id if it's a touch pointer. */\n\tid: string;\n\t/** The Physical Pointer it's linked to. */\n\tphysical: PhysicalPointer;\n\t/** What button action this is for. */\n\tbutton: MouseButton;\n};\n\n/**\n * Keeps track of the recent down position of mouse buttons.\n * Allowing us to perform simulated clicks or double click drags with any of them.\n */\ninterface ClickInfo {\n\t/** The id of the LOGICAL pointer that most recently pressed this mouse button. */\n\tpointerId?: string;\n\t/** The id of the PHYSICAL pointer tied to the logical pointer that most recently pressed this mouse button. */\n\tphysicalId?: string;\n\t/** Whether the last action for this Button was from a touch. */\n\tisTouch: boolean;\n\t/** Whether this mouse button was pushed down THIS FRAME */\n\tisDown: boolean;\n\t/** Whether this mouse button is currently being held down. */\n\tisHeld: boolean;\n\t/**\n\t * Whether this mouse button has been a simulated click or not.\n\t * Clicks are registered if the mouse goes up within a small window\n\t * after going down, and the mouse has not moved beyond a certain threshold.\n\t */\n\tclicked: boolean;\n\t/** The time the mouse button was pressed down. */\n\ttimeDownMillisHistory: number[];\n\t/**\n\t * The last known position the mouse button was pressed down.\n\t *\n\t * Also Used for calculating simulated clicks, when touch events\n\t * don't provide delta from lift to down.\n\t */\n\tposDown?: DoubleCoords;\n\t/**\n\t * How much the mouse has ABSOLUTELY moved since the last click down.\n\t * ONLY USED FOR CALCULATING SIMULATED CLICKS AND DOUBLE CLICK DRAGS,\n\t * as if the pointer has moved too far, we don't register the click.\n\t *\n\t * We use delta instead of remembering the position down, because when\n\t * the mouse is locked in perspective mode, the position is not updated.\n\t *\n\t * This can only be positive, not negative.\n\t */\n\tdeltaSinceDown: DoubleCoords;\n\t/**\n\t * The last known position of the last active pointer for this mouse button.\n\t * UPDATES ON DOWN AND UP, NOT ON MOVE.\n\t */\n\tposition?: DoubleCoords;\n\t/** Whether this frame incurred the start of a double click drag */\n\tdoubleClickDrag: boolean;\n}\n\n/**\n * Creates an input listener that listens to mouse and keyboard events on the given element.\n *\n * EVERY FRAME you need to dispatch the 'reset-listener-events' event on the document\n * to reset the state of the input listener.\n * @param element - The HTML element to listen for events on.\n * @returns An object with methods to check the state of mouse and keyboard inputs.\n */\nfunction CreateInputListener(\n\telement: HTMLElement | typeof document,\n\t{ keyboard = true, mouse = true }: { keyboard?: boolean; mouse?: boolean } = {},\n): InputListener {\n\tconst keyDowns: KeyDownInfo[] = [];\n\tconst keyHelds: string[] = [];\n\t/** The amount the scroll wheel has scrolled this frame. */\n\tlet wheelDelta: number = 0;\n\n\t/** Tracks the physical input sources. Only one entry for 'mouse'. */\n\tconst physicalPointers: Record<string, PhysicalPointer> = {};\n\t/** Tracks the virtual pointers, one for each button action (left/right/middle). */\n\tconst logicalPointers: Record<string, LogicalPointer> = {};\n\n\t/** A list of all LOGICAL pointer id's that were pressed down this frame. */\n\tconst pointersDown: string[] = [];\n\n\t/**\n\t * Whether to treat all left click actions as right click actions.\n\t * This is useful for allowing fingers to right click.\n\t */\n\tlet treatLeftAsRight = false;\n\n\t// console.log(\"Mouse supported: \", docutil.isMouseSupported());\n\t// Immediately add the mouse pointer if the doc supports it\n\tif (docutil.isMouseSupported()) {\n\t\tphysicalPointers['mouse'] = {\n\t\t\tisTouch: false,\n\t\t\tid: 'mouse',\n\t\t\tposition: [0, 0],\n\t\t\tdelta: [0, 0],\n\t\t\tpositionHistory: [],\n\t\t\tvelocity: [0, 0],\n\t\t};\n\t}\n\n\t/** Whether there has been any input this frame. */\n\tlet atleastOneInputThisFrame = false;\n\n\tconst clickInfo: Record<MouseButton, ClickInfo> = {\n\t\t[Mouse.LEFT]: {\n\t\t\tisTouch: false,\n\t\t\tisDown: false,\n\t\t\tisHeld: false,\n\t\t\tclicked: false,\n\t\t\tdoubleClickDrag: false,\n\t\t\ttimeDownMillisHistory: [],\n\t\t\tdeltaSinceDown: [0, 0],\n\t\t},\n\t\t[Mouse.MIDDLE]: {\n\t\t\tisTouch: false,\n\t\t\tisDown: false,\n\t\t\tisHeld: false,\n\t\t\tclicked: false,\n\t\t\tdoubleClickDrag: false,\n\t\t\ttimeDownMillisHistory: [],\n\t\t\tdeltaSinceDown: [0, 0],\n\t\t},\n\t\t[Mouse.RIGHT]: {\n\t\t\tisTouch: false,\n\t\t\tisDown: false,\n\t\t\tisHeld: false,\n\t\t\tclicked: false,\n\t\t\tdoubleClickDrag: false,\n\t\t\ttimeDownMillisHistory: [],\n\t\t\tdeltaSinceDown: [0, 0],\n\t\t},\n\t};\n\n\tconst eventHandlers: Record<string, { target: EventTarget; handler: EventListener }> = {};\n\n\t// Helper Functions ---------------------------------------------------------------------------\n\n\tfunction addListener(target: EventTarget, eventType: string, handler: EventListener): void {\n\t\ttarget.addEventListener(eventType, handler);\n\t\teventHandlers[eventType] = { target, handler };\n\t}\n\n\t/** Reset the input events for the next frame. Fire 'reset-listener-events' event at the very end of EVERY frame. */\n\tdocument.addEventListener('reset-listener-events', () => {\n\t\t// console.log(\"Resetting events\");\n\t\t// We can continuously hold a key without triggering more events, so held keys should still count as an input that frame.\n\t\t// atleastOneInputThisFrame = keyHelds.length > 0 || Object.values(clickInfo).some(clickInfo => clickInfo.isHeld);\n\t\tatleastOneInputThisFrame = keyHelds.length > 0;\n\t\t// console.log(\"Atleast one input this frame: \", atleastOneInputThisFrame);\n\t\t// For each mouse button, reset its state\n\t\tfor (const button of Object.values(clickInfo)) {\n\t\t\tbutton.isDown = false;\n\t\t\tbutton.clicked = false;\n\t\t\tbutton.doubleClickDrag = false;\n\t\t\t// Trim their timeDownMillisHistory of old mouse downs\n\t\t\tbutton.timeDownMillisHistory = button.timeDownMillisHistory.filter(\n\t\t\t\t(time) => time > Date.now() - 3000,\n\t\t\t);\n\t\t}\n\t\t// For each pointer, reset its state\n\t\tconst now = Date.now();\n\t\tfor (const pointer of Object.values(physicalPointers)) {\n\t\t\tpointer.delta = [0, 0];\n\t\t\tpointer.positionHistory = pointer.positionHistory.filter(\n\t\t\t\t(entry) => entry.time > Date.now() - MOUSE_POS_HISTORY_WINDOW_MILLIS,\n\t\t\t);\n\t\t\trecalcPointerVel(pointer, now);\n\t\t}\n\n\t\tkeyDowns.length = 0;\n\t\tpointersDown.length = 0;\n\t\twheelDelta = 0;\n\t});\n\n\t/** Calculates the mouse velocity based on recent mouse positions. */\n\tfunction recalcPointerVel(pointer: PhysicalPointer, now: number): void {\n\t\t// Remove old entries, stop once we encounter recent enough data\n\t\tconst timeToRemoveEntriesBefore = now - MOUSE_POS_HISTORY_WINDOW_MILLIS;\n\t\twhile (\n\t\t\tpointer.positionHistory.length > 0 &&\n\t\t\tpointer.positionHistory[0]!.time < timeToRemoveEntriesBefore\n\t\t)\n\t\t\tpointer.positionHistory.shift();\n\n\t\t// Calculate velocity if there are at least two positions\n\t\tif (pointer.positionHistory.length >= 2) {\n\t\t\tconst latestMousePosEntry =\n\t\t\t\tpointer.positionHistory[pointer.positionHistory.length - 1]!;\n\t\t\tconst firstMousePosEntry = pointer.positionHistory[0]!; // { mousePos, time }\n\t\t\tconst timeDiffBetwFirstAndLastEntryMillis =\n\t\t\t\tlatestMousePosEntry.time - firstMousePosEntry.time;\n\n\t\t\tconst mVX =\n\t\t\t\t(latestMousePosEntry.pos[0] - firstMousePosEntry.pos[0]) /\n\t\t\t\ttimeDiffBetwFirstAndLastEntryMillis;\n\t\t\tconst mVY =\n\t\t\t\t(latestMousePosEntry.pos[1] - firstMousePosEntry.pos[1]) /\n\t\t\t\ttimeDiffBetwFirstAndLastEntryMillis;\n\n\t\t\tpointer.velocity = [mVX, mVY];\n\t\t} else pointer.velocity = [0, 0];\n\t}\n\n\t// Simulated Click Events (either mouse or finger) ------------------------------------------------------------\n\n\tfunction updateClickInfoDown(targetButton: MouseButton, e: MouseEvent | Touch): void {\n\t\t// console.log(\"Mouse down: \", MouseNames[targetButton]);\n\t\tconst targetButtonInfo = clickInfo[targetButton];\n\t\tif (targetButtonInfo === undefined) return; // Invalid button (some mice have extra buttons)\n\n\t\t// This makes it so the coordinate input fields are unfocused when clicking the canvas.\n\t\tconst prev = document.activeElement;\n\t\tif (element instanceof HTMLElement && prev !== element && prev instanceof HTMLElement)\n\t\t\tprev.blur();\n\n\t\t// Generate a unique logical ID for the action.\n\t\tconst logicalId = getLogicalPointerId(e, targetButton);\n\t\tconst physicalId = getPhysicalPointerId(e);\n\t\ttargetButtonInfo.pointerId = logicalId;\n\t\ttargetButtonInfo.physicalId = physicalId;\n\t\ttargetButtonInfo.isTouch = !(e instanceof MouseEvent); // CAN'T USE instanceof Touch because it's not defined in Safari!\n\t\ttargetButtonInfo.isDown = true;\n\t\ttargetButtonInfo.isHeld = true;\n\t\tconst relativeMousePos = getRelativeMousePosition([e.clientX, e.clientY], element);\n\t\ttargetButtonInfo.position = [...relativeMousePos];\n\t\t// if (targetButton === Mouse.LEFT) pointersDown.push(targetButtonInfo.pointerId!);\n\t\t// Push them down anyway no matter which type of click.\n\t\t// So that you can still pinch the board when fingers act as right clicks.\n\t\tpointersDown.push(logicalId);\n\n\t\t// Create LOGICAL pointer, which automatically means it's held down.\n\t\tlogicalPointers[logicalId] = {\n\t\t\tid: logicalId,\n\t\t\tphysical: physicalPointers[physicalId]!,\n\t\t\tbutton: targetButton,\n\t\t};\n\n\t\t// Update click ------------\n\t\tconst previousTimeDown =\n\t\t\ttargetButtonInfo.timeDownMillisHistory[\n\t\t\t\ttargetButtonInfo.timeDownMillisHistory.length - 1\n\t\t\t];\n\t\tconst now = Date.now();\n\t\ttargetButtonInfo.timeDownMillisHistory.push(now);\n\t\t// Update double click draw ----------\n\t\tconst DOUBLE_CLICK_TIME_MILLIS =\n\t\t\te instanceof MouseEvent\n\t\t\t\t? CLICK_THRESHOLDS.MOUSE.DOUBLE_CLICK_TIME_MILLIS\n\t\t\t\t: CLICK_THRESHOLDS.TOUCH.DOUBLE_CLICK_TIME_MILLIS; // CAN'T USE instanceof Touch because it's not defined in Safari!\n\t\tif (previousTimeDown && now - previousTimeDown < DOUBLE_CLICK_TIME_MILLIS) {\n\t\t\t// Mouse has been down at least once before.\n\t\t\t// Now we now posDown will be defined, so we can calculate the distance to that last click down.\n\t\t\t// Works for 2D mode, desktop & mobile\n\t\t\tconst posDown = targetButtonInfo.posDown;\n\t\t\tconst distMoved = posDown\n\t\t\t\t? Math.max(\n\t\t\t\t\t\tMath.abs(posDown[0] - relativeMousePos[0]),\n\t\t\t\t\t\tMath.abs(posDown[1] - relativeMousePos[1]),\n\t\t\t\t\t)\n\t\t\t\t: 0;\n\t\t\t// Works for 3D mode, desktop (mouse is locked in place then)\n\t\t\tconst delta = Math.max(\n\t\t\t\ttargetButtonInfo.deltaSinceDown[0],\n\t\t\t\ttargetButtonInfo.deltaSinceDown[1],\n\t\t\t);\n\t\t\t// console.log(\"Mouse delta:\", delta);\n\t\t\tconst MOVE_VPIXELS =\n\t\t\t\te instanceof MouseEvent\n\t\t\t\t\t? CLICK_THRESHOLDS.MOUSE.MOVE_VPIXELS\n\t\t\t\t\t: CLICK_THRESHOLDS.TOUCH.MOVE_VPIXELS; // CAN'T USE instanceof Touch because it's not defined in Safari!\n\t\t\tif (distMoved < MOVE_VPIXELS && delta < MOVE_VPIXELS) {\n\t\t\t\t// Only register the double click drag if the mouse hasn't moved too far from its last click down.\n\t\t\t\ttargetButtonInfo.doubleClickDrag = true;\n\t\t\t\t// console.log(\"Mouse double click dragged: \", MouseNames[targetButton]);\n\t\t\t}\n\t\t\t// else console.log(\"Mouse double click MOVED TOO FAR: \", MouseNames[targetButton]);\n\t\t} // ----------------\n\n\t\t// Now we can update the last click down after checking for its distance to the last one.\n\t\ttargetButtonInfo.posDown = [...relativeMousePos];\n\t\ttargetButtonInfo.deltaSinceDown = [0, 0]; // Reset the delta since down\n\t}\n\n\tfunction updateClickInfoUp(targetButton: MouseButton, e: MouseEvent | Touch): void {\n\t\t// console.log(\"Mouse up: \", MouseNames[targetButton]);\n\t\tconst targetButtonInfo = clickInfo[targetButton];\n\t\tif (targetButtonInfo === undefined) return; // Invalid button (some mice have extra buttons)\n\t\tconst logicalId = getLogicalPointerId(e, targetButton);\n\t\tconst physicalId = getPhysicalPointerId(e);\n\t\ttargetButtonInfo.pointerId = logicalId;\n\t\ttargetButtonInfo.physicalId = physicalId;\n\t\ttargetButtonInfo.isTouch = !(e instanceof MouseEvent); // CAN'T USE instanceof Touch because it's not defined in Safari!\n\t\ttargetButtonInfo.isDown = false;\n\t\ttargetButtonInfo.isHeld = false;\n\t\tconst relativeMousePos = getRelativeMousePosition([e.clientX, e.clientY], element);\n\t\ttargetButtonInfo.position = [...relativeMousePos];\n\n\t\t// Remove the pointer from the list of pointers down too, if it's in there.\n\t\t// This can happen if it was added & removed in a single frame.\n\t\tconst index = pointersDown.indexOf(targetButtonInfo.pointerId!);\n\t\tif (index !== -1) pointersDown.splice(index, 1);\n\n\t\t// Mark the LOGICAL pointer as no longer held.\n\t\t// We have to delete it so that it doesn't inflate the pointer count.\n\t\tdelete logicalPointers[logicalId];\n\n\t\t// Update click --------------\n\t\tconst mouseHistory = targetButtonInfo.timeDownMillisHistory;\n\t\tconst timePassed = Date.now() - (mouseHistory[mouseHistory.length - 1] ?? 0); // Since the latest click\n\t\tconst TIME_MILLIS =\n\t\t\te instanceof MouseEvent\n\t\t\t\t? CLICK_THRESHOLDS.MOUSE.TIME_MILLIS\n\t\t\t\t: CLICK_THRESHOLDS.TOUCH.TIME_MILLIS; // CAN'T USE instanceof Touch because it's not defined in Safari!\n\t\tif (timePassed < TIME_MILLIS) {\n\t\t\t// Works for 2D mode, desktop & mobile\n\t\t\tconst posDown = targetButtonInfo.posDown;\n\t\t\tconst distMoved = posDown\n\t\t\t\t? Math.max(\n\t\t\t\t\t\tMath.abs(posDown[0] - relativeMousePos[0]),\n\t\t\t\t\t\tMath.abs(posDown[1] - relativeMousePos[1]),\n\t\t\t\t\t)\n\t\t\t\t: 0; // No click down to compare to. This can happen if you click down offscreen.\n\t\t\t// Works for 3D mode, desktop (mouse is locked in place then)\n\t\t\tconst delta = Math.max(\n\t\t\t\ttargetButtonInfo.deltaSinceDown[0],\n\t\t\t\ttargetButtonInfo.deltaSinceDown[1],\n\t\t\t);\n\t\t\t// console.log(\"Mouse delta: \", delta);\n\t\t\tconst MOVE_VPIXELS =\n\t\t\t\te instanceof MouseEvent\n\t\t\t\t\t? CLICK_THRESHOLDS.MOUSE.MOVE_VPIXELS\n\t\t\t\t\t: CLICK_THRESHOLDS.TOUCH.MOVE_VPIXELS; // CAN'T USE instanceof Touch because it's not defined in Safari!\n\t\t\tif (distMoved < MOVE_VPIXELS && delta < MOVE_VPIXELS) {\n\t\t\t\ttargetButtonInfo.clicked = true;\n\t\t\t\t// console.log(\"Mouse clicked: \", MouseNames[targetButton]);\n\t\t\t}\n\t\t} // --------------\n\t}\n\n\t/**\n\t * On pointer move. This updates the deltaSinceDown for the\n\t * clickInfo of the mouse button whos most recent action\n\t * was from the pointerId.\n\t *\n\t * If the pointer moves too much, don't simulate a click.\n\t */\n\tfunction updateDeltaSinceDownForPointer(physicalPointerId: string, delta: DoubleCoords): void {\n\t\t// Update the delta (deltaSinceDown) for simulated mouse clicks\n\t\tObject.values(Mouse).forEach((targetButton) => {\n\t\t\tconst targetButtonInfo = clickInfo[targetButton];\n\n\t\t\t// Only update the click info's delta since down if the physical pointer that most recently performed that click action matches\n\t\t\tif (targetButtonInfo.physicalId !== physicalPointerId) return;\n\n\t\t\t// Update the delta since down\n\t\t\ttargetButtonInfo.deltaSinceDown[0] += Math.abs(delta[0]);\n\t\t\ttargetButtonInfo.deltaSinceDown[1] += Math.abs(delta[1]);\n\t\t});\n\t}\n\n\tif (mouse) {\n\t\t// Mouse Events ---------------------------------------------------------------------------\n\n\t\taddListener(element, 'mousedown', ((e: MouseEvent): void => {\n\t\t\tif (element instanceof HTMLElement) {\n\t\t\t\tif (e.target !== element) return; // Ignore events triggered on CHILDREN of the element.\n\t\t\t\t// Prevents dragging the board also selecting/highlighting text in Coordinates container\n\t\t\t\t// We can't prevent default the document input listener tho or dropdown selections can't be opened.\n\t\t\t\te.preventDefault();\n\t\t\t}\n\t\t\tconst targetPointer = physicalPointers['mouse'];\n\t\t\tif (!targetPointer) return; // Sometimes the 'mousedown' event is fired from touch events, even though the mouse pointer does not exist.\n\t\t\tatleastOneInputThisFrame = true;\n\t\t\tconst eventButton = e.button as MouseButton;\n\t\t\t// If alt is held,  right click instead\n\t\t\tconst button =\n\t\t\t\t(e.altKey || treatLeftAsRight) && eventButton === Mouse.LEFT\n\t\t\t\t\t? Mouse.RIGHT\n\t\t\t\t\t: eventButton;\n\t\t\tupdateClickInfoDown(button, e);\n\t\t}) as EventListener);\n\n\t\t// This listener is placed on the document so we don't miss mouseup events if the user lifts their mouse off the element.\n\t\taddListener(document, 'mouseup', ((e: MouseEvent): void => {\n\t\t\tatleastOneInputThisFrame = true;\n\t\t\tconst eventButton = e.button as MouseButton;\n\t\t\t// If alt is held, right click instead\n\t\t\tconst button =\n\t\t\t\t(e.altKey || treatLeftAsRight) && eventButton === Mouse.LEFT\n\t\t\t\t\t? Mouse.RIGHT\n\t\t\t\t\t: eventButton;\n\t\t\tupdateClickInfoUp(button, e);\n\t\t}) as EventListener);\n\n\t\t// Mouse position tracking\n\t\taddListener(element, 'mousemove', ((e: MouseEvent): void => {\n\t\t\tatleastOneInputThisFrame = true;\n\t\t\tconst physicalPointer = physicalPointers['mouse'];\n\t\t\tif (!physicalPointer) return; // Sometimes the 'mousemove' event is fired from touch events, even though the mouse pointer does not exist.\n\t\t\tphysicalPointer.position = getRelativeMousePosition([e.clientX, e.clientY], element);\n\t\t\t// console.log(`Updated pointer ${targetPointer.id} position:`, targetPointer.position);\n\t\t\t// Update delta (Note: e.movementX/Y are relative to the document, it should be fine)\n\t\t\t// Add to the current delta, in case this event is triggered multiple times in a frame.\n\t\t\tphysicalPointer.delta[0] += e.movementX;\n\t\t\tphysicalPointer.delta[1] += e.movementY;\n\n\t\t\t// Update the delta (deltaSinceDown) for simulated mouse clicks\n\t\t\tupdateDeltaSinceDownForPointer(physicalPointer.id, physicalPointer.delta);\n\n\t\t\t// console.log(\"Mouse delta: \", targetPointer.delta);\n\t\t\t// Update velocity\n\t\t\tconst now = Date.now();\n\t\t\tphysicalPointer.positionHistory.push({ pos: [...physicalPointer.position], time: now }); // Deep copy the mouse position to avoid modifying the original\n\t\t\trecalcPointerVel(physicalPointer, now);\n\t\t\t// console.log(\"Mouse relative position: \", targetPointer.position);\n\t\t}) as EventListener);\n\n\t\t// Scroll wheel tracking\n\t\taddListener(element, 'wheel', ((e: WheelEvent): void => {\n\t\t\tif (element instanceof HTMLElement && e.target !== element) return; // Ignore events triggered on CHILDREN of the element.\n\t\t\tatleastOneInputThisFrame = true;\n\t\t\twheelDelta = e.deltaY;\n\t\t\t// console.log(\"Scroll wheel: \", wheelDelta);\n\t\t}) as EventListener);\n\n\t\t// Prevent the context menu on right click\n\t\taddListener(element, 'contextmenu', ((e: MouseEvent): void => {\n\t\t\tif (element instanceof Document || e.target !== element) return; // Allow context menu outside the element, or inside as long as the target isn't the element.\n\t\t\tatleastOneInputThisFrame = true;\n\t\t\t// console.log(\"Context menu\");\n\t\t\te.preventDefault();\n\t\t}) as EventListener);\n\n\t\t// Finger Events ---------------------------------------------------------------------------\n\n\t\taddListener(element, 'touchstart', ((e: TouchEvent): void => {\n\t\t\tif (e.target !== element) return; // Ignore events triggered on CHILDREN of the element.\n\t\t\tatleastOneInputThisFrame = true;\n\n\t\t\t// Prevent default behavior of touch events\n\t\t\t// Stops fingers from also triggering mouse events,\n\t\t\t// and prevents chrome swipe gestures.\n\t\t\t// This still allows the touchstart to perform default actions\n\t\t\t// if we interacted with an element INSIDE the element.\n\t\t\tif (e.target instanceof HTMLElement && e.target === element) e.preventDefault();\n\n\t\t\tfor (let i = 0; i < e.changedTouches.length; i++) {\n\t\t\t\tconst touch: Touch = e.changedTouches[i]!;\n\t\t\t\tconst position = getRelativeMousePosition([touch.clientX, touch.clientY], element);\n\n\t\t\t\tconst physicalId = getPhysicalPointerId(touch);\n\n\t\t\t\t// 1. Create the Physical Pointer\n\t\t\t\tphysicalPointers[physicalId] = {\n\t\t\t\t\tisTouch: true,\n\t\t\t\t\tid: physicalId,\n\t\t\t\t\tposition,\n\t\t\t\t\tdelta: [0, 0],\n\t\t\t\t\tpositionHistory: [{ pos: [...position], time: Date.now() }],\n\t\t\t\t\tvelocity: [0, 0],\n\t\t\t\t};\n\t\t\t\t// console.log(\"Touch start: \", touch.identifier);\n\n\t\t\t\t// Treat fingers as the left mouse button by default\n\t\t\t\tconst button = treatLeftAsRight ? Mouse.RIGHT : Mouse.LEFT;\n\t\t\t\tupdateClickInfoDown(button, touch);\n\t\t\t}\n\t\t}) as EventListener);\n\n\t\taddListener(element, 'touchmove', ((e: TouchEvent): void => {\n\t\t\tatleastOneInputThisFrame = true;\n\t\t\tfor (let i = 0; i < e.changedTouches.length; i++) {\n\t\t\t\tconst touch: Touch = e.changedTouches[i]!;\n\t\t\t\tconst touchId = getPhysicalPointerId(touch);\n\t\t\t\tconst physicalPointer = physicalPointers[touchId];\n\t\t\t\tif (!physicalPointer) continue; // This touch likely started outside the element, so we ignored adding it.\n\n\t\t\t\tconst relativeTouchPos = getRelativeMousePosition(\n\t\t\t\t\t[touch.clientX, touch.clientY],\n\t\t\t\t\telement,\n\t\t\t\t);\n\t\t\t\t// Update delta\n\t\t\t\tphysicalPointer.delta[0] += relativeTouchPos[0] - physicalPointer.position[0];\n\t\t\t\tphysicalPointer.delta[1] += relativeTouchPos[1] - physicalPointer.position[1];\n\t\t\t\t// Position\n\t\t\t\tphysicalPointer.position = relativeTouchPos;\n\n\t\t\t\t// Update the delta (deltaSinceDown) for simulated mouse clicks\n\t\t\t\tupdateDeltaSinceDownForPointer(physicalPointer.id, physicalPointer.delta);\n\n\t\t\t\t// Update velocity\n\t\t\t\tconst now = Date.now();\n\t\t\t\tphysicalPointer.positionHistory.push({\n\t\t\t\t\tpos: [...physicalPointer.position],\n\t\t\t\t\ttime: now,\n\t\t\t\t}); // Deep copy the touch position to avoid modifying the original\n\t\t\t\trecalcPointerVel(physicalPointer, now);\n\t\t\t\t// console.log(\"Touch position: \", targetPointer.position);\n\t\t\t}\n\t\t}) as EventListener);\n\n\t\t// This listeners are placed on the document so we don't miss touchend events if the user lifts their finger off the element.\n\t\taddListener(document, 'touchend', touchEndCallback as EventListener);\n\t\taddListener(document, 'touchcancel', touchEndCallback as EventListener);\n\n\t\tfunction touchEndCallback(e: TouchEvent): void {\n\t\t\tatleastOneInputThisFrame = true;\n\t\t\tfor (let i = 0; i < e.changedTouches.length; i++) {\n\t\t\t\tconst touch: Touch = e.changedTouches[i]!;\n\t\t\t\t// console.log(\"Touch end/cancel: \", touch.identifier);\n\t\t\t\tconst physicalId = getPhysicalPointerId(touch);\n\t\t\t\t// Destroy both pointers since it's a touch\n\t\t\t\tdelete logicalPointers[physicalId];\n\t\t\t\tdelete physicalPointers[physicalId];\n\n\t\t\t\t// Treat fingers as the left mouse button by default\n\t\t\t\tconst button = treatLeftAsRight ? Mouse.RIGHT : Mouse.LEFT;\n\t\t\t\tupdateClickInfoUp(button, touch);\n\t\t\t}\n\t\t}\n\t}\n\n\t// Keyboard Events ---------------------------------------------------------------------------\n\n\tif (keyboard) {\n\t\taddListener(element, 'keydown', ((e: KeyboardEvent): void => {\n\t\t\t// If spacebar pressed when checkbox focused => Prevent default.\n\t\t\t// Prevents pushing spacebar in the board editor game rules UI after\n\t\t\t// toggling a checkbox from toggling it again when you intend to zoom.\n\t\t\tif (\n\t\t\t\te.code === 'Space' &&\n\t\t\t\tdocument.activeElement instanceof HTMLInputElement &&\n\t\t\t\tdocument.activeElement.type === 'checkbox'\n\t\t\t)\n\t\t\t\te.preventDefault();\n\t\t\t// if (e.target !== element) return; // Ignore events triggered on CHILDREN of the element.\n\t\t\tif (document.activeElement instanceof HTMLInputElement) return; // Ignore events when the user is typing in a text box.\n\t\t\t// console.log(\"Key down: \", e.code);\n\t\t\tatleastOneInputThisFrame = true;\n\t\t\tif (!keyDowns.some((keyInfo) => keyInfo.keyCode === e.code)) {\n\t\t\t\tkeyDowns.push({\n\t\t\t\t\tkeyCode: e.code,\n\t\t\t\t\tmetaKey: e.ctrlKey || e.metaKey,\n\t\t\t\t\tshiftKey: e.shiftKey,\n\t\t\t\t});\n\t\t\t}\n\t\t\t// Only add to keyHelds if no meta key was held, unless the key IS a meta key itself.\n\t\t\t// Prevents stuff like `Ctrl > A` from panning the board.\n\t\t\tconst isMetaKey =\n\t\t\t\te.code === 'ControlLeft' ||\n\t\t\t\te.code === 'ControlRight' ||\n\t\t\t\te.code === 'MetaLeft' ||\n\t\t\t\te.code === 'MetaRight' ||\n\t\t\t\te.code === 'AltLeft' ||\n\t\t\t\te.code === 'AltRight';\n\t\t\tif (!keyHelds.includes(e.code) && (isMetaKey || !(e.ctrlKey || e.metaKey || e.altKey)))\n\t\t\t\tkeyHelds.push(e.code);\n\n\t\t\t// Prevent default behavior for shortcuts without a built in event.\n\t\t\t// This still allows copy & paste events to bubble through to our listeners,\n\t\t\t// but for example it prevents Ctrl+A from selecting all text on the page.\n\t\t\tif (manual_shortcuts.includes(e.code) && (e.ctrlKey || e.metaKey)) e.preventDefault();\n\n\t\t\tif (e.key === 'Tab') e.preventDefault(); // Prevents the default tabbing behavior of cycling through elements on the page.\n\t\t}) as EventListener);\n\n\t\t// This listener is placed on the document so we don't miss mouseup events if the user lifts their mouse off the element.\n\t\taddListener(element, 'keyup', ((e: KeyboardEvent): void => {\n\t\t\t// console.log(\"Key up: \", e.code);\n\t\t\tatleastOneInputThisFrame = true;\n\t\t\tconst downIndex = keyDowns.findIndex((keyInfo) => keyInfo.keyCode === e.code);\n\t\t\tif (downIndex !== -1) keyDowns.splice(downIndex, 1);\n\n\t\t\tconst heldIndex = keyHelds.indexOf(e.code);\n\t\t\tif (heldIndex !== -1) keyHelds.splice(heldIndex, 1);\n\t\t}) as EventListener);\n\n\t\twindow.addEventListener('blur', function () {\n\t\t\t// Clear all keys being held, as when the window isn't in focus, we don't hear the key-up events.\n\t\t\t// So if we held down the shift key, then click off, then let go,\n\t\t\t// the game would CONTINUOUSLY keep zooming in without you pushing anything,\n\t\t\t// and you'd have to push the shift again to cancel it.\n\t\t\tkeyHelds.length = 0;\n\t\t});\n\t}\n\n\t// Return the InputListener object ---------------------------------------------------------------------------\n\n\treturn {\n\t\telement,\n\t\tatleastOneInput: (): boolean => atleastOneInputThisFrame,\n\t\tisMouseDown: (button: MouseButton): boolean => clickInfo[button].isDown ?? false,\n\t\tclaimMouseDown: (button: MouseButton): void => {\n\t\t\t// console.error(\"Claiming mouse down: \", MouseNames[button]);\n\t\t\tclickInfo[button].isDown = false;\n\t\t\t// Also remove the pointer from the list of pointers down this frame.\n\t\t\tconst pointerId = clickInfo[button].pointerId;\n\t\t\tconst index = pointersDown.indexOf(pointerId!);\n\t\t\t// console.error(\"Claiming pointer down1: \", pointerId);\n\t\t\tif (index !== -1) pointersDown.splice(index, 1);\n\t\t},\n\t\tclaimPointerDown: (pointerId: string): void => {\n\t\t\t// console.error(\"Claiming pointer down: \", pointerId);\n\t\t\tconst index = pointersDown.indexOf(pointerId);\n\t\t\tif (index === -1)\n\t\t\t\tthrow Error(\"Can't claim pointer down. Already claimed, or is not down.\");\n\t\t\t// console.error(\"Claiming pointer down2: \", pointerId);\n\t\t\tpointersDown.splice(index, 1);\n\t\t\t// Also claim the mouse down if this pointer is the most recent pointer that performed that action.\n\t\t\tObject.values(clickInfo).forEach((buttonInfo) => {\n\t\t\t\tif (buttonInfo.pointerId === pointerId) buttonInfo.isDown = false;\n\t\t\t});\n\t\t},\n\t\tclaimMouseClick: (button: MouseButton): void => {\n\t\t\t// console.error(\"Claiming mouse click: \", MouseNames[button]);\n\t\t\tclickInfo[button].clicked = false;\n\t\t\t// console.error(\"Claiming mouse click: \", MouseNames[button]);\n\t\t},\n\t\tcancelMouseClick: (button: MouseButton): number =>\n\t\t\t(clickInfo[button].timeDownMillisHistory.length = 0),\n\t\tisMouseHeld: (button: MouseButton): boolean => clickInfo[button].isHeld ?? false,\n\t\tisMouseTouch: (button: MouseButton): boolean => clickInfo[button].isTouch,\n\t\tisPointerTouch: (pointerId: string): boolean =>\n\t\t\tlogicalPointers[pointerId]?.physical.isTouch ?? false,\n\t\tgetMouseId: (button: MouseButton): string | undefined => clickInfo[button].pointerId,\n\t\tgetMousePhysicalId: (button: MouseButton): string | undefined =>\n\t\t\tclickInfo[button].physicalId,\n\t\tgetMousePosition: (button: MouseButton): DoubleCoords | undefined => {\n\t\t\tconst logicalId = clickInfo[button].pointerId;\n\t\t\tif (!logicalId) return undefined;\n\t\t\tconst logicalPointer = logicalPointers[logicalId];\n\t\t\t/**\n\t\t\t * A. Pointer exists => Return its current position. (It may not exist anymore if it was a finger that has since lifted)\n\t\t\t * B. Pointer does not exist => Return its last known position since it simulated an UP/DOWN mouse click.\n\t\t\t */\n\t\t\tif (logicalPointer) {\n\t\t\t\t// Pointer is still held, get its live position.\n\t\t\t\treturn logicalPointer.physical.position;\n\t\t\t} else {\n\t\t\t\t// Pointer has been lifted, return its last known position.\n\t\t\t\treturn clickInfo[button].position;\n\t\t\t}\n\t\t},\n\t\tisMouseClicked: (button: MouseButton): boolean => clickInfo[button].clicked,\n\t\tisMouseDoubleClickDragged: (button: MouseButton): boolean =>\n\t\t\tclickInfo[button].doubleClickDrag,\n\t\tsetTreatLeftasRight: (value: boolean): boolean => (treatLeftAsRight = value),\n\t\tgetPointerPos: (pointerId: string): DoubleCoords | undefined =>\n\t\t\tlogicalPointers[pointerId]?.physical.position,\n\t\tgetPhysicalPointerPos: (pointerId: string): DoubleCoords | undefined =>\n\t\t\tphysicalPointers[pointerId]?.position,\n\t\tgetPhysicalPointerIdOfPointer: (pointerId: string): string | undefined =>\n\t\t\tlogicalPointers[pointerId]?.physical.id,\n\t\tgetPhysicalPointerDelta: (physicalPointerId: string): DoubleCoords | undefined =>\n\t\t\tphysicalPointers[physicalPointerId]?.delta,\n\t\tgetPointerVel: (pointerId: string): DoubleCoords | undefined =>\n\t\t\tlogicalPointers[pointerId]?.physical.velocity,\n\t\tgetAllPointers: (button: MouseButton): string[] =>\n\t\t\tObject.values(logicalPointers)\n\t\t\t\t.filter((p) => p.button === button)\n\t\t\t\t.map((p) => p.id), // Filter out the ones not for the button action, and map to ids\n\t\tgetAllTouchPointers: (): string[] =>\n\t\t\tObject.values(logicalPointers)\n\t\t\t\t.filter((p) => p.physical.isTouch)\n\t\t\t\t.map((p) => p.id), // Filter out the non-touch ones, and map to ids\n\t\tgetAllPhysicalPointers: (): string[] => Object.keys(physicalPointers),\n\t\tisPointerHeld: (pointerId: string): boolean => logicalPointers[pointerId] !== undefined,\n\t\tpointerExists: (pointerId: string): boolean => logicalPointers[pointerId] !== undefined,\n\t\tgetPointersDown: (button: MouseButton): string[] =>\n\t\t\tpointersDown.filter((id) => logicalPointers[id]!.button === button), // Filter out the ones not for the button action\n\t\tgetTouchPointersDown: (): string[] =>\n\t\t\tpointersDown.filter((id) => logicalPointers[id]!.physical.isTouch),\n\t\tgetPointersDownCount: (): number => pointersDown.length,\n\t\tdoesPointerBelongToPhysicalPointer: (\n\t\t\tlogicalPointerId: string,\n\t\t\tphysicalPointerId: string,\n\t\t): boolean => {\n\t\t\tconst logicalPointer = logicalPointers[logicalPointerId];\n\t\t\tif (!logicalPointer) return false;\n\t\t\treturn logicalPointer.physical === physicalPointers[physicalPointerId];\n\t\t},\n\t\tgetWheelDelta: (): number => wheelDelta,\n\t\tisKeyDown: (\n\t\t\tkeyCode: string,\n\t\t\trequireMetaKey?: boolean,\n\t\t\trequireShiftKey?: boolean,\n\t\t): boolean => {\n\t\t\treturn keyDowns.some(\n\t\t\t\t(keyInfo) =>\n\t\t\t\t\tkeyInfo.keyCode === keyCode &&\n\t\t\t\t\t(!requireMetaKey || keyInfo.metaKey) &&\n\t\t\t\t\t(!requireShiftKey || keyInfo.shiftKey),\n\t\t\t);\n\t\t},\n\t\tisKeyHeld: (keyCode: string): boolean => keyHelds.includes(keyCode),\n\t\tclaimKey: (keyCode: string): void => {\n\t\t\tconst index = keyDowns.findIndex((k) => k.keyCode === keyCode);\n\t\t\tif (index !== -1) keyDowns.splice(index, 1);\n\t\t},\n\t\tremoveEventListeners: (): void => {\n\t\t\tObject.keys(eventHandlers).forEach((eventType) => {\n\t\t\t\tconst { target, handler } = eventHandlers[eventType]!;\n\t\t\t\ttarget.removeEventListener(eventType, handler);\n\t\t\t});\n\t\t\tconsole.log('Closed event listeners of Input Listener');\n\t\t},\n\t};\n}\n\n/** Generates the unique PHYSICAL pointer id for the mouse or touch event. */\nfunction getPhysicalPointerId(e: MouseEvent | Touch): string {\n\tconst mouseEvent = e instanceof MouseEvent; // CAN'T USE instanceof Touch because it's not defined in Safari!\n\treturn mouseEvent ? 'mouse' : e.identifier.toString();\n}\n\n/** Generates the unique pointer id for the mouse or touch event and button action. */\nfunction getLogicalPointerId(e: MouseEvent | Touch, button: MouseButton): string {\n\tconst mouseEvent = e instanceof MouseEvent; // CAN'T USE instanceof Touch because it's not defined in Safari!\n\treturn mouseEvent ? `mouse_${MouseNames[button]}` : e.identifier.toString();\n}\n\n/**\n * Converts the mouse coordinates to be relative to the\n * element bounding box instead of absolute to the whole page.\n */\nfunction getRelativeMousePosition(\n\tcoords: DoubleCoords,\n\telement: HTMLElement | typeof document,\n): DoubleCoords {\n\tif (element instanceof Document) return coords; // No need to adjust if we're listening on the document.\n\tconst rect = element.getBoundingClientRect();\n\treturn [coords[0] - rect.left, coords[1] - rect.top];\n}\n\nexport { Mouse, CreateInputListener };\n\nexport default {\n\tgetRelativeMousePosition,\n};\n\nexport type { InputListener, MouseButton };\n"
  },
  {
    "path": "src/client/scripts/esm/game/main.ts",
    "content": "// src/client/scripts/esm/game/main.ts\n\n/*\n * This is the main script. This is where the game begins running.\n\n * This initiates the gl context, calls for the initiating of the shader programs, camera,\n * and input listeners, and begins the game loop.\n */\n\nimport game from './chess/game.js';\nimport webgl from './rendering/webgl.js';\nimport camera from './rendering/camera.js';\nimport socketman from './websocket/socketman.js';\nimport IndexedDB from '../util/IndexedDB.js';\nimport maskedDraw from '../webgl/maskedDraw.js';\nimport guiloading from './gui/guiloading.js';\nimport LocalStorage from '../util/LocalStorage.js';\nimport frametracker from './rendering/frametracker.js';\nimport loadbalancer from './misc/loadbalancer.js';\nimport socketmessages from './websocket/socketmessages.js';\nimport frameratelimiter from './rendering/frameratelimiter.js';\n\n// Starts the game. Runs automatically once the page is loaded.\nfunction start(): void {\n\tguiloading.closeAnimation(); // Stops the loading screen animation\n\twebgl.init(); // Initiate the WebGL context. This is our web-based render engine.\n\tcamera.init(); // Initiates the matrixes (uniforms) of our shader programs: viewMatrix (Camera), projMatrix (Projection), modelMatrix (world translation)\n\n\tgame.init();\n\n\tinitListeners();\n\n\t// Immediately asks the server if we are in a game.\n\t// If so, it will send the info to join it.\n\tsocketmessages.send('game', 'joingame');\n\n\t// Update & draw the scene repeatedly\n\tframeratelimiter.requestFrame(gameLoop);\n}\n\nfunction initListeners(): void {\n\twindow.addEventListener('beforeunload', (_event) => {\n\t\t// console.log('Detecting unload');\n\n\t\t// This allows us to control the reason why the socket was closed.\n\t\t// \"1000 Closed by client\" instead of \"1001 Endpoint left\"\n\t\tsocketman.closeSocket();\n\n\t\tLocalStorage.eraseExpiredItems();\n\t\tIndexedDB.eraseExpiredItems();\n\t});\n}\n\n/** The main game loop. Called every frame. */\nfunction gameLoop(runtime: number): void {\n\tloadbalancer.update(runtime); // Updates fps, delta time, etc..\n\n\tgame.update(); // Always update the game, even if we're afk. By FAR this is less resource intensive than rendering!\n\n\trender(); // Render everything\n\n\t// Reset all event listeners states so we can catch any new events that happen for the next frame.\n\tdocument.dispatchEvent(new Event('reset-listener-events'));\n\n\t// Loop again while app is running.\n\tframeratelimiter.requestFrame(gameLoop);\n}\n\nfunction render(): void {\n\tif (!frametracker.doWeRenderNextFrame()) return; // Only render the world though if any visual on the screen changed! This is to save cpu when there's no page interaction or we're afk.\n\n\t// console.log(\"Rendering this frame\");\n\n\twebgl.clearScreen(); // Clear the color buffer and depth buffers\n\tmaskedDraw.onFrameStart(); // Reset stencil bit-pair index for this frame\n\tgame.render();\n\n\tframetracker.onFrameRender();\n}\n\nglobalThis.main = { start };\n"
  },
  {
    "path": "src/client/scripts/esm/game/misc/controls.ts",
    "content": "// src/client/scripts/esm/game/misc/controls.ts\n\n/**\n * This script controls the board navigation\n * via the WASD keys, space/shift, and mouse wheel.\n */\n\nimport type { Mesh } from '../rendering/piecemodels.js';\nimport type { FullGame } from '../../../../../shared/chess/logic/gamefile.js';\nimport type { DoubleCoords } from '../../../../../shared/chess/util/coordutil.js';\n\nimport jsutil from '../../../../../shared/util/jsutil.js';\nimport vectors from '../../../../../shared/util/math/vectors.js';\n\nimport toast from '../gui/toast.js';\nimport stats from '../gui/stats.js';\nimport mouse from '../../util/mouse.js';\nimport camera from '../rendering/camera.js';\nimport docutil from '../../util/docutil.js';\nimport guipause from '../gui/guipause.js';\nimport copygame from '../chess/copygame.js';\nimport boardpos from '../rendering/boardpos.js';\nimport socketman from '../websocket/socketman.js';\nimport boarddrag from '../rendering/boarddrag.js';\nimport selection from '../chess/selection.js';\nimport animation from '../rendering/animation.js';\nimport miniimage from '../rendering/miniimage.js';\nimport Transition from '../rendering/transitions/Transition.js';\nimport enginegame from './enginegame.js';\nimport perspective from '../rendering/perspective.js';\nimport piecemodels from '../rendering/piecemodels.js';\nimport guigameinfo from '../gui/guigameinfo.js';\nimport boardeditor from '../boardeditor/boardeditor.js';\nimport loadbalancer from './loadbalancer.js';\nimport guipromotion from '../gui/guipromotion.js';\nimport guinavigation from '../gui/guinavigation.js';\nimport { listener_document } from '../chess/game.js';\nimport specialrighthighlights from '../rendering/highlights/specialrighthighlights.js';\n\n// Constants -------------------------------------------------------------------\n\n/** The accelleration/deceleration rate of the board velocity in 2D mode. */\nconst panAccel2D: number = 145; // Default: 145\n/** The accelleration/deceleration rate of the board velocity in 3D mode. */\nconst panAccel3D: number = 75; // Default: 75\n\n/** The acceleration/deration rate of the board SCALE velocity in 2D mode. */\nconst scaleAccel_Desktop: number = 6.0; // Acceleration of board scaling   Default: 6\n/**\n * The deceleration rate of the board SCALE velocity in 3D mode.\n * (No accerlation, scale velocity is determined by finger movement)\n */\nconst scaleAccel_Mobile: number = 14.0; // Acceleration of board scaling   Default: 14\n\n/**\n * This is the scale velocity cap when using Space/Shift.\n * It is NOT the absoulte cap which you can reach by scrolling.\n */\nconst scaleVelCap = 2.0; // Default: 1\n\n/** The scale velocity cap when u sing the scroll wheel (higher). */\nconst scaleVelCap_Scroll = 2.5;\n\n/** Dampener multiplied to the wheel delta before applying it to the scale velocity. */\nconst wheelMultiplier = 0.015; // Default: 0.015\n\n// Panning & Zooming Controls WASD/Space/Shift/Wheel ------------------------------------------------------\n\n// Called from game.updateBoard()\nfunction updateNavControls(): void {\n\tif (guipause.areWePaused()) return; // Exit if paused\n\n\tboarddrag.checkIfBoardDropped(); // Needs to be before exiting from teleporting\n\n\tif (Transition.areTransitioning()) return; // Exit if teleporting\n\n\t// Keyboard\n\tdetectPanning(); // Movement (WASD)\n\tdetectZooming(); // Zoom/Scale (Space shift, mouse wheel)\n}\n\n/** Detects WASD controls, updating board velocity accordingly. */\nfunction detectPanning(): void {\n\tif (boarddrag.isBoardDragging()) return; // Only pan if we aren't dragging the board\n\n\tlet panVel = boardpos.getPanVel();\n\n\tlet panning = false; // Any panning key pressed this frame?\n\tif (!guipromotion.isUIOpen()) {\n\t\t// Disable the controls temporarily\n\t\tif (listener_document.isKeyHeld('KeyD')) {\n\t\t\tpanning = true;\n\t\t\taccelPanVel(panVel, 0);\n\t\t}\n\t\tif (listener_document.isKeyHeld('KeyA')) {\n\t\t\tpanning = true;\n\t\t\taccelPanVel(panVel, 180);\n\t\t}\n\t\tif (listener_document.isKeyHeld('KeyW')) {\n\t\t\tpanning = true;\n\t\t\taccelPanVel(panVel, 90);\n\t\t}\n\t\tif (listener_document.isKeyHeld('KeyS')) {\n\t\t\tpanning = true;\n\t\t\taccelPanVel(panVel, -90);\n\t\t}\n\t}\n\n\tif (panning) {\n\t\t// Make sure the velocity doesn't exceed the cap\n\t\tconst hyp = Math.hypot(...panVel);\n\t\tconst relativePanVelCap = boardpos.getRelativePanVelCap();\n\t\tconst ratio = hyp / relativePanVelCap;\n\t\tif (ratio > 1) {\n\t\t\t// Too fast, divide components by the ratio to cap our velocity\n\t\t\tpanVel[0] /= ratio;\n\t\t\tpanVel[1] /= ratio;\n\t\t}\n\t} else {\n\t\tpanVel = deccelPanVel(panVel);\n\t}\n\n\tboardpos.setPanVel(panVel); // Set the pan velocity\n}\n\n/** Accelerates the given pan velocity in the provided vector direction. */\nfunction accelPanVel(panVel: DoubleCoords, angleDegs: number): DoubleCoords {\n\tconst baseAngle = -perspective.getRotZ();\n\tconst dirOfTravel = baseAngle + angleDegs;\n\tconst angleRad = vectors.degreesToRadians(dirOfTravel);\n\tconst XYComponents: DoubleCoords = vectors.getXYComponents_FromAngle(angleRad);\n\tconst accelToUse = perspective.getEnabled() ? panAccel3D : panAccel2D;\n\tpanVel[0] += loadbalancer.getDeltaTime() * accelToUse * XYComponents[0];\n\tpanVel[1] += loadbalancer.getDeltaTime() * accelToUse * XYComponents[1];\n\treturn panVel;\n}\n\n/** Deccelerates the given pan velocity towards zero, without skipping past it. */\nfunction deccelPanVel(panVel: DoubleCoords): DoubleCoords {\n\tif (panVel[0] === 0 && panVel[1] === 0) return panVel; // Already stopped\n\n\tconst rateToUse = perspective.getEnabled() ? panAccel3D : panAccel2D;\n\n\tconst hyp = Math.hypot(...panVel);\n\tconst newHyp = hyp - loadbalancer.getDeltaTime() * rateToUse;\n\tif (newHyp < 0) return [0, 0]; // Stop completely before we start going in the opposite direction\n\n\tconst ratio = newHyp / hyp;\n\n\tconst newPanVel: DoubleCoords = [panVel[0] * ratio, panVel[1] * ratio];\n\n\treturn newPanVel;\n}\n\n/** Detects Space/Shift/Wheel controls, updating board SCALE velocity accordingly. */\nfunction detectZooming(): void {\n\tlet scaleVel = boardpos.getScaleVel();\n\n\tlet scaling = false;\n\tlet scrolling = false;\n\tif (!guipromotion.isUIOpen()) {\n\t\t// Disable the controls temporarily\n\t\t// Space/Shift\n\t\tif (listener_document.isKeyHeld('Space')) {\n\t\t\tscaling = true;\n\t\t\tscaleVel -= loadbalancer.getDeltaTime() * scaleAccel_Desktop;\n\t\t}\n\t\tif (listener_document.isKeyHeld('ShiftLeft')) {\n\t\t\tscaling = true;\n\t\t\tscaleVel += loadbalancer.getDeltaTime() * scaleAccel_Desktop;\n\t\t}\n\t\t// Mouse wheel\n\t\tconst wheelDelta = mouse.getWheelDelta();\n\t\tif (wheelDelta !== 0) {\n\t\t\tscaling = true;\n\t\t\tscrolling = true;\n\t\t\tscaleVel -= wheelMultiplier * wheelDelta;\n\t\t}\n\t}\n\n\tif (scaling) {\n\t\t// Cap the velocity\n\t\tconst capToUse = scrolling ? scaleVelCap_Scroll : scaleVelCap;\n\t\tif (scaleVel > capToUse) scaleVel = capToUse;\n\t\telse if (scaleVel < -capToUse) scaleVel = -capToUse;\n\t} else {\n\t\tscaleVel = deccelerateScaleVel(scaleVel);\n\t}\n\n\tboardpos.setScaleVel(scaleVel);\n}\n\n/** Deccelerates the given scale velocity towards zero, without skipping past it. */\nfunction deccelerateScaleVel(scaleVel: number): number {\n\tif (scaleVel === 0) return scaleVel; // Already stopped\n\n\tconst deccelerationToUse = docutil.isMouseSupported() ? scaleAccel_Desktop : scaleAccel_Mobile;\n\n\tif (scaleVel > 0) {\n\t\tscaleVel -= loadbalancer.getDeltaTime() * deccelerationToUse;\n\t\tif (scaleVel < 0) scaleVel = 0;\n\t} else {\n\t\t// scaleVel < 0\n\t\tscaleVel += loadbalancer.getDeltaTime() * deccelerationToUse;\n\t\tif (scaleVel > 0) scaleVel = 0;\n\t}\n\n\treturn scaleVel;\n}\n\n// Toggles ---------------------------------------------------------------------------------\n\n/** Debug toggles that are not only for in a game, but outside. */\nfunction testOutGameToggles(): void {\n\tif (listener_document.isKeyDown('Backquote')) camera.toggleDebug();\n\tif (listener_document.isKeyDown('Digit4')) socketman.toggleDebug(); // Adds simulated websocket latency with high ping\n\tif (listener_document.isKeyDown('Digit7')) enginegame.toggleDebug(); // Render engine generated legal moves\n\tif (listener_document.isKeyDown('KeyM')) stats.toggleFPS();\n}\n\n/** Debug toggles that are only for in a game. */\nfunction testInGameToggles(gamefile: FullGame, mesh: Mesh | undefined): void {\n\tif (listener_document.isKeyDown('Escape')) guipause.toggle();\n\n\tif (listener_document.isKeyDown('Digit1')) selection.toggleEditMode(); // EDIT MODE TOGGLE\n\tif (listener_document.isKeyDown('Digit2')) {\n\t\tconsole.log(jsutil.deepCopyObject(gamefile));\n\t\tconsole.log('Estimated gamefile memory usage: ' + jsutil.estimateMemorySizeOf(gamefile));\n\t}\n\tif (listener_document.isKeyDown('Digit3')) animation.toggleDebug(); // Each animation slows down and renders continuous ribbon\n\tif (listener_document.isKeyDown('Digit5')) copygame.copyGame(true); // Copies the gamefile as a single position, without all the moves.\n\tif (listener_document.isKeyDown('Digit6')) specialrighthighlights.toggle(); // Highlights special rights and en passant\n\n\tif (listener_document.isKeyDown('Tab')) guipause.callback_ToggleArrows();\n\tif (mesh && listener_document.isKeyDown('KeyR')) {\n\t\tpiecemodels.regenAll(gamefile.boardsim, mesh);\n\t\ttoast.show('Regenerated piece models.', { durationMultiplier: 0.5 });\n\t}\n\tif (listener_document.isKeyDown('KeyN')) {\n\t\tguinavigation.toggle();\n\t\tif (!boardeditor.areInBoardEditor()) guigameinfo.toggle();\n\t}\n\tif (listener_document.isKeyDown('KeyP')) miniimage.toggle();\n\n\tguinavigation.update();\n}\n\n// Exports ---------------------------------------------------------------------------------\n\nexport default {\n\tupdateNavControls,\n\ttestOutGameToggles,\n\ttestInGameToggles,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/misc/enginegame.ts",
    "content": "// src/client/scripts/esm/game/misc/enginegame.ts\n\n// This module keeps track of the data of the engine game we are currently in.\n\nimport type { Player } from '../../../../../shared/chess/util/typeutil.js';\n\nimport jsutil from '../../../../../shared/util/jsutil.js';\nimport movevalidation from '../../../../../shared/chess/logic/movevalidation.js';\nimport gamefileutility from '../../../../../shared/chess/util/gamefileutility.js';\nimport typeutil, { players as p } from '../../../../../shared/chess/util/typeutil.js';\nimport coordutil, { Coords, CoordsKey } from '../../../../../shared/chess/util/coordutil.js';\n\nimport toast from '../gui/toast.js';\nimport gameslot from '../chess/gameslot.js';\nimport premoves from '../chess/premoves.js';\nimport boardpos from '../rendering/boardpos.js';\nimport snapping from '../rendering/highlights/snapping.js';\nimport selection from '../chess/selection.js';\nimport perspective from '../rendering/perspective.js';\nimport drawsquares from '../rendering/highlights/annotations/drawsquares.js';\nimport { GameBus } from '../GameBus.js';\nimport movesequence from '../chess/movesequence.js';\nimport frametracker from '../rendering/frametracker.js';\nimport gamecompressor from '../chess/gamecompressor.js';\nimport squarerendering from '../rendering/highlights/squarerendering.js';\nimport checkmatepractice from '../chess/checkmatepractice.js';\n\n// Types ------------------------------------------------------------------------\n\ninterface EngineConfig {\n\t/** Hard time limit for the engine to think in milliseconds */\n\tengineTimeLimitPerMoveMillis: number;\n\t// If you are using a checkmate practice engine, this is required.\n\tcheckmateSelectedID?: string;\n\tstrengthLevel?: number;\n\tmultiPv?: number;\n}\n\n// Variables --------------------------------------------------------------------\n\n/** Whether we are currently in an engine game. */\nlet inEngineGame: boolean = false;\nlet ourColor: Player | undefined;\nlet engineColor: Player | undefined;\nlet currentEngine: string | undefined; // name of the current engine used\nlet engineConfig: EngineConfig | undefined; // json that is sent to the engine, giving it extra config information\nlet engineWorker: Worker | undefined;\n\n/** Whether to render the engine's generated legal moves for debugging purposes. Toggled by pressing `7`. */\nlet move_gen_debug: boolean = false;\n/** Stores the legal moves generated by the engine for each move index in the game history. */\nconst moveHistoryLegalMoves: Map<number, string[]> = new Map();\n/** Queue of pending debug requests with their move indices */\nconst pendingDebugRequests: number[] = [];\n\n// Events -----------------------------------------------------------------------\n\nGameBus.addEventListener('user-move-played', () => {\n\tonMovePlayed();\n});\nGameBus.addEventListener('game-concluded', () => {\n\tif (!inEngineGame) return;\n\tcheckmatepractice.onEngineGameConclude();\n});\n\n// Functions ------------------------------------------------------------------------\n\nfunction areInEngineGame(): boolean {\n\treturn inEngineGame;\n}\n\nfunction getOurColor(): Player | undefined {\n\tif (!inEngineGame) throw Error('Cannot get our color if we are not in an engine game!');\n\treturn ourColor!;\n}\n\nfunction isItOurTurn(): boolean {\n\tif (!inEngineGame)\n\t\tthrow Error(\"Cannot get isItOurTurn of engine game when we're not in an engine game.\");\n\treturn gameslot.getGamefile()!.basegame.whosTurn === ourColor;\n}\n\nfunction getCurrentEngine(): string | undefined {\n\treturn currentEngine;\n}\n\n/**\n * Inits an engine game. In particular, it needs gameOptions in order to know what engine to use for this enginegame.\n * This method launches an engine webworker for the current game.\n * @param {Object} options - An object that contains the properties `currentEngine` and `engineConfig`\n */\nfunction initEngineGame(options: {\n\tyouAreColor: Player;\n\tcurrentEngine: string;\n\tengineConfig: EngineConfig;\n}): Promise<void> {\n\tconsole.log(`Starting engine game with engine \"${options.currentEngine}\".`);\n\n\tinEngineGame = true;\n\tourColor = options.youAreColor;\n\tengineColor = typeutil.invertPlayer(ourColor);\n\tcurrentEngine = options.currentEngine;\n\tengineConfig = options.engineConfig;\n\n\t// Initialize the engine as a webworker\n\tif (!window.Worker) {\n\t\talert(\"Your browser doesn't support web workers. Cannot play against an engine.\");\n\t\t// Reject the promise returned by this function\n\t\treturn Promise.reject(\n\t\t\tnew Error(\"Cannot finish loading engine game because web workers aren't supported.\"),\n\t\t);\n\t}\n\tengineWorker = new Worker(`../scripts/esm/game/chess/engines/${currentEngine}.js`, {\n\t\ttype: 'module',\n\t}); // module type allows the web worker to import methods and types from other scripts.\n\n\t// Return a promise that resolves when the ENGINE WORKER has finished fetching/loading.\n\treturn new Promise<void>((resolve, reject): void => {\n\t\t// Set up a handler for the 'isready' command that indicates the worker is loaded and ready\n\t\t// We have to manually send this message at the top of our engines.\n\t\tengineWorker!.onmessage = (e: MessageEvent): void => {\n\t\t\tif (e.data === 'readyok') resolve(); // Engine is ready!\n\t\t};\n\t\tengineWorker!.onerror = (e: ErrorEvent): void => {\n\t\t\treject(new Error('Worker failed to load: ' + e.message));\n\t\t};\n\t}).then((_result: any) => {\n\t\t// After the promise resolves, we know the worker is ready\n\t\t// Overwrite the onmessage listener to listen for move submissions\n\t\tengineWorker!.onmessage = (e: MessageEvent): void => handleEngineMessage(e.data);\n\t\t// Remove the error handler (no longer needed after worker is ready)\n\t\tengineWorker!.onerror = null;\n\t\t// Ensures if the debug mode was on before starting an engine game,\n\t\t// the engine generated legal moves are rendered as soon as the engine is ready.\n\t\trequestMovesForCurrentPosition();\n\t});\n}\n\n// Call when we leave an engine game\nfunction closeEngineGame(): void {\n\tinEngineGame = false;\n\tourColor = undefined;\n\tengineColor = undefined;\n\tcurrentEngine = undefined;\n\tengineConfig = undefined;\n\tmoveHistoryLegalMoves.clear();\n\tpendingDebugRequests.length = 0;\n\tperspective.resetRotations(); // Without this, leaving an engine game of which we were black, won't reset our rotation.\n\n\t// terminate the webworker\n\tif (engineWorker) engineWorker.terminate();\n\tengineWorker = undefined;\n\tcheckmatepractice.onGameUnload();\n}\n\n/**\n * Tests if we are this color in the engine game.\n * @param color - p.WHITE / p.BLACK\n * @returns *true* if we are that color.\n */\nfunction areWeColor(color: Player): boolean {\n\treturn color === ourColor;\n}\n\n/**\n * This method is called externally when the player submits his move in an engine game\n * It submits the gamefile to the webworker\n */\nfunction onMovePlayed(): void {\n\tif (!inEngineGame) return; // Don't do anything if it's not an engine game\n\tconst gamefile = gameslot.getGamefile()!;\n\t// Make sure it's the engine's turn\n\tif (gamefile.basegame.whosTurn !== engineColor) return; // Don't do anything if it's our turn (not the engines)\n\tcheckmatepractice.registerHumanMove(); // inform the checkmatepractice script that the human player has made a move\n\tif (gamefile.basegame.gameConclusion) return; // Don't do anything if the game is over\n\n\trequestMovesForCurrentPosition(); // Request generated moves for debugging FIRST\n\n\t// Request the engine to perform a best move calculation...\n\n\tconst longformIn = gamecompressor.compressGamefile(gamefile); // Compress the gamefile to send to the engine in a simpler json format\n\t// Send the gamefile to the engine web worker\n\t/** This has all nested functions removed. */\n\tconst stringGamefile = JSON.stringify(gamefile, jsutil.stringifyReplacer);\n\n\t// Derive clock times for both colors in milliseconds, similar to UCI wtime/btime/winc/binc\n\tlet wtime: number | undefined;\n\tlet btime: number | undefined;\n\tlet winc: number | undefined;\n\tlet binc: number | undefined;\n\tconst basegame = gamefile.basegame;\n\tconst clocks = basegame.clocks;\n\tif (!basegame.untimed && clocks) {\n\t\twtime = clocks.currentTime[p.WHITE];\n\t\tbtime = clocks.currentTime[p.BLACK];\n\t\tconst incSeconds = clocks.startTime.increment;\n\t\twinc = incSeconds * 1000;\n\t\tbinc = incSeconds * 1000;\n\t}\n\n\t// prettier-ignore\n\tconst timing = wtime !== undefined && btime !== undefined ? {\n\t\twtime,\n\t\tbtime,\n\t\twinc,\n\t\tbinc,\n\t} : undefined;\n\n\tif (engineWorker)\n\t\tengineWorker.postMessage({\n\t\t\tstringGamefile,\n\t\t\tlf: longformIn,\n\t\t\tengineConfig: engineConfig,\n\t\t\tyouAreColor: engineColor,\n\t\t\twtime: timing?.wtime,\n\t\t\tbtime: timing?.btime,\n\t\t\twinc: timing?.winc,\n\t\t\tbinc: timing?.binc,\n\t\t});\n\telse console.error('User made a move in an engine game but no engine webworker is loaded!');\n}\n\nfunction handleEngineMessage(data: any): void {\n\t// console.log('Received message from engine worker:', data);\n\n\tif (typeof data !== 'object' || data === null) {\n\t\tconsole.error('Received invalid message from engine worker:', data);\n\t\treturn;\n\t}\n\n\t// Check if the message contains generated moves for debugging\n\tif (data.type === 'move') {\n\t\t// Message contains the engine's best move suggestion\n\t\tmakeEngineMove(data.data);\n\t} else if (data.type === 'generatedMoves') {\n\t\t// Store the moves at the oldest pending request's move index\n\t\tif (pendingDebugRequests.length > 0) {\n\t\t\tconst requestedMoveIndex = pendingDebugRequests.shift()!;\n\t\t\tmoveHistoryLegalMoves.set(requestedMoveIndex, [...data.data]);\n\t\t}\n\t\t// console.log('Received generated moves from engine worker:', data.data);\n\t\tframetracker.onVisualChange(); // Ensure the frame is rendered\n\t} else {\n\t\tconsole.error('Received unknown message from engine worker:', data);\n\t}\n}\n\n/**\n * This method takes care of all the logic involved in making an engine move\n * It gets called after the engine finishes its calculation\n * @param move - The move that SHOULD be a string in compact format \"x,y>x,y=P\"\n */\nfunction makeEngineMove(tokenMove: unknown): void {\n\tif (!inEngineGame) return;\n\tif (!currentEngine)\n\t\treturn console.error('Attempting to make engine move, but no engine loaded!');\n\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh();\n\n\tif (tokenMove === null) {\n\t\t// Null can mean the engine didn't return a best move (perhaps it didn't\n\t\t// find any legal moves, or thought it was checkmate), or an error occurred.\n\t\t// In this case, resign for the engine.\n\t\tconsole.log(`Engine returned a null move. Resigning the game...`);\n\t\tgamefileutility.setConclusion(gamefile.basegame, {\n\t\t\tcondition: 'resignation',\n\t\t\tvictor: ourColor!,\n\t\t});\n\t\tgameslot.concludeGame();\n\t\treturn;\n\t}\n\n\tpremoves.performWithUnapplied(gamefile, mesh, () => {\n\t\tconst moveValidationResults = movevalidation.isTokenMoveLegal(gamefile, tokenMove);\n\n\t\tif (!moveValidationResults.valid) {\n\t\t\ttoast.show(\n\t\t\t\t`Engine submitted an illegal move. Please report this bug! Move \"${tokenMove}\" is illegal for reason: ${moveValidationResults.reason}`,\n\t\t\t\t{ error: true, durationMultiplier: 100 },\n\t\t\t);\n\t\t\treturn false; // Don't physically play next premove\n\t\t}\n\n\t\t// Go to latest move before making a new move\n\t\tmovesequence.viewFront(gamefile, mesh);\n\n\t\tmovesequence.makeMoveAndAnimate(gamefile, mesh, moveValidationResults.tagged);\n\n\t\tcheckmatepractice.registerEngineMove(); // inform the checkmatepractice script that the engine has made a move\n\n\t\t// If the debug mode is on, request the generated moves for the new position after playing the engine's move\n\t\trequestMovesForCurrentPosition();\n\n\t\treturn true; // Good to physically play next premove\n\t});\n\n\tselection.reselectPiece(); // Reselect the currently selected piece. Recalc its moves and recolor it if needed.\n}\n\n/** Toggles the rendering of engine generated legal moves for debugging purposes. */\nfunction toggleDebug(): void {\n\tmove_gen_debug = !move_gen_debug;\n\ttoast.show(`Toggled engine move gen highlights: ${move_gen_debug}`);\n\n\tif (!move_gen_debug)\n\t\tpendingDebugRequests.length = 0; // Turning off: Clear pending requests.\n\telse requestMovesForCurrentPosition(); // Turning on: Request moves for current position.\n}\n\n/** Callback for enginegame actions when a new local move is viewed. */\nfunction onViewMove(): void {\n\t// Request the move gen for the current ply, if debug mode is on\n\trequestMovesForCurrentPosition();\n}\n\n/**\n * Requests legal moves for the currently viewed position if not already cached.\n * Should be called when navigating through move history with debug mode on.\n */\nfunction requestMovesForCurrentPosition(): void {\n\tif (!inEngineGame || !move_gen_debug) return;\n\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst currentMoveIndex = gamefile.boardsim.state.local.moveIndex;\n\tif (moveHistoryLegalMoves.has(currentMoveIndex)) return; // Already have move gen for this position\n\n\t// Add a new move gen request to pending queue\n\tpendingDebugRequests.push(currentMoveIndex);\n\n\t// Compress the gamefile as a single position (not including future moves)\n\t// This ensures the engine analyzes the currently viewed position\n\tconst longformIn = gamecompressor.compressGamefile(gamefile, true);\n\tconst stringGamefile = JSON.stringify(gamefile, jsutil.stringifyReplacer);\n\n\tif (engineWorker)\n\t\tengineWorker.postMessage({\n\t\t\tstringGamefile,\n\t\t\tlf: longformIn,\n\t\t\tengineConfig: engineConfig,\n\t\t\tyouAreColor: engineColor,\n\t\t\trequestGeneratedMoves: true,\n\t\t});\n}\n\n/** Renders a debug preview of the engine's generated legal moves for the current position. */\nfunction render(): void {\n\tif (!inEngineGame) return;\n\tif (!move_gen_debug) return;\n\n\t// Get the moves for the current position\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst currentMoveIndex = gamefile.boardsim.state.local.moveIndex;\n\tconst currentMoves = moveHistoryLegalMoves.get(currentMoveIndex) || [];\n\n\tif (currentMoves.length === 0) return; // No moves to render\n\n\t// Map moves to squares\n\tconst coordsKeys: CoordsKey[] = currentMoves.flatMap((moveStr: string) => {\n\t\tconst [_from, to] = moveStr.split('>');\n\t\treturn [to]; // We only care about the destination square for highlighting\n\t}) as CoordsKey[]; // [\"x,y\", ...]\n\n\tconst coords: Coords[] = coordsKeys.map((s) => coordutil.getCoordsFromKey(s)); // [[x,y], ...]\n\n\t// If we're zoomed out, then the size of the moves are constant.\n\tconst u_size = boardpos.areZoomedOut()\n\t\t? snapping.getEntityWidthWorld()\n\t\t: boardpos.getBoardScaleAsNumber();\n\n\tconst color = drawsquares.PRESET_SQUARE_COLOR;\n\n\t// Render legal move squares\n\tsquarerendering.genModel(coords, color).render(undefined, undefined, { u_size });\n}\n\n// Export ---------------------------------------------------------------------------------\n\nexport default {\n\tareInEngineGame,\n\tgetOurColor,\n\tisItOurTurn,\n\tgetCurrentEngine,\n\tinitEngineGame,\n\tcloseEngineGame,\n\tareWeColor,\n\tonMovePlayed,\n\ttoggleDebug,\n\trender,\n\tonViewMove,\n};\n\nexport type { EngineConfig };\n"
  },
  {
    "path": "src/client/scripts/esm/game/misc/gamesound.ts",
    "content": "// src/client/scripts/esm/game/misc/gamesound.ts\n\n/**\n * This script is in charge of storing our audio\n * spritesheet, and playing game sound effects.\n * It takes variables such as distances pieces moved\n * so it can deduce the correct sound play options when\n * calling {@link AudioManager.playAudio}.\n */\n\nimport type { Coords } from '../../../../../shared/chess/util/coordutil.js';\nimport type { EffectConfig } from '../../audio/AudioEffects.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport math from '../../../../../shared/util/math/math.js';\n\nimport screenshake from '../rendering/screenshake.js';\nimport WaterRipples from '../rendering/WaterRipples.js';\nimport AudioManager, { SoundObject } from '../../audio/AudioManager.js';\n\n// Constants --------------------------------------------------------------------------\n\n/** The timestamps where each game sound effect starts and ends inside our sound spritesheet. */\nconst soundStamps = {\n\tgamestart: [0, 2.008],\n\tmove: [2.009, 2.15],\n\tcapture: [2.151, 2.462],\n\tbell: [2.463, 5.402],\n\tlowtime: [5.404, 5.985],\n\twin: [5.986, 7.994],\n\tdraw: [7.995, 10.003],\n\tloss: [10.004, 12.012],\n\tdrum1: [12.013, 16.012],\n\tdrum2: [16.013, 19.262],\n\ttick: [19.263, 25.012],\n\tticking: [25.013, 36.357],\n\tviola_staccato_c3: [36.359, 38.357],\n\tviolin_staccato_c4: [38.359, 40.357],\n\tmarimba_c2: [40.359, 42.356],\n\tmarimba_c2_soft: [42.357, 44.356],\n\tbase_staccato_c2: [44.357, 46.354],\n\tripple: [46.356, 50.354],\n\tglass_crack_1: [50.356, 50.76],\n\tglass_crack_2: [50.76, 51.848],\n\tglass_crack_3: [51.848, 52.621],\n\tglass_crack_4: [52.621, 53.222],\n\tglass_crack_5: [53.222, 53.627],\n} as const;\n\ntype SoundName = keyof typeof soundStamps;\n\ntype SoundTimeSnippet = readonly [number, number];\n\n// Move Configs --------------------------------------------------------------------------\n\n/** Config for successive, or rapidly played move sounds. */\nconst SUCCESSIVE_MOVES_CONFIG = {\n\t/** If move sounds are played within this time, they get delayed until this amount time has passed, in milliseconds.\n\t * This is to prevent sounds from playing at the exact same time, such as the king & rook while castling. */\n\tgap: 35,\n\t/** The threshold in milliseconds to count two move sounds as successive. */\n\tthreshold: 60,\n\t/** The volume dampener for successive move sounds. */\n\tdampener: 0.5,\n} as const;\n/** Config for controlling moves' reverb effect. */\nconst REVERB_CONFIG = {\n\t/** The maximum `wetLevel` to use for moves' reverb effects. */\n\tmaxWetLevel: 3.5,\n\t/** The duration of moves' reverb effects, in seconds. */\n\tduration: 1.5,\n\t/** The minimum distance a piece needs to move for a reverb effect to gradually increase in volume. */\n\tminDist: 15,\n\t/** The distance a piece needs to move for the reverb effect to be at its max volume. */\n\tmaxDist: 80,\n} as const;\n\n/** Config for the bell gong sound effect when moves are extremely large. */\nconst BELL_CONFIG = {\n\t/** The distance a piece needs to move for the bell sound to play. */\n\tminDist: bd.fromBigInt(1_000_000n),\n\t/** The volume of the bell gongs, as a multiplier to the move sound's volume. */\n\tvolume: 0.6,\n} as const;\n\n/** Config for the water droplet ripple effect for EXTREMELY large moves. */\nconst RIPPLE_CONFIG = {\n\t/**\n\t * The minimum distance a piece needs to move for the water droplet ripple effect to trigger.\n\t * At current settings, this starts at the Spectral Edge beginning.\n\t */\n\tminDist: bd.fromBigInt(10n ** 120n), // 10^120 squares\n\t// minDist: bd.fromBigInt(20n), // FOR TESTING\n\tmaxPlaybackRate: 1.18,\n\tminPlaybackRate: 1.0,\n\t/**\n\t * How much slower the playback rate is, depending on how far you move.\n\t * 0.002 yields .18 playback rate travel in e90\n\t * At current settings, it stops decreasing at about e210, 30e after Iridescence zone begins.\n\t */\n\tplaybackRateReductionPerE: 0.002, // Default: 0.002\n\t/** The volume of the ripple sound effecet, as a multiplier to the move sound's volume. */\n\tvolume: 0.8,\n} as const;\n\n/** Config for the screen shake effect for very large moves. */\nconst SHAKE_CONFIG = {\n\t/** The order of magnitude distance a piece needs to move for the screen shake to begin triggering. */\n\tminDist: 4, // 10,000 squares => trauma begins increasing from 0\n\t/**\n\t * How much screen shake trauma is added per order of magnitude the piece moved.\n\t * 0.012 yields 1.0 shake trauma at about 1e90\n\t */\n\ttraumaMultiplier: 0.012,\n};\n\n/** Config for playing premove sound effects. */\nconst PREMOVE_CONFIG = {\n\t/** Premove sounds are played faster so they sound more like a click. */\n\tplaybackRate: 1.5,\n\t/** Premove sounds are slightly quieter. */\n\tvolume: 0.5,\n} as const;\n\n// Initiation Variables --------------------------------------------------------------------------\n\n/** The decoded buffer of the fetched game sound spritesheet. */\nlet spritesheetDecodedBuffer: AudioBuffer | undefined = undefined;\n\n// State ------------------------------------------------------------------------------\n\n/** Timestamp of the last played move sound. */\nlet timeLastMoveOrCaptureSound = 0;\n\n// Spritesheet Buffer ----------------------------------------------------\n\n// Fetch and decode the buffer of the sound spritesheet.\nfetch('sounds/spritesheet/soundspritesheet.opus')\n\t.then((response) => response.arrayBuffer())\n\t.then((arrayBuffer) => AudioManager.decodeAudioData(arrayBuffer))\n\t.then((decodedBuffer) => {\n\t\tspritesheetDecodedBuffer = decodedBuffer;\n\t\t// console.log('Sound spritesheet loaded and decoded successfully.');\n\t})\n\t.catch((error) => {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tconsole.error(`An error ocurred during loading of sound spritesheet: ${message}`);\n\t});\n\n/** Retrieves the sound time snippet for the specified sound. */\nfunction getSoundStamp(soundName: SoundName): SoundTimeSnippet {\n\tconst stamp = soundStamps[soundName];\n\tif (!stamp) throw new Error(`Cannot return sound stamp for unknown sound \"${soundName}\".`);\n\treturn stamp;\n}\n\n/** Calculates the duration of a sound time snippet in seconds. */\nfunction getStampDuration(stamp: SoundTimeSnippet): number {\n\t// [ startTimeSecs, endTimeSecs ]\n\treturn stamp[1] - stamp[0];\n}\n\n/** Retrieves the start time and duration of a sound inside the spritesheet. */\nfunction getSoundTimeSnippet(soundName: SoundName): { startTime: number; duration: number } {\n\tconst stamp = getSoundStamp(soundName);\n\tconst startTime = stamp[0];\n\tconst duration = getStampDuration(stamp);\n\treturn { startTime, duration };\n}\n\n// Playing Sounds -----------------------------------------------------------------------------\n\n/**\n * Plays a sound by name from the spritesheet.\n * @param soundName The name of the sound to play.\n * @param options Optional parameters like volume, delay, and offset.\n * @returns A SoundObject if the sound is played, otherwise undefined.\n */\nfunction playSoundEffect(\n\tsoundName: SoundName,\n\toptions: {\n\t\tvolume?: number;\n\t\tdelay?: number;\n\t\toffset?: number;\n\t\treverbWetLevel?: number;\n\t\treverbDuration?: number;\n\t\tplaybackRate?: number;\n\t\tbypassDownsampler?: boolean;\n\t} = {},\n): SoundObject | undefined {\n\tlet { startTime, duration } = getSoundTimeSnippet(soundName);\n\tconst {\n\t\tvolume,\n\t\tdelay,\n\t\toffset,\n\t\treverbWetLevel,\n\t\treverbDuration,\n\t\tplaybackRate,\n\t\tbypassDownsampler,\n\t} = options;\n\n\t// If offset is specified, adjust the start time and duration accordingly\n\tif (offset) {\n\t\tconst offsetSecs = offset / 1000;\n\t\tstartTime += offsetSecs;\n\t\tduration -= offsetSecs;\n\t\t// Don't play the sound if the offset exceeds the sound duration (can happen with 'tick' sound)\n\t\tif (duration <= 0) return;\n\t}\n\n\t// Add reverb effect if specified\n\tconst effects: EffectConfig[] = [];\n\tif (reverbWetLevel && reverbDuration)\n\t\teffects.push({\n\t\t\ttype: 'reverb',\n\t\t\tdurationSecs: reverbDuration,\n\t\t\tdryLevel: 1,\n\t\t\twetLevel: reverbWetLevel,\n\t\t});\n\n\treturn AudioManager.playAudio(spritesheetDecodedBuffer, {\n\t\tstartTime,\n\t\tduration,\n\t\tvolume,\n\t\tdelay,\n\t\tplaybackRate,\n\t\teffects,\n\t\tbypassDownsampler,\n\t});\n}\n\n/**\n * Plays a piece move sound effect.\n * Automatically handles effects such as capture, reverb, bell, dampening, etc.\n * @param distanceMoved - How far the piece moved.\n * @param capture - Whether this move made a capture.\n * @param premove - Whether this move is a premove.\n * @param destination - Optional. The destination coordinates of the piece move, for ripple effects.\n */\nfunction playMove(\n\tdistanceMoved: BigDecimal,\n\tcapture: boolean,\n\tpremove: boolean,\n\tdestination?: Coords,\n): void {\n\t// Update the time since the last move sound was played\n\tconst now = Date.now();\n\tconst timeSinceLastMoveSoundPlayed = now - timeLastMoveOrCaptureSound;\n\ttimeLastMoveOrCaptureSound = now;\n\n\tconst soundEffectName = capture ? 'capture' : 'move';\n\n\t// Determine if we should add delay (sounds played at same time, such as the king & rook while castling)\n\tconst delaySecs =\n\t\tMath.max(0, SUCCESSIVE_MOVES_CONFIG.gap - timeSinceLastMoveSoundPlayed) / 1000;\n\n\t// Determine if we should dampen the sound (sounds played successively, close together)\n\tconst shouldDampen = timeSinceLastMoveSoundPlayed < SUCCESSIVE_MOVES_CONFIG.threshold;\n\tconst successiveDampener = shouldDampen ? SUCCESSIVE_MOVES_CONFIG.dampener : 1; // Successively played moves are quieter\n\tconst premoveDampener = premove ? PREMOVE_CONFIG.volume : 1; // Premoves are slightly quieter\n\tconst dampener = successiveDampener * premoveDampener; // Total dampener\n\tconst volume = 1 * dampener;\n\n\tconst playbackRate = premove ? PREMOVE_CONFIG.playbackRate : 1; // Premove moves are played faster, so they sound more like a click.\n\n\tconst { reverbWetLevel, reverbDuration } = calculateReverb(distanceMoved);\n\n\tplaySoundEffect(soundEffectName, {\n\t\tvolume,\n\t\treverbWetLevel,\n\t\treverbDuration,\n\t\tdelay: delaySecs,\n\t\tplaybackRate,\n\t});\n\n\tif (destination && bd.compare(distanceMoved, RIPPLE_CONFIG.minDist) >= 0) {\n\t\t// Trigger water dropplet ripple effect\n\t\tconst rippleVolume = volume * RIPPLE_CONFIG.volume;\n\t\t// Calculate playback rate based on distance moved\n\t\tconst eDifference = bd.log10(distanceMoved) - bd.log10(RIPPLE_CONFIG.minDist);\n\t\tconst ripplePlayrate =\n\t\t\tplaybackRate *\n\t\t\tMath.max(\n\t\t\t\tRIPPLE_CONFIG.maxPlaybackRate -\n\t\t\t\t\teDifference * RIPPLE_CONFIG.playbackRateReductionPerE,\n\t\t\t\tRIPPLE_CONFIG.minPlaybackRate,\n\t\t\t);\n\t\t// console.log(\"Ripple playrate:\", ripplePlayrate);\n\n\t\tplaySoundEffect('ripple', {\n\t\t\tvolume: rippleVolume,\n\t\t\tdelay: delaySecs,\n\t\t\tplaybackRate: ripplePlayrate,\n\t\t});\n\t\tWaterRipples.addRipple(destination);\n\t\tscreenshake.trigger(0.25);\n\t} else {\n\t\t// Apply screen shake for very large moves\n\t\tconst rawTrauma =\n\t\t\t(bd.log10(distanceMoved) - SHAKE_CONFIG.minDist) * SHAKE_CONFIG.traumaMultiplier;\n\t\tconst trauma = math.clamp(rawTrauma, 0, 1);\n\t\tif (trauma > 0) screenshake.trigger(trauma);\n\n\t\tif (bd.compare(distanceMoved, BELL_CONFIG.minDist) >= 0) {\n\t\t\t// Move is large enough to play the bell sound too\n\t\t\tconst bellVolume = volume * BELL_CONFIG.volume;\n\t\t\tplaySoundEffect('bell', { volume: bellVolume, delay: delaySecs, playbackRate });\n\t\t}\n\t}\n}\n\n/** Takes the distance a piece moved, and returns the applicable reverb wet level and duration. */\nfunction calculateReverb(\n\tdistanceMoved: BigDecimal,\n):\n\t| { reverbWetLevel: number; reverbDuration: number }\n\t| { reverbWetLevel: undefined; reverbDuration: undefined } {\n\tconst distanceMovedNum = bd.toNumber(distanceMoved);\n\tconst x =\n\t\t(distanceMovedNum - REVERB_CONFIG.minDist) /\n\t\t(REVERB_CONFIG.maxDist - REVERB_CONFIG.minDist); // 0-1\n\tif (x <= 0) return { reverbWetLevel: undefined, reverbDuration: undefined };\n\telse if (x >= 1)\n\t\treturn {\n\t\t\treverbWetLevel: REVERB_CONFIG.maxWetLevel,\n\t\t\treverbDuration: REVERB_CONFIG.duration,\n\t\t};\n\n\tconst reverbWetLevel = REVERB_CONFIG.maxWetLevel * x; // No easing applied, for now\n\n\treturn { reverbWetLevel, reverbDuration: REVERB_CONFIG.duration };\n}\n\nfunction playGamestart(): SoundObject | undefined {\n\treturn playSoundEffect('gamestart', { volume: 0.4, bypassDownsampler: true });\n}\n\nfunction playWin(delay?: number): SoundObject | undefined {\n\treturn playSoundEffect('win', { volume: 0.7, delay });\n}\n\nfunction playDraw(delay?: number): SoundObject | undefined {\n\treturn playSoundEffect('draw', { volume: 0.7, delay });\n}\n\nfunction playLoss(delay?: number): SoundObject | undefined {\n\treturn playSoundEffect('loss', { volume: 0.7, delay });\n}\n\nfunction playLowtime(): SoundObject | undefined {\n\treturn playSoundEffect('lowtime');\n}\n\nfunction playDrum(): SoundObject | undefined {\n\tconst soundName = Math.random() > 0.5 ? 'drum1' : 'drum2';\n\treturn playSoundEffect(soundName, { volume: 0.7 });\n}\n\nfunction playTick({ volume, offset }: { volume?: number; offset?: number } = {}):\n\t| SoundObject\n\t| undefined {\n\treturn playSoundEffect('tick', { volume, offset });\n}\n\nfunction playTicking({ volume, offset }: { volume?: number; offset?: number } = {}):\n\t| SoundObject\n\t| undefined {\n\treturn playSoundEffect('ticking', { volume, offset });\n}\n\nfunction playViola_c3({ volume }: { volume?: number } = {}): SoundObject | undefined {\n\treturn playSoundEffect('viola_staccato_c3', { volume });\n}\n\nfunction playViolin_c4(): SoundObject | undefined {\n\treturn playSoundEffect('violin_staccato_c4', { volume: 0.9 });\n}\n\nfunction playMarimba(): SoundObject | undefined {\n\tconst audioName = Math.random() > 0.15 ? 'marimba_c2_soft' : 'marimba_c2';\n\treturn playSoundEffect(audioName, { volume: 0.4 });\n}\n\nfunction playBase(): SoundObject | undefined {\n\treturn playSoundEffect('base_staccato_c2', { volume: 0.8 });\n}\n\nfunction playGlassCrack(): SoundObject | undefined {\n\tconst rand = Math.random();\n\t// prettier-ignore\n\tconst soundName: SoundName = rand < 0.2 ? 'glass_crack_1'\n\t\t: rand < 0.4 ? 'glass_crack_2'\n\t\t: rand < 0.6 ? 'glass_crack_3'\n\t\t: rand < 0.8 ? 'glass_crack_4'\n\t\t\t\t\t\t: 'glass_crack_5';\n\tconst PLAYRATE_BASE_OFFSET = -0.2;\n\tconst PLAYRATE_VARIATION = 0.07;\n\tconst playrate = 1 + (Math.random() * 2 - 1) * PLAYRATE_VARIATION + PLAYRATE_BASE_OFFSET;\n\treturn playSoundEffect(soundName, {\n\t\tvolume: 0.04,\n\t\tplaybackRate: playrate,\n\t\treverbWetLevel: 4.0,\n\t\treverbDuration: 0.8,\n\t\tbypassDownsampler: true,\n\t});\n}\n\n// Exports ------------------------------------------------------------------------------\n\nexport default {\n\tplayMove,\n\tplayGamestart,\n\tplayWin,\n\tplayDraw,\n\tplayLoss,\n\tplayLowtime,\n\tplayDrum,\n\tplayTick,\n\tplayTicking,\n\tplayViola_c3,\n\tplayViolin_c4,\n\tplayMarimba,\n\tplayBase,\n\tplayGlassCrack,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/misc/invites.ts",
    "content": "// src/client/scripts/esm/game/misc/invites.ts\n\n/**\n * This script manages the invites on the Play page.\n */\n\nimport type { Player } from '../../../../../shared/chess/util/typeutil.js';\nimport type { VariantCode } from '../../../../../shared/chess/variants/variantdictionary.js';\nimport type { TimeControl } from '../../../../../shared/types.js';\nimport type { Invite, InvitesMessage } from '../websocket/socketschemas.js';\n\nimport uuid from '../../../../../shared/util/uuid.js';\nimport clockutil from '../../../../../shared/chess/util/clockutil.js';\nimport { players as p } from '../../../../../shared/chess/util/typeutil.js';\n\nimport toast from '../gui/toast.js';\nimport guiplay from '../gui/guiplay.js';\nimport docutil from '../../util/docutil.js';\nimport gamesound from './gamesound.js';\nimport socketsubs from '../websocket/socketsubs.js';\nimport LocalStorage from '../../util/LocalStorage.js';\nimport loadbalancer from './loadbalancer.js';\nimport validatorama from '../../util/validatorama.js';\nimport socketmessages from '../websocket/socketmessages.js';\nimport usernamecontainer from '../../util/usernamecontainer.js';\n\n// Types -------------------------------------------------------------------------\n\n/** Create lobby invite options. */\nexport interface InviteOptions {\n\tvariant: VariantCode;\n\tclock: TimeControl;\n\tcolor: Player | null;\n\tprivate: 'public' | 'private';\n\trated: 'casual' | 'rated';\n}\n\n// Elements ----------------------------------------------------------------------\n\nconst invitesContainer = document.getElementById('invites')!;\nconst ourInviteContainer = document.getElementById('our-invite')!;\nconst element_joinExisting = document.getElementById('join-existing')!;\nconst element_inviteCodeCode = document.getElementById('invite-code-code')!;\n\n// Variables ---------------------------------------------------------------------\n\n/** Whether we have an existing invite created by us. */\nlet weHaveInvite = false;\n/** The ID of our existing invite, if any. */\nlet ourInviteID: string | undefined;\n\n// Functions ---------------------------------------------------------------------\n\nfunction gelement_iCodeCode(): HTMLElement {\n\treturn element_inviteCodeCode;\n}\n\nfunction update(): void {\n\tif (!guiplay.isOpen()) return; // Not on the play screen\n\tif (loadbalancer.areWeHibernating())\n\t\ttoast.show(translations.invites.move_mouse, { durationMultiplier: 0.1 });\n}\n\nfunction unsubIfWeNotHave(): void {\n\tif (!weHaveInvite) unsubFromInvites();\n}\n\n/**\n * Unsubscribes from the invites subscriptions list.\n */\nfunction unsubFromInvites(): void {\n\tclear(true);\n\tsocketsubs.unsubFromSub('invites');\n}\n\n/**\n * Update invites list according to new data!\n * Should be called by websocket script when it receives a\n * message that the server says is for the \"invites\" subscription\n */\nfunction onmessage(contents: InvitesMessage): void {\n\t// Any incoming message will have no effect if we're not on the invites page.\n\t// This can happen if we have slow network and leave the invites screen before the server sends us an invites-related message.\n\tif (!guiplay.isOpen()) return;\n\n\tswitch (contents.action) {\n\t\tcase 'inviteslist':\n\t\t\t// Update the list in the document\n\t\t\tupdateInviteList(contents.value.invitesList);\n\t\t\tupdateActiveGameCount(contents.value.currentGameCount);\n\t\t\tbreak;\n\t\tcase 'gamecount':\n\t\t\tupdateActiveGameCount(contents.value);\n\t\t\tbreak;\n\t\tdefault:\n\t\t\t// @ts-ignore\n\t\t\tconsole.error(`Received message for invites with unknown action: ${contents.action}`);\n\t\t\tbreak;\n\t}\n}\n\n/**\n * Sends the create invite request message from the given InviteOptions specified on the invite creation screen.\n */\nfunction create(variantOptions: InviteOptions): void {\n\tif (weHaveInvite)\n\t\treturn console.error(\"We already have an existing invite, can't create more.\");\n\n\tconst inviteOptions = {\n\t\tvariant: variantOptions.variant,\n\t\tclock: variantOptions.clock,\n\t\tcolor: variantOptions.color,\n\t\tpublicity: variantOptions.private, // Only the `private` property is changed to `publicity`\n\t\trated: variantOptions.rated,\n\t};\n\n\tgenerateTagForInvite(inviteOptions);\n\n\tguiplay.lockCreateInviteButton();\n\n\t// The function to execute when we hear back the server's response\n\tconst onreplyFunc = guiplay.unlockCreateInviteButton;\n\n\t// console.log(\"Invite options before sending create invite:\");\n\t// console.log(inviteOptions);\n\n\tsocketmessages.send('invites', 'createinvite', inviteOptions, true, onreplyFunc);\n}\n\nfunction cancel(inviteID = ourInviteID): void {\n\tif (!weHaveInvite) return;\n\tif (!inviteID) return toast.show(translations.invites.cannot_cancel, { error: true });\n\n\tLocalStorage.deleteItem('invite-tag');\n\n\tguiplay.lockCreateInviteButton();\n\n\t// The function to execute when we hear back the server's response\n\tconst onreplyFunc = guiplay.unlockCreateInviteButton;\n\n\tsocketmessages.send('invites', 'cancelinvite', inviteID, true, onreplyFunc);\n}\n\n/**\n * Generates a tag id for the invite parameters before\n * we send off action \"createinvite\" to the server.\n */\nfunction generateTagForInvite(inviteOptions: {\n\tvariant: string;\n\tclock: TimeControl;\n\tcolor: Player | null;\n\tpublicity: 'public' | 'private';\n\trated: 'casual' | 'rated';\n\ttag?: string;\n}): void {\n\t// Create and send invite with a tag so we know which ones ours\n\tconst tag = uuid.generateID_Base62(8);\n\n\t// NEW browser storage method!\n\tLocalStorage.saveItem('invite-tag', tag);\n\n\tinviteOptions.tag = tag;\n}\n\n/** Updates the invite elements on the invite creation screen according to the new list provided. */\nfunction updateInviteList(list: Invite[]): void {\n\t// { invitesList, currentGameCount }\n\tif (!list) return;\n\n\tconst alreadySeenOurInvite = weHaveInvite;\n\tlet alreadyPlayedSound = false;\n\n\t// Close all previous event listeners and delete invites from the document\n\tclear();\n\n\t// Append latest invites to the document and re-init event listeners.\n\tlet foundOurs = false;\n\tlet privateInviteID: string | undefined = undefined;\n\tourInviteID = undefined;\n\tfor (let i = 0; i < list.length; i++) {\n\t\t// { usernamecontainer, variant, clock, color, publicity }\n\t\tconst invite = list[i]!;\n\n\t\t// Is this our own invite?\n\t\tconst ours = foundOurs ? false : isInviteOurs(invite);\n\t\tif (ours) {\n\t\t\tfoundOurs = true;\n\t\t\tourInviteID = invite.id;\n\t\t\tif (!alreadySeenOurInvite) {\n\t\t\t\tgamesound.playMarimba();\n\t\t\t\talreadyPlayedSound = true;\n\t\t\t}\n\t\t}\n\n\t\tconst classes = ['invite', 'button', 'unselectable'];\n\t\tconst isPrivate = invite.publicity === 'private';\n\t\tif (isPrivate) privateInviteID = invite.id;\n\t\tif (ours && !isPrivate) classes.push('ours');\n\t\telse if (ours && isPrivate) classes.push('private');\n\n\t\tconst newInvite = createDiv(classes, undefined, invite.id);\n\n\t\t// <div class=\"invite-child\">Playername (elo)</div>\n\t\t// <div class=\"invite-child\">Standard</div>\n\t\t// <div class=\"invite-child\">15m+15s</div>\n\t\t// <div class=\"invite-child\">Random</div>\n\t\t// <div class=\"invite-child\">Casual</div>\n\t\t// <div class=\"invite-child accept\">Accept</div>\n\n\t\tif (invite.usernamecontainer.type === 'guest') {\n\t\t\t// Standardize the name according to our language.\n\t\t\tif (ours) invite.usernamecontainer.username = translations.you_indicator;\n\t\t\telse invite.usernamecontainer.username = translations.guest_indicator;\n\t\t}\n\t\tconst username_item = { value: invite.usernamecontainer.username, openInNewWindow: false };\n\t\tconst displayelement_usernamecontainer = usernamecontainer.createUsernameContainer(\n\t\t\tinvite.usernamecontainer.type,\n\t\t\tusername_item,\n\t\t\tinvite.usernamecontainer.rating,\n\t\t).element;\n\t\tdisplayelement_usernamecontainer.classList.add('invite-child');\n\t\tnewInvite.appendChild(displayelement_usernamecontainer);\n\t\t// @ts-ignore\n\t\tconst variant = createDiv(['invite-child'], translations[invite.variant]);\n\t\tnewInvite.appendChild(variant);\n\n\t\tconst time = clockutil.getClockFromKey(invite.clock);\n\t\tconst cloc = createDiv(['invite-child'], time);\n\t\tnewInvite.appendChild(cloc);\n\n\t\t// prettier-ignore\n\t\tconst uColor: string = ours ? invite.color === p.WHITE ? translations.invites.you_are_white : invite.color === p.BLACK ? translations.invites.you_are_black : translations.invites.random\n                            : invite.color === p.WHITE ? translations.invites.you_are_black : invite.color === p.BLACK ? translations.invites.you_are_white : translations.invites.random;\n\t\tconst color = createDiv(['invite-child'], uColor);\n\t\tnewInvite.appendChild(color);\n\n\t\tconst rated = createDiv(['invite-child'], translations[invite.rated]);\n\t\tnewInvite.appendChild(rated);\n\n\t\tconst a: string = ours ? translations.invites.cancel : translations.invites.accept;\n\t\tconst accept = createDiv(['invite-child', 'accept'], a);\n\t\tnewInvite.appendChild(accept);\n\n\t\tconst targetCont = ours ? ourInviteContainer : invitesContainer;\n\t\ttargetCont.appendChild(newInvite);\n\t}\n\n\tif (!alreadyPlayedSound) playBaseIfNewInvite(list);\n\n\tweHaveInvite = foundOurs;\n\tupdateCreateInviteButton();\n\tupdatePrivateInviteCode(privateInviteID);\n\n\tguiplay.initListeners_Invites();\n\n\t// If we are on \"Local\" and have an existing invite, IMMEDIATELY cancel it! This can happen with slow network.\n\tif (weHaveInvite && guiplay.getModeSelected() !== 'online') cancel();\n}\n\n/**\n * Plucks base C2 (audio cue) if the new invites list contains an invite from a new person!\n *\n * Uses a closure to maintain state of recent users and IDs from the last list.\n */\nconst playBaseIfNewInvite = (() => {\n\tconst COOLDOWN_SECS = 10;\n\n\tconst recentUsers: Record<string, boolean> = {};\n\tlet IDsInLastList: Record<string, boolean> = {};\n\n\treturn function (inviteList: Invite[]): void {\n\t\tlet playedSound = false;\n\t\tconst newIDsInList: Record<string, boolean> = {};\n\t\tinviteList.forEach((invite) => {\n\t\t\tconst name = invite.usernamecontainer.username;\n\t\t\tconst id = invite.id;\n\t\t\tnewIDsInList[id] = true;\n\t\t\tif (IDsInLastList[id]) return; // Not a new invite, was there last update.\n\t\t\tif (recentUsers[name]) return; // We recently played a sound for this user\n\t\t\tif (isInviteOurs(invite)) return;\n\t\t\trecentUsers[name] = true;\n\t\t\tsetTimeout(() => delete recentUsers[name], COOLDOWN_SECS * 1000);\n\t\t\tif (playedSound) return;\n\t\t\tplaySoundNewOpponentInvite();\n\t\t\tplayedSound = true;\n\t\t});\n\t\tIDsInLastList = newIDsInList;\n\t};\n})();\n\nfunction playSoundNewOpponentInvite(): void {\n\tif (docutil.isMouseSupported()) gamesound.playBase();\n\telse gamesound.playViola_c3();\n}\n\n/**\n * Close all previous event listeners and delete invites from the document\n * @param resetRecentUsersCache - If true, resets the playBaseIfNewInvite closure's internal state for tracking recent users\n */\nfunction clear(resetRecentUsersCache?: true): void {\n\tguiplay.closeListeners_Invites();\n\tourInviteContainer.innerHTML = ''; // Deletes all contained invite elements\n\tinvitesContainer.innerHTML = ''; // Deletes all contained invite elements\n\tweHaveInvite = false;\n\tourInviteID = undefined;\n\telement_inviteCodeCode.textContent = '';\n\t// Passing in an empty list resets the local scope variables for next time.\n\tif (resetRecentUsersCache) playBaseIfNewInvite([]);\n}\n\n/** Deletes all invites and resets create invite button if on play page. */\nfunction clearIfOnPlayPage(): void {\n\tif (!guiplay.isOpen()) return; // Not on the play screen\n\tclear();\n\tupdateCreateInviteButton();\n}\n\n/** Tests if a virtual invite belongs to us. */\nfunction isInviteOurs(invite: Invite): boolean {\n\tif (validatorama.areWeLoggedIn()) {\n\t\treturn (\n\t\t\tinvite.usernamecontainer.type === 'player' &&\n\t\t\tvalidatorama.getOurUsername() === invite.usernamecontainer.username\n\t\t);\n\t}\n\n\tif (!invite.tag) return invite.id === ourInviteID; // Tag not present (invite converted from an HTML element), compare ID instead.\n\n\t// Compare the tag..\n\n\tconst localStorageTag = LocalStorage.loadItem('invite-tag');\n\tif (!localStorageTag) return false;\n\tif (invite.tag === localStorageTag) return true;\n\treturn false;\n}\n\n/** Creates a virtual invite from the given invite HTML element. */\nfunction getInviteFromElement(inviteElement: HTMLElement): Invite {\n\tconst id = inviteElement.getAttribute('id')!;\n\n\t/**\n\t * Starting from the first child, the order goes:\n\t * Usernamecontainer, Variant, TimeControl, Color, Publicity, Rated\n\t * (see the {@link Invite} object)\n\t */\n\n\treturn {\n\t\tusernamecontainer: usernamecontainer.extractPropertiesFromUsernameContainerElement(\n\t\t\tinviteElement.children[0] as HTMLDivElement,\n\t\t),\n\t\tvariant: inviteElement.children[1]!.textContent,\n\t\tclock: inviteElement.children[2]!.textContent as TimeControl,\n\t\tcolor: Number(inviteElement.children[3]!.textContent) as Player,\n\t\tpublicity: inviteElement.children[4]!.textContent as 'public' | 'private',\n\t\trated: inviteElement.children[5]!.textContent as 'casual' | 'rated',\n\t\tid,\n\t};\n}\n\nfunction createDiv(\n\tclasses: string[],\n\ttextContent: string | undefined,\n\tid?: string,\n): HTMLDivElement {\n\tconst element = document.createElement('div');\n\tclasses.forEach((c) => element.classList.add(c));\n\tif (textContent !== undefined) element.textContent = textContent;\n\tif (id !== undefined) element.id = id;\n\treturn element;\n}\n\nfunction accept(inviteID: string, isPrivate: boolean): void {\n\tconst inviteinfo = { id: inviteID, isPrivate };\n\n\tguiplay.lockAcceptInviteButton();\n\n\t// The function to execute when we hear back the server's response\n\tconst onreplyFunc = guiplay.unlockAcceptInviteButton;\n\n\tsocketmessages.send('invites', 'acceptinvite', inviteinfo, true, onreplyFunc);\n}\n\n/** Called when an invite element is clicked. */\nfunction click(element: HTMLElement): void {\n\tconst invite = getInviteFromElement(element);\n\tconst isOurs = isInviteOurs(invite);\n\n\tif (isOurs) {\n\t\t// Only cancel if the Create Invite button isn't disabled\n\t\tif (!guiplay.isCreateInviteButtonLocked()) cancel(invite.id);\n\t} else {\n\t\t// Not our invite, accept the one we clicked\n\t\tif (!guiplay.isAcceptInviteButtonLocked()) accept(invite.id, false);\n\t}\n}\n\nfunction updateCreateInviteButton(): void {\n\tif (guiplay.getModeSelected() !== 'online') return;\n\tif (weHaveInvite)\n\t\tguiplay.setElement_CreateInviteTextContent(translations.invites.cancel_invite);\n\telse guiplay.setElement_CreateInviteTextContent(translations.invites.create_invite);\n}\n\nfunction updatePrivateInviteCode(privateInviteID: string | undefined): void {\n\t// If undefined, we know we don't have a \"private\" invite\n\tif (guiplay.getModeSelected() === 'local') return;\n\n\tif (!weHaveInvite) {\n\t\tguiplay.showElement_joinPrivate();\n\t\tguiplay.hideElement_inviteCode();\n\t\treturn;\n\t}\n\n\t// We have an invite...\n\n\t// If the classlist of our private invite contains a \"private\" property of \"private\",\n\t// then display our invite code text!\n\n\tif (privateInviteID) {\n\t\tguiplay.hideElement_joinPrivate();\n\t\tguiplay.showElement_inviteCode();\n\t\telement_inviteCodeCode.textContent = privateInviteID.toUpperCase();\n\t\treturn;\n\t}\n\n\t// Else our invite is NOT private, only show the \"Private Invite:\" display.\n\n\tguiplay.showElement_joinPrivate();\n\tguiplay.hideElement_inviteCode();\n}\n\nfunction updateActiveGameCount(newCount: number): void {\n\tif (newCount === undefined) throw Error('Need to specify active game count');\n\telement_joinExisting.textContent = `${translations.invites.join_existing_active_games} ${newCount}`;\n}\n\nfunction doWeHave(): boolean {\n\treturn weHaveInvite;\n}\n\n/**\n * Subscribes to the invites list. We will receive updates\n * for incoming and deleted invites from other players.\n * @param ignoreAlreadySubbed - *true* If the socket closed unexpectedly and we need to resub. subs.invites will already be true so we ignore that.\n */\nasync function subscribeToInvites(ignoreAlreadySubbed?: boolean): Promise<void> {\n\t// Set to true when we are restarting the connection and need to resub to everything we were to before.\n\tif (!guiplay.isOpen()) return; // Don't subscribe to invites if we're not on the play page!\n\n\tconst alreadySubbed = socketsubs.areSubbedToSub('invites');\n\tif (!ignoreAlreadySubbed && alreadySubbed) return;\n\t// console.log(\"Subbing to invites!\");\n\tsocketsubs.addSub('invites');\n\tsocketmessages.send('general', 'sub', 'invites');\n}\n\n// Exports -----------------------------------------------------------------------\n\nexport default {\n\tgelement_iCodeCode,\n\tonmessage,\n\tupdate,\n\tcreate,\n\tcancel,\n\tclear,\n\taccept,\n\tclick,\n\tdoWeHave,\n\tclearIfOnPlayPage,\n\tunsubIfWeNotHave,\n\tsubscribeToInvites,\n\tunsubFromInvites,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/misc/keybinds.ts",
    "content": "// src/client/scripts/esm/game/misc/keybinds.ts\n\n/**\n * This script will store the keybinds for various game actions.\n *\n * Currently we only store keybinds that actually CHANGE.\n * But in the future we can expand this with perhaps an option menu.\n */\n\nimport perspective from '../rendering/perspective.js';\nimport preferences from '../../components/header/preferences.js';\nimport etoolmanager from '../boardeditor/tools/etoolmanager.js';\nimport guinavigation from '../gui/guinavigation.js';\nimport { listener_document } from '../chess/game.js';\nimport { Mouse, MouseButton } from '../input.js';\n\n/** Returns the mouse button currently assigned to board dragging. */\nfunction getBoardDragMouseButton(): MouseButton | undefined {\n\tif (perspective.getEnabled()) return undefined;\n\tif (guinavigation.isAnnotationsButtonEnabled()) return Mouse.LEFT; // Allows a second pointer to pinch zoom the board even when drawing annote with first pointer.\n\tif (etoolmanager.isLeftMouseReserved()) return Mouse.RIGHT;\n\t// Default: Left mouse drags board\n\treturn Mouse.LEFT;\n}\n\n/** Returns the mouse button currently assigned to drawing annotations. */\nfunction getAnnotationMouseButton(): MouseButton | undefined {\n\tif (guinavigation.isAnnotationsButtonEnabled() || perspective.getEnabled()) return Mouse.RIGHT;\n\tif (etoolmanager.isLeftMouseReserved()) return undefined; // NO BUTTON draws annotations (right click reserved for dragging)\n\t// Default: Right mouse draws annotations\n\treturn Mouse.RIGHT;\n}\n\n/** Returns the mouse button currently assigned to collapsing annotations, or cancelling premoves. */\nfunction getCollapseMouseButton(): MouseButton | undefined {\n\tif (etoolmanager.isLeftMouseReserved()) return undefined; // Left click reserved for drawing tool\n\t// Default: Right mouse\n\treturn Mouse.LEFT;\n}\n\n/** Returns the mouse button currently assigned to piece selection. */\nfunction getPieceSelectionMouseButton(): MouseButton | undefined {\n\tif (etoolmanager.isLeftMouseReserved()) return undefined; // Left click reserved for drawing tool\n\t// Default: Left mouse\n\treturn Mouse.LEFT;\n}\n\n/**\n * Returns true if piece dragging should currently be treated as enabled.\n * The Ctrl key, if held, temporarily inverts the drag preference.\n */\nfunction getEffectiveDragEnabled(): boolean {\n\tconst dragEnabled = preferences.getDragEnabled();\n\tconst ctrlOverride =\n\t\tlistener_document.isKeyHeld('ControlLeft') || listener_document.isKeyHeld('ControlRight');\n\treturn ctrlOverride ? !dragEnabled : dragEnabled;\n}\n\nexport default {\n\tgetBoardDragMouseButton,\n\tgetAnnotationMouseButton,\n\tgetCollapseMouseButton,\n\tgetPieceSelectionMouseButton,\n\tgetEffectiveDragEnabled,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/misc/loadbalancer.ts",
    "content": "// src/client/scripts/esm/game/misc/loadbalancer.ts\n\n/**\n * This script keeps track of our deltaTime, FPS, AFK status, and hibernation status.\n */\n\nimport jsutil from '../../../../../shared/util/jsutil.js';\n\nimport stats from '../gui/stats.js';\nimport config from '../config.js';\nimport invites from './invites.js';\nimport tabnameflash from './onlinegame/tabnameflash.js';\nimport { listener_document, listener_overlay } from '../chess/game.js';\n\n// Variables -------------------------------------------------------------\n\n/** In millis since the start of the program (updated at the beginning of each frame). */\nlet runTime: number;\n/** Time in seconds since last animation frame */\nlet deltaTime: number;\nlet lastFrameTime: number = 0;\n\n/** Milliseconds to average the fps over */\nconst fpsWindow = 1000;\n/** Contains an ordered array of the timestamps of all frames over the last second */\nconst frames: number[] = [];\nlet fps = 0;\n/** Estimation of the monitor's refresh rate. */\nlet monitorRefreshRate = 0;\n\nlet isAFK = false;\n/** Milliseconds of inactivity to pause title screen animation, saving cpu. */\nconst timeUntilAFK = { normal: 30_000, dev: 2_000 }; // Default: 30_000\nlet AFKTimeoutID: number | undefined;\n\nlet isHibernating = false;\nconst timeUntilHibernation = 1000 * 60 * 30; // 30 minutes\n// const timeUntilHibernation = 10000; // 10s for dev testing\n/** ID of the timer to declare we are hibernating! */\nlet hibernateTimeoutID: number | undefined;\n\nlet windowIsVisible = true;\n\nconst timeToDeleteInviteAfterPageHiddenMillis = 1000 * 60 * 30; // 30 minutes\n// const timeToDeleteInviteAfterPageHiddenMillis = 1000 * 10; // 10 seconds\nlet timeToDeleteInviteTimeoutID: number | undefined;\n\n// Functions -------------------------------------------------------------\n\n/** Millis since the start of the program. */\nfunction getRunTime(): number {\n\treturn runTime;\n}\n\n/** Returns the amount of seconds that have passed since last frame. */\nfunction getDeltaTime(): number {\n\treturn deltaTime;\n}\n\nfunction getTimeUntilAFK(): number {\n\treturn config.DEV_BUILD ? timeUntilAFK.dev : timeUntilAFK.normal;\n}\n\nfunction areWeAFK(): boolean {\n\treturn isAFK;\n}\n\nfunction areWeHibernating(): boolean {\n\treturn isHibernating;\n}\n\nfunction isPageHidden(): boolean {\n\treturn !windowIsVisible;\n}\n\nfunction update(runtime: number): void {\n\t// milliseconds\n\tupdateDeltaTime(runtime);\n\n\tframes.push(runTime);\n\ttrimFrames();\n\n\tupdateFPS();\n\n\tupdateMonitorRefreshRate();\n\n\tupdateAFK();\n}\n\nfunction updateDeltaTime(runtime: number): void {\n\trunTime = runtime;\n\tdeltaTime = (runTime - lastFrameTime) / 1000;\n\tlastFrameTime = runTime;\n}\n\n// Deletes frame timestamps from our list over 1 second ago\nfunction trimFrames(): void {\n\t// What time was it 1 second ago\n\tconst splitPoint = runTime - fpsWindow;\n\n\t// Use binary search to find the split point.\n\tconst indexToSplit = jsutil.findIndexOfPointInOrganizedArray(frames, splitPoint);\n\n\t// This will not delete a timestamp if it falls exactly on the split point.\n\tframes.splice(0, indexToSplit);\n}\n\nfunction updateFPS(): void {\n\tfps = (frames.length * 1000) / fpsWindow;\n\tstats.updateFPS(fps);\n}\n\n// Our highest-ever fps will be the monitor's refresh rate!\nfunction updateMonitorRefreshRate(): void {\n\tif (fps <= monitorRefreshRate) return;\n\tmonitorRefreshRate = fps;\n}\n\nfunction updateAFK(): void {\n\tif (listener_overlay.atleastOneInput() || listener_document.atleastOneInput())\n\t\tonReturnFromAFK();\n}\n\nfunction onReturnFromAFK(): void {\n\tisAFK = false;\n\tisHibernating = false;\n\trestartAFKTimer();\n\trestartHibernateTimer();\n\n\t// Make sure we're subbed to invites list if we're on the play page!\n\tinvites.subscribeToInvites();\n}\n\nfunction restartAFKTimer(): void {\n\tclearTimeout(AFKTimeoutID);\n\tAFKTimeoutID = window.setTimeout(onAFK, getTimeUntilAFK());\n}\n\nfunction restartHibernateTimer(): void {\n\tclearTimeout(hibernateTimeoutID);\n\thibernateTimeoutID = window.setTimeout(onHibernate, timeUntilHibernation);\n}\n\nfunction onAFK(): void {\n\tisAFK = true;\n\tAFKTimeoutID = undefined;\n\t//console.log(\"Set AFK to true!\")\n}\n\nfunction onHibernate(): void {\n\tif (invites.doWeHave()) return restartHibernateTimer(); // Don't hibernate if we have an open invite AND the page is visible!\n\tisHibernating = true;\n\thibernateTimeoutID = undefined;\n\t// console.log(\"Set hibernating to true!\")\n\n\t// Unsub from invites list\n\tinvites.unsubFromInvites();\n}\n\n// The 'focus' and 'blur' event listeners fire the MOST common, when you so much as click a different window on-screen,\n// EVEN though the game is still visible on screen, it just means it lost focus!\n\n// This fires the next most commonly, whenever\n// the page becomes NOT visible on the screen no more!\n// It's at the same time this fires when animation frames are no longer rendered.\n// Use this listener as a giveaway that we have disconnected!\n\ndocument.addEventListener('visibilitychange', function () {\n\tif (document.hidden) {\n\t\twindowIsVisible = false;\n\n\t\t// Unsub from invites list if we don't have an invite!\n\t\t// invitesweb.unsubIfWeNotHave();\n\n\t\t// Set a timer to delete our invite after not returning to the page!\n\t\t// THIS ALSO UNSUBS US\n\t\t// timeToDeleteInviteTimeoutID = setTimeout(websocket.unsubFromInvites, timeToDeleteInviteAfterPageHiddenMillis)\n\t\t// This ONLY cancels our invite if we have one\n\t\ttimeToDeleteInviteTimeoutID = window.setTimeout(\n\t\t\tinvites.cancel,\n\t\t\ttimeToDeleteInviteAfterPageHiddenMillis,\n\t\t);\n\t} else {\n\t\twindowIsVisible = true;\n\n\t\t// Resub to invites list if we are on the play page and aren't already!\n\t\t// invitesweb.subscribeToInvites();\n\n\t\t// Cancel the timer to delete our invite after not returning to the page\n\t\tcancelTimerToDeleteInviteAfterLeavingPage();\n\n\t\ttabnameflash.cancelMoveSound();\n\t}\n});\n\n// Cancel the timer to delete our invite after not returning to the page\nfunction cancelTimerToDeleteInviteAfterLeavingPage(): void {\n\tclearTimeout(timeToDeleteInviteTimeoutID);\n\ttimeToDeleteInviteTimeoutID = undefined;\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\tgetRunTime,\n\tgetDeltaTime,\n\tupdate,\n\tareWeAFK,\n\tareWeHibernating,\n\tisPageHidden,\n\trestartAFKTimer,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/misc/onlinegame/afk.ts",
    "content": "// src/client/scripts/esm/game/misc/onlinegame/afk.ts\n\n/**\n * This script keeps track of how long we have been afk in the current online game,\n * and if it's for too long, it informs the server that fact,\n * then the server starts an auto-resign timer if we don't return.\n *\n * This will also display a countdown onscreen, and sound effects,\n * before we are auto-resigned.\n *\n * It will also display a countdown until our opponent is auto-resigned,\n * if they are the one that is afk.\n */\n\nimport moveutil from '../../../../../../shared/chess/util/moveutil.js';\nimport gamefileutility from '../../../../../../shared/chess/util/gamefileutility.js';\n\nimport toast from '../../gui/toast.js';\nimport gameslot from '../../chess/gameslot.js';\nimport gamesound from '../gamesound.js';\nimport onlinegame from './onlinegame.js';\nimport pingManager from '../../../util/pingManager.js';\nimport socketmessages from '../../websocket/socketmessages.js';\nimport { listener_document, listener_overlay } from '../../chess/game.js';\n\n// Constants -----------------------------------------------------------------------\n\n/** The time, in seconds, we must be AFK for us to alert the server that fact. Afterward the server will start an auto-resign timer. */\nconst timeUntilAFKSecs: number = 40; // 40 + 20 = 1 minute\n\n/** ABORTABLE GAMES ONLY (< 2 moves played): The time, in seconds, we must be AFK for us to alert the server that fact. Afterward the server will start an auto-resign timer. */\nconst timeUntilAFKSecs_Abortable: number = 20; // 20 + 20 = 40 seconds\n\n/** UNTIMED GAMES ONLY: The time, in seconds, we must be AFK for us to alert the server that fact. Afterward the server will start an auto-resign timer. */\nconst timeUntilAFKSecs_Untimed: number = 100; // 100 + 20 = 2 minutes\n\n/** The amount of time we have, in milliseconds, from the time we alert the\n * server we are afk, to the time we lose if we don't return. */\nconst timerToLossFromAFK: number = 20000; // HAS TO MATCH SERVER-END\n\n/** The ID of the timeout that can be used to cancel the timer that will alert the server we are afk, if we are not no longer afk by then. */\nlet timeoutID: ReturnType<typeof setTimeout> | undefined;\n\n/** The timestamp we will lose from being AFK, if we are not no longer afk by that time. */\nlet timeWeLoseFromAFK: number | undefined;\n\n/** The timeout ID of the timer to display the next \"You are AFK...\" message. */\nlet displayAFKTimeoutID: ReturnType<typeof setTimeout> | undefined;\n\n/** The timeout ID of the timer to play the next staccato violin sound effect of the 10-second countdown to auto-resign from being afk. */\nlet playStaccatoTimeoutID: ReturnType<typeof setTimeout> | undefined;\n\n/** The timestamp our opponent will lose from being AFK, if they are not no longer afk by that time. */\nlet timeOpponentLoseFromAFK: number | undefined;\n\n/** The timeout ID of the timer to display the next \"Opponent is AFK...\" message. */\nlet displayOpponentAFKTimeoutID: ReturnType<typeof setTimeout> | undefined;\n\n// If we lost connection while displaying toast status messages of when our opponent will disconnect, stop doing that.\ndocument.addEventListener('connection-lost', () => {\n\t// Stop saying when the opponent will lose from being afk\n\tclearTimeout(displayOpponentAFKTimeoutID);\n});\n\nfunction isOurAFKAutoResignTimerRunning(): boolean {\n\t// If the time we will lose from being afk is defined, the timer is running\n\treturn timeWeLoseFromAFK !== undefined;\n}\n\nfunction onGameStart(): void {\n\t// Start the timer that will inform the server we are afk, the server thenafter starting an auto-resign timer.\n\trescheduleAlertServerWeAFK();\n}\n\nfunction onGameClose(): void {\n\t// Reset everything\n\tcancelAFKTimer();\n\ttimeoutID = undefined;\n\ttimeWeLoseFromAFK = undefined;\n\tdisplayAFKTimeoutID = undefined;\n\tplayStaccatoTimeoutID = undefined;\n\tdisplayOpponentAFKTimeoutID = undefined;\n\ttimeOpponentLoseFromAFK = undefined;\n}\n\nfunction onMovePlayed({ isOpponents }: { isOpponents: boolean }): void {\n\t// Restart the timer that will inform the server we are afk, the server thenafter starting an auto-resign timer.\n\trescheduleAlertServerWeAFK();\n\tif (isOpponents) stopOpponentAFKCountdown(); // The opponent is no longer AFK if they were)\n}\n\nfunction updateAFK(): void {\n\tif (gamefileutility.isGameOver(gameslot.getGamefile()!.basegame)) return; // Game is over\n\tif (!listener_overlay.atleastOneInput() && !listener_document.atleastOneInput()) return; // No input this frame, don't reset the timer to tell the server we are afk.\n\t// There has been mouse movement, restart the afk auto-resign timer.\n\tif (isOurAFKAutoResignTimerRunning()) tellServerWeBackFromAFK(); // Also tell the server we are back, IF it had started an auto-resign timer!\n\trescheduleAlertServerWeAFK();\n}\n\n/**\n * Restarts the timer that will inform the server we are afk,\n * the server thenafter starting an auto-resign timer.\n */\nfunction rescheduleAlertServerWeAFK(): void {\n\tclearTimeout(timeoutID);\n\tconst { basegame } = gameslot.getGamefile()!;\n\tif (\n\t\t!onlinegame.isItOurTurn() ||\n\t\tgamefileutility.isGameOver(basegame) ||\n\t\t(onlinegame.getIsPrivate() && basegame.untimed)\n\t)\n\t\treturn;\n\t// Timed resignable games cannot be auto-resigned from going afk (to make tournament games more fair)\n\tif (!basegame.untimed && moveutil.isGameResignable(basegame)) return;\n\t// Games with less than 2 moves played more-quickly start the AFK auto resign timer\n\tconst timeUntilAlertServerWeAFKSecs = !moveutil.isGameResignable(basegame)\n\t\t? timeUntilAFKSecs_Abortable\n\t\t: basegame.untimed\n\t\t\t? timeUntilAFKSecs_Untimed\n\t\t\t: timeUntilAFKSecs;\n\ttimeoutID = setTimeout(tellServerWeAFK, timeUntilAlertServerWeAFKSecs * 1000);\n}\n\nfunction cancelAFKTimer(): void {\n\tclearTimeout(timeoutID);\n\tclearTimeout(displayAFKTimeoutID);\n\tclearTimeout(playStaccatoTimeoutID);\n\tclearTimeout(displayOpponentAFKTimeoutID);\n}\n\nfunction tellServerWeAFK(): void {\n\tsocketmessages.send('game', 'AFK');\n\ttimeWeLoseFromAFK = Date.now() + timerToLossFromAFK;\n\n\t// Play lowtime alert sound\n\tgamesound.playLowtime();\n\n\t// Display on screen \"You are AFK. Auto-resigning in 20...\"\n\tdisplayWeAFK(20);\n\t// The first violin staccato note is played in 10 seconds\n\tplayStaccatoTimeoutID = setTimeout(playStaccatoNote, 10000, 'c3', 10);\n}\n\nfunction tellServerWeBackFromAFK(): void {\n\tsocketmessages.send('game', 'AFK-Return');\n\ttimeWeLoseFromAFK = undefined;\n\tclearTimeout(displayAFKTimeoutID);\n\tclearTimeout(playStaccatoTimeoutID);\n\tdisplayAFKTimeoutID = undefined;\n\tplayStaccatoTimeoutID = undefined;\n}\n\nfunction displayWeAFK(secsRemaining: number): void {\n\tconst resigningOrAborting = moveutil.isGameResignable(gameslot.getGamefile()!.basegame)\n\t\t? translations.onlinegame.auto_resigning_in\n\t\t: translations.onlinegame.auto_aborting_in;\n\ttoast.show(\n\t\t`${translations.onlinegame.afk_warning} ${resigningOrAborting} ${secsRemaining}...`,\n\t\t{ durationMillis: 1000 },\n\t);\n\tconst nextSecsRemaining = secsRemaining - 1;\n\tif (nextSecsRemaining === 0) return; // Stop\n\tconst timeRemainUntilAFKLoss = timeWeLoseFromAFK! - Date.now();\n\tconst timeToPlayNextDisplayWeAFK = timeRemainUntilAFKLoss - nextSecsRemaining * 1000;\n\tdisplayAFKTimeoutID = setTimeout(displayWeAFK, timeToPlayNextDisplayWeAFK, nextSecsRemaining);\n}\n\nfunction playStaccatoNote(note: 'c3' | 'c4', secsRemaining: number): void {\n\tif (note === 'c3') gamesound.playViola_c3();\n\telse if (note === 'c4') gamesound.playViolin_c4();\n\n\tconst nextSecsRemaining = secsRemaining > 5 ? secsRemaining - 1 : secsRemaining - 0.5;\n\tif (nextSecsRemaining === 0) return; // Stop\n\tconst nextNote = nextSecsRemaining === Math.floor(nextSecsRemaining) ? 'c3' : 'c4';\n\tconst timeRemainUntilAFKLoss = timeWeLoseFromAFK! - Date.now();\n\tconst timeToPlayNextDisplayWeAFK = timeRemainUntilAFKLoss - nextSecsRemaining * 1000;\n\tplayStaccatoTimeoutID = setTimeout(\n\t\tplayStaccatoNote,\n\t\ttimeToPlayNextDisplayWeAFK,\n\t\tnextNote,\n\t\tnextSecsRemaining,\n\t);\n}\n\nfunction startOpponentAFKCountdown(millisUntilAutoAFKResign: number): void {\n\t// Cancel the previous one if this is overwriting\n\tstopOpponentAFKCountdown();\n\n\t// Ping is round-trip time (RTT), So divided by two to get the approximate\n\t// time that has elapsed since the server sent us the correct clock values\n\tconst timeLeftMillis = millisUntilAutoAFKResign - pingManager.getHalfPing();\n\n\ttimeOpponentLoseFromAFK = Date.now() + timeLeftMillis;\n\t// How much time is left? Usually starts at 20 seconds\n\tconst secsRemaining = Math.ceil(timeLeftMillis / 1000);\n\tdisplayOpponentAFK(secsRemaining);\n}\n\nfunction stopOpponentAFKCountdown(): void {\n\tclearTimeout(displayOpponentAFKTimeoutID);\n\tdisplayOpponentAFKTimeoutID = undefined;\n}\n\nfunction displayOpponentAFK(secsRemaining: number): void {\n\tconst resigningOrAborting = moveutil.isGameResignable(gameslot.getGamefile()!.basegame)\n\t\t? translations.onlinegame.auto_resigning_in\n\t\t: translations.onlinegame.auto_aborting_in;\n\ttoast.show(\n\t\t`${translations.onlinegame.opponent_afk} ${resigningOrAborting} ${secsRemaining}...`,\n\t\t{ durationMillis: 1000 },\n\t);\n\tconst nextSecsRemaining = secsRemaining - 1;\n\tif (nextSecsRemaining === 0) return; // Stop\n\tconst timeRemainUntilAFKLoss = timeOpponentLoseFromAFK! - Date.now();\n\tconst timeToPlayNextDisplayWeAFK = timeRemainUntilAFKLoss - nextSecsRemaining * 1000;\n\tdisplayOpponentAFKTimeoutID = setTimeout(\n\t\tdisplayOpponentAFK,\n\t\ttimeToPlayNextDisplayWeAFK,\n\t\tnextSecsRemaining,\n\t);\n}\n\nexport default {\n\tonGameStart,\n\tisOurAFKAutoResignTimerRunning,\n\tonMovePlayed,\n\tupdateAFK,\n\ttimeUntilAFKSecs,\n\tonGameClose,\n\tstartOpponentAFKCountdown,\n\tstopOpponentAFKCountdown,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/misc/onlinegame/disconnect.ts",
    "content": "// src/client/scripts/esm/game/misc/onlinegame/disconnect.ts\n\n/**\n * This script displays a countdown on screen, when our opponent disconnects,\n * how much longer they have remaining until they are auto-resigned.\n *\n * If they disconnect not by choice (bad network), the server they are gives them a little\n * extra time to reconnect.\n */\n\nimport moveutil from '../../../../../../shared/chess/util/moveutil.js';\n\nimport afk from './afk.js';\nimport toast from '../../gui/toast.js';\nimport gameslot from '../../chess/gameslot.js';\nimport pingManager from '../../../util/pingManager.js';\n\n// Types ---------------------------------------------------------------\n\n/** The parameters for the opponent disconnect countdown. */\ninterface OpponentDisconnectValue {\n\tmillisUntilAutoDisconnectResign: number;\n\twasByChoice: boolean;\n}\n\n// Variables -----------------------------------------------------------------------\n\n/** The timestamp our opponent will lose from disconnection, if they don't reconnect before then. */\nlet timeOpponentLoseFromDisconnect: number | undefined;\n\n/** The timeout ID of the timer to display the next \"Opponent has disconnected...\" message. */\nlet displayOpponentDisconnectTimeoutID: ReturnType<typeof setTimeout> | undefined;\n\n/**\n * Starts the countdown for when the opponent will be auto-resigned due to disconnection.\n * This will overwrite any existing \"Opponent is AFK\" or disconnection countdowns.\n * @param params - Parameters for the countdown.\n * @param params.millisUntilAutoDisconnectResign - The number of milliseconds remaining until the opponent is auto-resigned for disconnecting.\n * @param params.wasByChoice - Indicates whether the opponent disconnected intentionally (true) or unintentionally (false).\n */\nfunction startOpponentDisconnectCountdown({\n\tmillisUntilAutoDisconnectResign,\n\twasByChoice,\n}: OpponentDisconnectValue): void {\n\t// This overwrites the \"Opponent is AFK\" timer\n\tafk.stopOpponentAFKCountdown();\n\t// Cancel the previous one if this is overwriting\n\tstopOpponentDisconnectCountdown();\n\tconst timeLeftMillis = millisUntilAutoDisconnectResign - pingManager.getHalfPing();\n\ttimeOpponentLoseFromDisconnect = Date.now() + timeLeftMillis;\n\t// How much time is left? Usually starts at 20 | 60 seconds\n\tconst secsRemaining = Math.ceil(timeLeftMillis / 1000);\n\tdisplayOpponentDisconnect(secsRemaining, wasByChoice);\n}\n\nfunction stopOpponentDisconnectCountdown(): void {\n\tclearTimeout(displayOpponentDisconnectTimeoutID);\n\tdisplayOpponentDisconnectTimeoutID = undefined;\n}\n\nfunction displayOpponentDisconnect(secsRemaining: number, wasByChoice: boolean): void {\n\tconst opponent_disconnectedOrLostConnection = wasByChoice\n\t\t? translations.onlinegame.opponent_disconnected\n\t\t: translations.onlinegame.opponent_lost_connection;\n\tconst resigningOrAborting = moveutil.isGameResignable(gameslot.getGamefile()!.basegame)\n\t\t? translations.onlinegame.auto_resigning_in\n\t\t: translations.onlinegame.auto_aborting_in;\n\t// The \"You are AFK\" message should overwrite, be on top of, this message,\n\t// so if that is running, don't display this 1-second disconnect message, but don't cancel it either!\n\tif (!afk.isOurAFKAutoResignTimerRunning())\n\t\ttoast.show(\n\t\t\t`${opponent_disconnectedOrLostConnection} ${resigningOrAborting} ${secsRemaining}...`,\n\t\t\t{ durationMillis: 1000 },\n\t\t);\n\tconst nextSecsRemaining = secsRemaining - 1;\n\tif (nextSecsRemaining === 0) return; // Stop\n\tconst timeRemainUntilDisconnectLoss = timeOpponentLoseFromDisconnect! - Date.now();\n\tconst timeToPlayNextDisplayOpponentDisconnect =\n\t\ttimeRemainUntilDisconnectLoss - nextSecsRemaining * 1000;\n\tdisplayOpponentDisconnectTimeoutID = setTimeout(\n\t\tdisplayOpponentDisconnect,\n\t\ttimeToPlayNextDisplayOpponentDisconnect,\n\t\tnextSecsRemaining,\n\t\twasByChoice,\n\t);\n}\n\nexport default {\n\tstartOpponentDisconnectCountdown,\n\tstopOpponentDisconnectCountdown,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/misc/onlinegame/drawoffers.ts",
    "content": "// src/client/scripts/esm/game/misc/onlinegame/drawoffers.ts\n\n/**\n * This script stores the logic surrounding draw extending and acceptance\n * in online games, client-side.\n *\n * It also keeps track of the last ply (half-move) we extended a draw offer,\n * if we have done so, in the current online game.\n */\n\nimport type { DrawOfferInfo } from '../../../../../../shared/types.js';\n\nimport moveutil from '../../../../../../shared/chess/util/moveutil.js';\n\nimport toast from '../../gui/toast.js';\nimport guipause from '../../gui/guipause.js';\nimport gameslot from '../../chess/gameslot.js';\nimport gamesound from '../gamesound.js';\nimport onlinegame from './onlinegame.js';\nimport guidrawoffer from '../../gui/guidrawoffer.js';\nimport socketmessages from '../../websocket/socketmessages.js';\n\n// Variables ---------------------------------------------------\n\n/**\n * Minimum number of plies (half-moves) that\n * must span between 2 consecutive draw offers\n * by the same player!\n *\n * THIS MUST ALWAYS MATCH THE SERVER-SIDE!!!!\n */\nconst movesBetweenDrawOffers: number = 2;\n\n/** The last move we extended a draw, if we have, otherwise undefined. */\nlet plyOfLastOfferedDraw: number | undefined;\n\n/** Whether we have an open draw offer FROM OUR OPPONENT */\nlet isAcceptingDraw: boolean = false;\n\n// Functions ---------------------------------------------------\n\n/**\n * Returns true if us extending a draw offer to our opponent is legal.\n */\nfunction isOfferingDrawLegal(): boolean {\n\tconst gamefile = gameslot.getGamefile()!;\n\tif (!onlinegame.areInOnlineGame()) return false; // Can't offer draws in local games\n\tif (!moveutil.isGameResignable(gamefile.basegame)) return false; // Not at least 2+ moves\n\tif (onlinegame.hasServerConcludedGame()) return false; // Can't offer draws after the game has ended\n\tif (isTooSoonToOfferDraw()) return false; // It's been too soon since our last offer\n\treturn true; // Is legal to EXTEND\n}\n\n/**\n * Returns true if it's been too soon since our last draw offer extension\n * for us to extend another one. We cannot extend them too rapidly.\n */\nfunction isTooSoonToOfferDraw(): boolean {\n\tconst gamefile = gameslot.getGamefile()!;\n\tif (plyOfLastOfferedDraw === undefined) return false; // We have made zero offers so far this game\n\n\tconst movesSinceLastOffer = gamefile.basegame.moves.length - plyOfLastOfferedDraw;\n\tif (movesSinceLastOffer < movesBetweenDrawOffers) return true;\n\treturn false;\n}\n\n/**\n * Returns *true* if we have an open draw offer from our OPPONENT.\n */\nfunction areWeAcceptingDraw(): boolean {\n\treturn isAcceptingDraw;\n}\n\n/** Is called when we receive a draw offer from our opponent */\nfunction onOpponentExtendedOffer(): void {\n\tisAcceptingDraw = true; // Needs to be set FIRST, because guidrawoffer.open() relies on it.\n\tguidrawoffer.open();\n\tgamesound.playBase();\n\tguipause.updateDrawOfferButton();\n}\n\n/** Is called when our opponent declines our draw offer */\nfunction onOpponentDeclinedOffer(): void {\n\ttoast.show(`Opponent declined draw offer.`);\n}\n\n/**\n * Extends a draw offer in our current game.\n * All legality checks have already passed!\n */\nfunction extendOffer(): void {\n\tsocketmessages.send('game', 'offerdraw');\n\tconst gamefile = gameslot.getGamefile()!;\n\tplyOfLastOfferedDraw = gamefile.basegame.moves.length;\n\ttoast.show(`Waiting for opponent to accept...`); // TODO: Needs to be localized for the user's language.\n\tguipause.updateDrawOfferButton();\n}\n\n/**\n * This fires when we click the checkmark in\n * the draw offer UI on the bottom navigation bar.\n */\nfunction callback_AcceptDraw(): void {\n\tisAcceptingDraw = false;\n\tsocketmessages.send('game', 'acceptdraw');\n\tguidrawoffer.close();\n\tguipause.updateDrawOfferButton();\n}\n\n/**\n * This fires when we click the X-mark in\n * the draw offer UI on the bottom navigation bar,\n * or when we click \"Accept Draw\" in the pause menu!\n * @param [options] - Optional settings.\n * @param [options.informServer=true] - If true, the server will be informed that the draw offer has been declined.\n * We'll want to set this to false if we call this after making a move, because the server auto-declines it.\n */\nfunction callback_declineDraw(): void {\n\tif (!isAcceptingDraw) return; // No open draw offer from our opponent\n\tcloseDraw();\n\t// Notify the server\n\tsocketmessages.send('game', 'declinedraw');\n\ttoast.show(`Draw declined`); // TODO: This needs to be localized to the user's language\n}\n\n/**\n * Closes the current draw offer, if there is one, from our opponent.\n * This does NOT notify the server.\n */\nfunction closeDraw(): void {\n\tif (!isAcceptingDraw) return; // No open draw offer from our opponent\n\tguidrawoffer.close();\n\tisAcceptingDraw = false;\n}\n\n/**\n * Set the current draw offer values according to the information provided.\n * This is called after a page refresh when we're in a game.\n */\nfunction set(drawOffer: DrawOfferInfo): void {\n\tplyOfLastOfferedDraw = drawOffer.lastOfferPly;\n\tif (!drawOffer.unconfirmed) return; // No open draw offer\n\t// Open draw offer!!\n\tonOpponentExtendedOffer();\n}\n\n/** Called whenever a move is played in an online game */\nfunction onMovePlayed({ isOpponents }: { isOpponents: boolean }): void {\n\t// Declines any open draw offer from our opponent. We don't need to inform\n\t// the server because the server knows to auto decline when we submit our move.\n\tif (!isOpponents) closeDraw();\n}\n\n/**\n * Called when an online game concludes or is closed. Closes any open draw\n * offer and resets all draw for values for future games.\n */\nfunction onGameClose(): void {\n\tplyOfLastOfferedDraw = undefined;\n\tisAcceptingDraw = false;\n\tguidrawoffer.close();\n\tguipause.updateDrawOfferButton();\n}\n\nexport default {\n\tisOfferingDrawLegal,\n\tareWeAcceptingDraw,\n\tcallback_AcceptDraw,\n\tcallback_declineDraw,\n\tonOpponentExtendedOffer,\n\tonOpponentDeclinedOffer,\n\textendOffer,\n\tset,\n\tonMovePlayed,\n\tonGameClose,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/misc/onlinegame/movesendreceive.ts",
    "content": "// src/client/scripts/esm/game/misc/onlinegame/movesendreceive.ts\n\n/**\n * This script handles sending our move in online games to the server,\n * and receiving moves from our opponent.\n */\n\nimport type { Mesh } from '../../rendering/piecemodels.js';\nimport type { FullGame } from '../../../../../../shared/chess/logic/gamefile.js';\nimport type { MoveTagged } from '../../../../../../shared/chess/logic/movepiece.js';\nimport type { MoveValidationResult } from '../../../../../../shared/chess/logic/movevalidation.js';\nimport type { ClockValues, OpponentsMoveMessage } from '../../../../../../shared/types.js';\n\nimport clock from '../../../../../../shared/chess/logic/clock.js';\nimport moveutil from '../../../../../../shared/chess/util/moveutil.js';\nimport icnconverter from '../../../../../../shared/chess/logic/icn/icnconverter.js';\nimport movevalidation from '../../../../../../shared/chess/logic/movevalidation.js';\nimport gamefileutility from '../../../../../../shared/chess/util/gamefileutility.js';\nimport { isGameInstantlyDeleted } from '../../../../../../shared/chess/variants/servervalidation.js';\n\nimport gameslot from '../../chess/gameslot.js';\nimport guiclock from '../../gui/guiclock.js';\nimport premoves from '../../chess/premoves.js';\nimport guipause from '../../gui/guipause.js';\nimport selection from '../../chess/selection.js';\nimport socketsubs from '../../websocket/socketsubs.js';\nimport onlinegame from './onlinegame.js';\nimport { GameBus } from '../../GameBus.js';\nimport movesequence from '../../chess/movesequence.js';\nimport socketmessages from '../../websocket/socketmessages.js';\n\n// Events ---------------------------------------------------------------------\n\nGameBus.addEventListener('user-move-played', () => {\n\tsendMove();\n});\n\n// Functions -------------------------------------------------------------------\n\n/**\n * Called when selection.js moves a piece. This will send it to the server\n * if we're in an online game.\n */\nfunction sendMove(): void {\n\tif (\n\t\t!onlinegame.areInOnlineGame() ||\n\t\t!onlinegame.areInSync() ||\n\t\t!socketsubs.areSubbedToSub('game')\n\t)\n\t\treturn; // Skip\n\t// console.log(\"Sending our move..\");\n\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst lastMove = moveutil.getLastMove(gamefile.boardsim.moves)!;\n\tconst moveToken = lastMove.token; // \"x,y>x,yN\"\n\n\tconst data = {\n\t\tmove: moveToken,\n\t\tmoveNumber: gamefile.basegame.moves.length,\n\t\tgameConclusion: gamefile.basegame.gameConclusion,\n\t};\n\n\tsocketmessages.send('game', 'submitmove', data, true);\n\n\tonlinegame.onMovePlayed({ isOpponents: false });\n}\n\n/**\n * Called when we received our opponents move. This verifies they're move\n * and claimed game conclusion is legal. If it isn't, it reports them and doesn't forward their move.\n * If it is legal, it forwards the game to the front, then forwards their move.\n */\nfunction handleOpponentsMove(\n\tgamefile: FullGame,\n\tmesh: Mesh | undefined,\n\tmessage: OpponentsMoveMessage,\n): void {\n\t// Make sure the move number matches the expected.\n\tconst expectedMoveNumber = gamefile.boardsim.moves.length + 1;\n\tif (message.moveNumber !== expectedMoveNumber) {\n\t\t// A desync happened\n\t\tconsole.error(\n\t\t\t`We have desynced from the game. Resyncing. Expected opponent's move number: ${expectedMoveNumber}. Actual: ${message.moveNumber}. Opponent's move: ${JSON.stringify(message.move)}. Move number: ${message.moveNumber}`,\n\t\t);\n\t\treturn onlinegame.resyncToGame();\n\t}\n\n\t// Convert the move from compact short format \"x,y>x,y=N\" to JSON.\n\t// Gauranteed by the server to be parsable.\n\tconst moveTagged: MoveTagged = icnconverter.parseTokenMove(message.move.token);\n\n\tpremoves.performWithUnapplied(gamefile, mesh, () => {\n\t\t// If not legal, this will be a string for why it is illegal.\n\t\t// THIS ATTACHES ANY SPECIAL TAGS TO THE MOVE\n\t\tconst moveValidationResult = movevalidation.isOpponentsMoveLegal(\n\t\t\tgamefile,\n\t\t\tmoveTagged,\n\t\t\tmessage.gameConclusion,\n\t\t);\n\n\t\t// Only report cheating when the server won't delete the game instantly.\n\t\tif (\n\t\t\tcheckAndReportIllegalOpponentMove(\n\t\t\t\tgamefile,\n\t\t\t\tmoveValidationResult,\n\t\t\t\tmessage.move.token,\n\t\t\t\tmessage.moveNumber,\n\t\t\t)\n\t\t) {\n\t\t\treturn false; // Don't physically play next premove\n\t\t}\n\n\t\t// At this stage, the move is legal, or allowed anyway in a private game. Apply it.\n\n\t\t// Go to latest move before making a new move\n\t\tmovesequence.viewFront(gamefile, mesh);\n\n\t\tmovesequence.makeMoveAndAnimate(gamefile, mesh, moveTagged);\n\n\t\t// Edit the clocks\n\n\t\tconst { basegame } = gamefile;\n\n\t\t// Adjust the timer whos turn it is depending on ping.\n\t\tapplyClockValues(gamefile, message.clockValues);\n\n\t\t// For online games, the server is boss, so if they say the game is over, conclude it here.\n\t\tif (gamefileutility.isGameOver(basegame)) gameslot.concludeGame();\n\n\t\tonlinegame.onMovePlayed({ isOpponents: true });\n\t\tguipause.onReceiveOpponentsMove(); // Update the pause screen buttons\n\n\t\treturn true; // Good to physically play next premove\n\t});\n\n\tselection.reselectPiece(); // Reselect the currently selected piece. Recalc its moves and recolor it if needed.\n}\n\n/**\n * Logs an illegal opponent move and reports it to the server if the game warrants it.\n * @param moveValidationResult - The result of move validation (may be valid or invalid).\n * @param tokenMove - The move in compact string format, used for logging.\n * @param moveNumber - The move number, used for logging.\n * @returns Whether the move was illegal and was reported.\n */\nfunction checkAndReportIllegalOpponentMove(\n\tgamefile: FullGame,\n\tmoveValidationResult: MoveValidationResult,\n\ttokenMove: string,\n\tmoveNumber: number,\n): boolean {\n\tif (moveValidationResult.valid) return false;\n\n\tconsole.log(\n\t\t`Buddy made an illegal play: \"${tokenMove}\". Reason: ${moveValidationResult.reason} Move number: ${moveNumber}`,\n\t);\n\n\tif (\n\t\t!isGameInstantlyDeleted(\n\t\t\tgamefile.boardsim.variant,\n\t\t\tgamefile.basegame.dateTimestamp,\n\t\t\tonlinegame.getIsPrivate(),\n\t\t)\n\t) {\n\t\tonlinegame.reportOpponentsMove(moveValidationResult.reason);\n\t\treturn true;\n\t}\n\n\treturn false; // Private or server-validated game — allow through without reporting\n}\n\n/** Adjusts received clock values for ping and applies them to the game, if provided. */\nfunction applyClockValues(gamefile: FullGame, clockValues: ClockValues | undefined): void {\n\tif (!clockValues) return;\n\tif (gamefile.basegame.untimed) {\n\t\tconsole.warn('Received clock values for untimed game??');\n\t\treturn;\n\t}\n\tclockValues = onlinegame.adjustClockValuesForPing(clockValues);\n\tclock.edit(gamefile.basegame.clocks, clockValues);\n\tguiclock.edit(gamefile.basegame);\n}\n\n// Exports -------------------------------------------------------------------\n\nexport default {\n\tsendMove,\n\thandleOpponentsMove,\n\tcheckAndReportIllegalOpponentMove,\n\tapplyClockValues,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/misc/onlinegame/onlinegame.ts",
    "content": "// src/client/scripts/esm/game/misc/onlinegame/onlinegame.ts\n\n/**\n * This module keeps trap of the data of the onlinegame we are currently in.\n */\n\nimport type { ServerGameInfo } from '../../websocket/socketschemas.js';\nimport type { Player, PlayerGroup } from '../../../../../../shared/chess/util/typeutil.js';\nimport type { ClockValues, ParticipantState, Rating } from '../../../../../../shared/types.js';\n\nimport moveutil from '../../../../../../shared/chess/util/moveutil.js';\nimport gamefileutility from '../../../../../../shared/chess/util/gamefileutility.js';\nimport { isGameInstantlyDeleted } from '../../../../../../shared/chess/variants/servervalidation.js';\n\nimport afk from './afk.js';\nimport gameslot from '../../chess/gameslot.js';\nimport IndexedDB from '../../../util/IndexedDB.js';\nimport socketsubs from '../../websocket/socketsubs.js';\nimport disconnect from './disconnect.js';\nimport drawoffers from './drawoffers.js';\nimport pingManager from '../../../util/pingManager.js';\nimport { GameBus } from '../../GameBus.js';\nimport tabnameflash from './tabnameflash.js';\nimport socketmessages from '../../websocket/socketmessages.js';\n\n// Variables ------------------------------------------------------------------------------------------------------\n\n/** Whether or not we are currently in an online game. */\nlet inOnlineGame: boolean = false;\n\n/** The id of the online game we are in, if we are in one. */\nlet id: number | undefined;\n\n/**\n * Whether the game is a private one (joined from an invite code).\n */\nlet isPrivate: boolean | undefined;\n\n/**\n * Whether the game is rated.\n */\nlet rated: boolean | undefined;\n\n/**\n * The color we are in the online game, if we are in it.\n */\nlet ourColor: Player | undefined;\n\n/**\n * The ratings of the non-guest players in the game.\n * If the variant doesn't have a leaderboard, we fall back to the INFINITY leaderboard.\n */\nlet playerRatings: PlayerGroup<Rating> | undefined;\n\n/**\n * Different from gamefile.basegame.gameConclusion, because this is only true if {@link gamefileutility.concludeGame}\n * has been called, which IS ONLY called once the SERVER tells us the result of the game, not us!\n */\nlet serverHasConcludedGame: boolean | undefined;\n\n/**\n * Different from gamefile.basegame.gameConclusion, because this is true if the player has pressed the \"Resign/Abort\" button at some time during this game,\n * and NOT if the SERVER tells us that the game is concluded.\n */\nlet playerHasPressedAbortOrResignButton: boolean | undefined;\n\n/**\n * Whether we are in sync with the game on the server.\n * If false, we do not submit our move. (move will be auto-submitted upon resyncing)\n * Set to false whenever we lose connection, or the socket closes.\n * Set to true whenever we join game, or successfully resync.\n *\n * If we aren't subbed to a game, then it's automatically assumed we are out of sync.\n */\nlet inSync: boolean | undefined;\n\n// Events ------------------------------------------------------------------------------------------------------\n\nGameBus.addEventListener('game-concluded', () => {\n\tif (!inOnlineGame) return; // The game concluded wasn't an online game.\n\n\tserverHasConcludedGame = true; // This NEEDS to be above drawoffers.onGameClose(), as that relies on this!\n\tafk.onGameClose();\n\ttabnameflash.onGameClose();\n\tdeleteCustomVariantOptions();\n\tdrawoffers.onGameClose();\n\trequestRemovalFromPlayersInActiveGames();\n});\n\n// Getters --------------------------------------------------------------------------------------------------------------\n\nfunction areInOnlineGame(): boolean {\n\treturn inOnlineGame;\n}\n\n/** Returns the game id of the online game we're in.  */\nfunction getGameID(): number {\n\tif (!inOnlineGame)\n\t\tthrow Error(\"Cannot get id of online game when we're not in an online game.\");\n\treturn id!;\n}\n\nfunction getIsPrivate(): boolean {\n\tif (!inOnlineGame)\n\t\tthrow Error(\"Cannot get isPrivate of online game when we're not in an online game.\");\n\treturn isPrivate!;\n}\n\nfunction isRated(): boolean {\n\tif (!inOnlineGame) throw Error(\"Cannot ask if online game is rated when we're not in one.\");\n\treturn rated!;\n}\n\n/** Returns whether we are one of the players in the online game. */\nfunction doWeHaveRole(): boolean {\n\tif (!inOnlineGame)\n\t\tthrow Error(\n\t\t\t\"Cannot ask if we have a role in online game when we're not in an online game.\",\n\t\t);\n\treturn ourColor !== undefined;\n}\n\nfunction getOurColor(): Player | undefined {\n\tif (!inOnlineGame)\n\t\tthrow Error(\"Cannot get color we are in online game when we're not in an online game.\");\n\treturn ourColor;\n}\n\nfunction getPlayerRatings(): PlayerGroup<Rating> | undefined {\n\tif (!inOnlineGame) throw Error(\"Cannot get player ratings when we're not in an online game.\");\n\treturn playerRatings;\n}\n\nfunction areWeColorInOnlineGame(color: Player): boolean {\n\tif (!inOnlineGame) return false; // Can't be that color, because we aren't even in a game.\n\treturn ourColor === color;\n}\n\nfunction isItOurTurn(): boolean {\n\tif (!inOnlineGame)\n\t\tthrow Error(\"Cannot get isItOurTurn of online game when we're not in an online game.\");\n\treturn gameslot.getGamefile()!.basegame.whosTurn === ourColor;\n}\n\n/** Whether we have pressed the Abort/Resign game button this game. NOT when it says main menu. */\nfunction hasPlayerPressedAbortOrResignButton(): boolean {\n\tif (!inOnlineGame)\n\t\tthrow Error(\n\t\t\t\"Cannot get playerHasPressedAbortOrResignButton of online game when we're not in an online game.\",\n\t\t);\n\treturn playerHasPressedAbortOrResignButton!;\n}\n\nfunction areInSync(): boolean {\n\tif (!inOnlineGame)\n\t\tthrow Error(\"Cannot get inSync of online game when we're not in an online game.\");\n\treturn inSync!;\n}\n\n/**\n * Different from {@link gamefileutility.isGameOver}, because this only returns true if {@link gamefileutility.concludeGame}\n * has been called, which IS ONLY called once the SERVER tells us the result of the game, not us!\n */\nfunction hasServerConcludedGame(): boolean {\n\tif (!inOnlineGame)\n\t\tthrow Error(\n\t\t\t\"Cannot get serverHasConcludedGame of online game when we're not in an online game.\",\n\t\t);\n\treturn serverHasConcludedGame!;\n}\n\nfunction setInSyncTrue(): void {\n\tinSync = true;\n}\n\nfunction setInSyncFalse(): void {\n\tif (!inOnlineGame) return;\n\tinSync = false;\n}\n\n// Functions ------------------------------------------------------------------------------------------------------\n\nfunction initOnlineGame(options: {\n\tgameInfo: ServerGameInfo;\n\t/** Specify if we are a participant in the game, not a spectator. */\n\tyouAreColor?: Player;\n\t/** Only provide if we're a participant of an ongoing game, not a spectator, or when the game is over! */\n\tparticipantState?: ParticipantState;\n}): void {\n\tinOnlineGame = true;\n\tinSync = true;\n\n\t// Set static game properties that never change\n\tid = options.gameInfo.id;\n\trated = options.gameInfo.rated;\n\tisPrivate = options.gameInfo.publicity === 'private';\n\tplayerRatings = options.gameInfo.playerRatings;\n\n\tourColor = options.youAreColor;\n\n\t// If we are a participator, set the draw offers, disconnect timer, afk auto resign timer.\n\tset_DrawOffers_DisconnectInfo_AutoAFKResign(options.participantState);\n\n\tafk.onGameStart();\n\ttabnameflash.onGameStart({ isOurMove: isItOurTurn() });\n\n\tserverHasConcludedGame = false;\n\tplayerHasPressedAbortOrResignButton = false;\n\n\tinitEventListeners();\n}\n\nfunction set_DrawOffers_DisconnectInfo_AutoAFKResign(participantState?: ParticipantState): void {\n\tif (participantState) {\n\t\tdrawoffers.set(participantState.drawOffer);\n\n\t\t// If opponent is currently disconnected, display that countdown\n\t\tif (participantState.disconnect)\n\t\t\tdisconnect.startOpponentDisconnectCountdown(participantState.disconnect);\n\t\telse disconnect.stopOpponentDisconnectCountdown();\n\n\t\t// If Opponent is currently afk, display that countdown\n\t\tif (participantState.millisUntilAutoAFKResign !== undefined)\n\t\t\tafk.startOpponentAFKCountdown(participantState.millisUntilAutoAFKResign);\n\t\telse afk.stopOpponentAFKCountdown();\n\t}\n}\n\n// Call when we leave an online game\nfunction closeOnlineGame(): void {\n\tinOnlineGame = false;\n\tid = undefined;\n\tisPrivate = undefined;\n\trated = undefined;\n\tourColor = undefined;\n\tinSync = undefined;\n\tserverHasConcludedGame = undefined;\n\tplayerHasPressedAbortOrResignButton = undefined;\n\tafk.onGameClose();\n\tdisconnect.stopOpponentDisconnectCountdown();\n\ttabnameflash.onGameClose();\n\tdrawoffers.onGameClose();\n\tcloseEventListeners();\n}\n\nfunction initEventListeners(): void {\n\t// Add the event listeners for when we lose connection or the socket closes,\n\t// to set our inSync variable to false\n\tdocument.addEventListener('connection-lost', setInSyncFalse); // Custom event\n\tdocument.addEventListener('socket-closed', setInSyncFalse); // Custom event\n\n\t/**\n\t * Leave-game warning popups on every hyperlink.\n\t *\n\t * Add an listener for every single hyperlink on the page that will\n\t * confirm to us if we actually want to leave if we are in an online game.\n\t */\n\tdocument.querySelectorAll('a').forEach((link) => {\n\t\tlink.addEventListener('click', confirmNavigationAwayFromGame);\n\t});\n}\n\nfunction closeEventListeners(): void {\n\tdocument.removeEventListener('connection-lost', setInSyncFalse);\n\tdocument.removeEventListener('socket-closed', setInSyncFalse);\n\tdocument.querySelectorAll('a').forEach((link) => {\n\t\tlink.removeEventListener('click', confirmNavigationAwayFromGame);\n\t});\n}\n\n/**\n * Confirm that the user DOES actually want to leave the page if they are in an online game.\n *\n * Sometimes they could leave by accident, or even hit the \"Logout\" button by accident,\n * which just ejects them out of the game\n * @param event\n */\nfunction confirmNavigationAwayFromGame(event: MouseEvent): void {\n\t// Check if Command (Meta) or Ctrl key is held down\n\tif (event.metaKey || event.ctrlKey) return; // Allow opening in a new tab without confirmation\n\tif (gamefileutility.isGameOver(gameslot.getGamefile()!.basegame)) return;\n\n\tconst userConfirmed = confirm('Are you sure you want to leave the game?');\n\tif (userConfirmed) return; // Follow link like normal. Server then starts a 20-second auto-resign timer for disconnecting on purpose.\n\t// Cancel the following of the link.\n\tevent.preventDefault();\n\n\t/*\n\t * KEEP IN MIND that if we leave the pop-up open for 10 seconds,\n\t * JavaScript is frozen in that timeframe, which means as\n\t * far as the server can tell we're not communicating anymore,\n\t * so it automatically closes our websocket connection,\n\t * thinking we've disconnected, and starts a 60-second auto-resign timer.\n\t *\n\t * As soon as we hit cancel, we are communicating again.\n\t */\n}\n\nfunction update(): void {\n\tafk.updateAFK();\n}\n\n/**\n * Requests a game update from the server, since we are out of sync.\n */\nfunction resyncToGame(): void {\n\tif (!inOnlineGame) throw Error(\"Don't call resyncToGame() if not in an online game.\");\n\tinSync = false;\n\tsocketmessages.send('game', 'resync', id!);\n}\n\nfunction onMovePlayed({ isOpponents }: { isOpponents: boolean }): void {\n\t// Inform all the scripts that rely on online game\n\t// logic that a move occurred, so they can update accordingly\n\tafk.onMovePlayed({ isOpponents });\n\ttabnameflash.onMovePlayed({ isOpponents });\n\tdrawoffers.onMovePlayed({ isOpponents });\n}\n\nfunction reportOpponentsMove(reason: string): void {\n\t// Send the move number of the opponents move so that there's no mixup of which move we claim is illegal.\n\tconst opponentsMoveNumber = gameslot.getGamefile()!.basegame.moves.length + 1;\n\n\tconst message = {\n\t\treason,\n\t\topponentsMoveNumber,\n\t};\n\n\tsocketmessages.send('game', 'report', message);\n}\n\n/**  Called when the player presses the \"Abort / Resign\" button for the first time in an onlinegame. */\nfunction onAbortOrResignButtonPress(): void {\n\tif (!inOnlineGame) return;\n\tif (serverHasConcludedGame) return; // Don't need to abort/resign, game is already over\n\tif (playerHasPressedAbortOrResignButton) return; // Don't need to abort/resign, we have already done this during this game\n\n\tplayerHasPressedAbortOrResignButton = true;\n\n\tconst gamefile = gameslot.getGamefile()!;\n\tif (moveutil.isGameResignable(gamefile.basegame)) socketmessages.send('game', 'resign');\n\telse socketmessages.send('game', 'abort');\n}\n\n/**\n * Called when the player presses the \"Main Menu\" button in an onlinegame\n * This can happen if the game is already over or if the player has already pressed the \"Abort / Resign\" button.\n * This requests the server to stop serving us game updates, and allow us to join a new game.\n */\nfunction onMainMenuButtonPress(): void {\n\t// MUST BE BEFORE UNSUBBING, since the code will skip\n\t// sending this message if we are not subbed.\n\t// This allows us to join a new game.\n\t// Basically tells the server we don't want to see the game conclusion.\n\trequestRemovalFromPlayersInActiveGames();\n\n\t// Tell the server we no longer want game updates.\n\tsocketsubs.unsubFromSub('game');\n}\n\nfunction deleteCustomVariantOptions(): void {\n\t// Delete any custom pasted position in a private game.\n\tif (isPrivate) {\n\t\tconst storageKey = getKeyForOnlineGameVariantOptions(id!);\n\t\tIndexedDB.deleteItem(storageKey);\n\t}\n}\n\n/**\n * Lets the server know we have seen the game conclusion, and would\n * like to be allowed to join a new game if we leave quickly.\n *\n * THIS SHOULD ALSO be the point when the server knows we agree\n * with the resulting game conclusion (no cheating detected),\n * and the server may change the players elos!\n */\nfunction requestRemovalFromPlayersInActiveGames(): void {\n\tif (!areInOnlineGame()) return;\n\tif (!socketsubs.areSubbedToSub('game')) {\n\t\t// THE SERVER has deleted the game. Already removed from players in active games list!\n\t\t// console.log(\"Not sending request to remove from players in active games, because we are not subbed to the game.\");\n\t\treturn;\n\t}\n\n\t// Don't send this request if the server will have deleted this game instantly.\n\tconst { basegame, boardsim } = gameslot.getGamefile()!;\n\tif (isGameInstantlyDeleted(boardsim.variant, basegame.dateTimestamp, isPrivate!)) return;\n\tsocketmessages.send('game', 'removefromplayersinactivegames');\n}\n\n/**\n * Modifies the clock values to account for ping.\n */\nfunction adjustClockValuesForPing(clockValues: ClockValues): ClockValues {\n\tif (!clockValues.colorTicking) return clockValues; // No clock is ticking (< 2 moves, or game is over), don't adjust for ping\n\n\t// console.log(`Adjusting clock values for ping. Ping is ${pingManager.getPing()}.`);\n\n\t// Ping is round-trip time (RTT), So divided by two to get the approximate\n\t// time that has elapsed since the server sent us the correct clock values\n\tconst halfPing = pingManager.getHalfPing();\n\tif (halfPing > 2500)\n\t\tconsole.error(\n\t\t\t'Ping is above 5000 milliseconds!!! This is a lot to adjust the clock values!',\n\t\t);\n\t// console.log(`Ping is ${halfPing * 2}. Subtracted ${halfPing} millis from ${clockValues.colorTicking}'s clock.`);\n\n\tif (clockValues.clocks[clockValues.colorTicking] === undefined)\n\t\tthrow Error(\n\t\t\t`Invalid color \"${clockValues.colorTicking}\" to modify clock value to account for ping.`,\n\t\t);\n\tclockValues.clocks[clockValues.colorTicking]! -= halfPing;\n\n\t// Flag what time the player who's clock is ticking will lose on time.\n\t// Do this because while while the gamefile is being constructed, the time left may become innacurate.\n\tclockValues.timeColorTickingLosesAt =\n\t\tDate.now() + clockValues.clocks[clockValues.colorTicking]!;\n\n\treturn clockValues;\n}\n\n/**\n * Returns the key that's put in local storage to store the variant options\n * of the current online game, if we have pasted a position in a private match.\n */\nfunction getKeyForOnlineGameVariantOptions(gameID: number): string {\n\treturn `online-game-variant-options${gameID}`;\n}\n\n// Exports -------------------------------------------------------------------------\n\nexport default {\n\tonmessage,\n\tgetGameID,\n\tgetIsPrivate,\n\tisRated,\n\tdoWeHaveRole,\n\tgetOurColor,\n\tgetPlayerRatings,\n\tsetInSyncTrue,\n\tinitOnlineGame,\n\tset_DrawOffers_DisconnectInfo_AutoAFKResign,\n\tcloseOnlineGame,\n\tisItOurTurn,\n\thasPlayerPressedAbortOrResignButton,\n\tareInSync,\n\tresyncToGame,\n\tupdate,\n\tonAbortOrResignButtonPress,\n\tonMainMenuButtonPress,\n\thasServerConcludedGame,\n\treportOpponentsMove,\n\tonMovePlayed,\n\tareInOnlineGame,\n\tareWeColorInOnlineGame,\n\tadjustClockValuesForPing,\n\tgetKeyForOnlineGameVariantOptions,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/misc/onlinegame/onlinegamerouter.ts",
    "content": "// src/client/scripts/esm/game/misc/onlinegame/onlinegamerouter.ts\n\nimport type { Game } from '../../../../../../shared/chess/logic/gamefile.js';\nimport type { Condition } from '../../../../../../shared/chess/util/winconutil.js';\nimport type { PlayerGroup } from '../../../../../../shared/chess/util/typeutil.js';\nimport type { GamesRecord } from '../../../../../../server/database/gamesManager.js';\nimport type { LongFormatOut } from '../../../../../../shared/chess/logic/icn/icnconverter.js';\nimport type { GameMessage, JoinGameMessage } from '../../websocket/socketschemas.js';\nimport type { ClockValues, MovePacket, Rating } from '../../../../../../shared/types.js';\n\nimport uuid from '../../../../../../shared/util/uuid.js';\nimport clock from '../../../../../../shared/chess/logic/clock.js';\nimport icnconverter from '../../../../../../shared/chess/logic/icn/icnconverter.js';\nimport gamefileutility from '../../../../../../shared/chess/util/gamefileutility.js';\nimport { players as p, Player } from '../../../../../../shared/chess/util/typeutil.js';\n\nimport afk from './afk.js';\nimport toast from '../../gui/toast.js';\nimport board from '../../rendering/boardtiles.js';\nimport guiplay from '../../gui/guiplay.js';\nimport resyncer from './resyncer.js';\nimport gameslot from '../../chess/gameslot.js';\nimport guititle from '../../gui/guititle.js';\nimport guiclock from '../../gui/guiclock.js';\nimport selection from '../../chess/selection.js';\nimport disconnect from './disconnect.js';\nimport drawoffers from './drawoffers.js';\nimport gameloader from '../../chess/gameloader.js';\nimport onlinegame from './onlinegame.js';\nimport socketsubs from '../../websocket/socketsubs.js';\nimport guigameinfo from '../../gui/guigameinfo.js';\nimport validatorama from '../../../util/validatorama.js';\nimport movesendreceive from './movesendreceive.js';\nimport clientmetadatautil from '../../chess/clientmetadatautil.js';\n\n// Types -------------------------------------------------------------------------------------------------\n\n/** The game info of an ended game from the database, as sent by the server. */\ntype LoggedGameInfo = Required<\n\tPick<GamesRecord, 'game_id' | 'rated' | 'private' | 'termination' | 'icn'>\n>;\n\n// Routers --------------------------------------------------------------------------------------\n\n/**\n * Routes a server websocket message with subscription marked `game`.\n * This handles all messages related to the active game we're in.\n * @param contents - The contents of the incoming server websocket message\n */\nfunction routeMessage(contents: GameMessage): void {\n\t// console.log(`Received ${contents.action} from server! Message contents:`)\n\t// console.log(contents.value)\n\n\t// These actions are listened to, even when we're not in a game.\n\n\tif (contents.action === 'joingame') return handleJoinGame(contents.value);\n\telse if (contents.action === 'logged-game-info') return handleLoggedGameInfo(contents.value);\n\n\t// All other actions should be ignored if we're not in a game...\n\n\tif (!onlinegame.areInOnlineGame()) {\n\t\tconsole.log(\n\t\t\t`Received server 'game' message when we're not in an online game. Ignoring. Message: ${JSON.stringify(contents)}`,\n\t\t);\n\t\treturn;\n\t}\n\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst mesh = gameslot.getMesh();\n\n\tswitch (contents.action) {\n\t\tcase 'move':\n\t\t\tmovesendreceive.handleOpponentsMove(gamefile, mesh, contents.value);\n\t\t\tbreak;\n\t\tcase 'clock':\n\t\t\thandleUpdatedClock(gamefile.basegame, contents.value);\n\t\t\tbreak;\n\t\tcase 'gameupdate':\n\t\t\tresyncer.handleServerGameUpdate(gamefile, mesh, contents.value);\n\t\t\tbreak;\n\t\tcase 'gameratingchange':\n\t\t\tguigameinfo.addRatingChangeToExistingUsernameContainers(contents.value);\n\t\t\tbreak;\n\t\tcase 'unsub':\n\t\t\thandleUnsubbing();\n\t\t\tbreak;\n\t\tcase 'login':\n\t\t\thandleLogin(gamefile.basegame);\n\t\t\tbreak;\n\t\tcase 'nogame':\n\t\t\thandleNoGame(gamefile.basegame);\n\t\t\tbreak;\n\t\tcase 'leavegame':\n\t\t\thandleLeaveGame();\n\t\t\tbreak;\n\t\tcase 'opponentafk':\n\t\t\tafk.startOpponentAFKCountdown(contents.value.millisUntilAutoAFKResign);\n\t\t\tbreak;\n\t\tcase 'opponentafkreturn':\n\t\t\tafk.stopOpponentAFKCountdown();\n\t\t\tbreak;\n\t\tcase 'opponentdisconnect':\n\t\t\tdisconnect.startOpponentDisconnectCountdown(contents.value);\n\t\t\tbreak;\n\t\tcase 'opponentdisconnectreturn':\n\t\t\tdisconnect.stopOpponentDisconnectCountdown();\n\t\t\tbreak;\n\t\tcase 'drawoffer':\n\t\t\tdrawoffers.onOpponentExtendedOffer();\n\t\t\tbreak;\n\t\tcase 'declinedraw':\n\t\t\tdrawoffers.onOpponentDeclinedOffer();\n\t\t\tbreak;\n\t\tdefault:\n\t\t\ttoast.show(\n\t\t\t\t// @ts-ignore\n\t\t\t\t`Unknown action \"${contents.action}\" received from server in 'game' route.`,\n\t\t\t\t{ error: true },\n\t\t\t);\n\t\t\tbreak;\n\t}\n}\n\n/**\n * Joins a game when the server tells us we are now in one.\n *\n * This happens when we click an invite, or our invite is accepted.\n *\n * This type of message contains the MOST information about the game.\n * Less then \"gameupdate\"s, or resyncing.\n */\nfunction handleJoinGame(message: JoinGameMessage): void {\n\t// We were auto-unsubbed from the invites list, BUT we want to keep open the socket!!\n\tsocketsubs.deleteSub('invites');\n\tsocketsubs.addSub('game');\n\tguititle.close();\n\tguiplay.close();\n\t// If the clock values are present, adjust them for ping.\n\tif (message.clockValues)\n\t\tmessage.clockValues = onlinegame.adjustClockValuesForPing(message.clockValues);\n\tgameloader.startOnlineGame(message);\n}\n\n/**\n * Called when the server sends us the game info of an ENDED game inside the database.\n * This loads it, even if we didn't participate in the game, and immediately concludes it.\n * @param message - The message from the server containing the game info.\n */\nfunction handleLoggedGameInfo(message: LoggedGameInfo): void {\n\tlet parsedGame: LongFormatOut;\n\ttry {\n\t\tparsedGame = icnconverter.ShortToLong_Format(message.icn);\n\t} catch (e) {\n\t\t// Hmm, this isn't good. Why is a server-sent ICN crashing?\n\t\tconsole.error(e);\n\t\ttoast.show(\n\t\t\t'There was an error processing the game ICN sent from the server. This is a bug, please report!',\n\t\t\t{ error: true },\n\t\t);\n\t\treturn;\n\t}\n\n\t// Unload the currently loaded game, if we are in one\n\tif (gameloader.areInAGame()) {\n\t\tgameloader.unloadGame();\n\t\tsocketsubs.deleteSub('game'); // The server will have already unsubscribed us from the previous game.\n\t} // Else perhaps we need to close the title screen?? Or the loading screen??\n\n\t// Are we one of the players (automatically no, if there's only guests)\n\tconst ourUserId: number | undefined = validatorama.getOurUserId();\n\tconst whiteId: number | undefined = parsedGame.metadata.WhiteID\n\t\t? uuid.base62ToBase10(parsedGame.metadata.WhiteID)\n\t\t: undefined;\n\tconst blackId: number | undefined = parsedGame.metadata.BlackID\n\t\t? uuid.base62ToBase10(parsedGame.metadata.BlackID)\n\t\t: undefined;\n\t// prettier-ignore\n\tconst ourRole: Player | undefined = ourUserId !== undefined ? (ourUserId === whiteId ? p.WHITE : ourUserId === blackId ? p.BLACK : undefined) : undefined;\n\n\t// The clock values are already ingrained into the moves!\n\t// prettier-ignore\n\tconst moves: MovePacket[] = parsedGame.moves ? parsedGame.moves.map(m => {\n\t\tconst move: { token: string, clockStamp?: number } = { token: m.token };\n\t\t\t\tif (m.clockStamp !== undefined) move.clockStamp = m.clockStamp;\n\t\t\t\treturn move;\n\t}) : [];\n\n\t// Display elo ratings, if any.\n\tconst playerRatings: PlayerGroup<Rating> = {};\n\tif (parsedGame.metadata.WhiteElo)\n\t\tplayerRatings[p.WHITE] = clientmetadatautil.getRatingFromWhiteBlackElo(\n\t\t\tparsedGame.metadata.WhiteElo,\n\t\t);\n\tif (parsedGame.metadata.BlackElo)\n\t\tplayerRatings[p.BLACK] = clientmetadatautil.getRatingFromWhiteBlackElo(\n\t\t\tparsedGame.metadata.BlackElo,\n\t\t);\n\n\t// Load the game.\n\tgameloader.startOnlineGame({\n\t\tgameInfo: {\n\t\t\tid: message.game_id,\n\t\t\trated: Boolean(message.rated),\n\t\t\tpublicity: message.private ? 'private' : 'public',\n\t\t\tplayerRatings,\n\t\t},\n\t\tmetadata: parsedGame.metadata,\n\t\tgameConclusion: clientmetadatautil.getGameConclusionFromResultAndTermination(\n\t\t\tparsedGame.metadata.Result!,\n\t\t\tmessage.termination as Condition,\n\t\t),\n\t\tmoves,\n\t\tyouAreColor: ourRole,\n\t});\n}\n\n/**\n * Called when we received the updated clock values from the server after submitting our move.\n */\nfunction handleUpdatedClock(basegame: Game, clockValues: ClockValues): void {\n\tif (basegame.untimed) throw Error('Received clock values for untimed game??');\n\n\t// Adjust the timer whos turn it is depending on ping.\n\tclockValues = onlinegame.adjustClockValuesForPing(clockValues);\n\tclock.edit(basegame.clocks, clockValues); // Edit the clocks\n\tguiclock.edit(basegame);\n}\n\n/**\n * Called after the server deletes the game after it has ended.\n * It basically tells us the server will no longer be sending updates related to the game,\n * so we should just unsub.\n *\n * Called when the server informs us they have unsubbed us from receiving updates from the game.\n * At this point we should leave the game.\n */\nfunction handleUnsubbing(): void {\n\tsocketsubs.deleteSub('game');\n}\n\n/**\n * The server has unsubscribed us from receiving updates from the game\n * and from submitting actions as ourselves,\n * due to the reason we are no longer logged in.\n */\nfunction handleLogin(basegame: Game): void {\n\ttoast.show(translations.onlinegame.not_logged_in, { error: true, durationMultiplier: 100 });\n\tsocketsubs.deleteSub('game');\n\tclock.endGame(basegame);\n\tguiclock.stopClocks(basegame);\n\tselection.unselectPiece();\n\tboard.darkenColor();\n}\n\n/**\n * The server has reported the game no longer exists,\n * there will be nore more updates for it.\n *\n * Visually, abort the game.\n *\n * This can happen when either:\n * * Your page tries to resync to the game after it's long over.\n * * The server restarts mid-game.\n */\nfunction handleNoGame(basegame: Game): void {\n\ttoast.show(translations.onlinegame.game_no_longer_exists, { durationMultiplier: 1.5 });\n\tsocketsubs.deleteSub('game');\n\tgamefileutility.setConclusion(basegame, { condition: 'aborted' });\n\tgameslot.concludeGame();\n}\n\n/**\n * You have connected to the same game from another window/device.\n * Leave the game on this page.\n *\n * This allows you to return to the invite creation screen,\n * but you won't be allowed to create an invite if you're still in a game.\n * However you can start a local game.\n */\nfunction handleLeaveGame(): void {\n\ttoast.show(translations.onlinegame.another_window_connected);\n\tsocketsubs.deleteSub('game');\n\tgameloader.unloadGame();\n\tguititle.open();\n}\n\nexport default {\n\trouteMessage,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/misc/onlinegame/resyncer.ts",
    "content": "// src/client/scripts/esm/game/misc/onlinegame/resyncer.ts\n\n/**\n * This script handles game updates and recyning an online game,\n * when for one reason or another we become out of sync.\n *\n * Game updates also count as resyncs, because that's what the server\n * sends anyway when we request a resync.\n *\n * This could be because we sent a move at the exact same time\n * the opponent resigned,\n * or it could be because the socket closed...\n */\n\nimport type { Mesh } from '../../rendering/piecemodels.js';\nimport type { FullGame } from '../../../../../../shared/chess/logic/gamefile.js';\nimport type { GameConclusion } from '../../../../../../shared/chess/util/winconutil.js';\nimport type { MoveRecord, MoveTagged } from '../../../../../../shared/chess/logic/movepiece.js';\nimport type { GameUpdateMessage, MovePacket } from '../../../../../../shared/types.js';\n\nimport moveutil from '../../../../../../shared/chess/util/moveutil.js';\nimport icnconverter from '../../../../../../shared/chess/logic/icn/icnconverter.js';\nimport movevalidation from '../../../../../../shared/chess/logic/movevalidation.js';\nimport gamefileutility from '../../../../../../shared/chess/util/gamefileutility.js';\n\nimport gameslot from '../../chess/gameslot.js';\nimport premoves from '../../chess/premoves.js';\nimport guipause from '../../gui/guipause.js';\nimport selection from '../../chess/selection.js';\nimport onlinegame from './onlinegame.js';\nimport movesequence from '../../chess/movesequence.js';\nimport movesendreceive from './movesendreceive.js';\n\n// Functions -----------------------------------------------------------------------------\n\n/**\n * Called when the server sends us the conclusion of the game when it ends,\n * OR we just need to resync! The game may not always be over.\n */\nfunction handleServerGameUpdate(\n\tgamefile: FullGame,\n\tmesh: Mesh | undefined,\n\tmessage: GameUpdateMessage,\n): void {\n\tconst claimedGameConclusion = message.gameConclusion;\n\n\t// This needs to be BEFORE synchronizeMovesList(), otherwise it won't resend our move since it thinks we're not in sync\n\tonlinegame.setInSyncTrue();\n\n\t/**\n\t * Make sure we are in sync with the final move list.\n\t * We need to do this because sometimes the game can end before the\n\t * server sees our move, but on our screen we have still played it.\n\t */\n\tconst result = synchronizeMovesList(\n\t\tgamefile,\n\t\tmesh,\n\t\tmessage.moves,\n\t\tclaimedGameConclusion,\n\t\tmessage.forceSync,\n\t); // { opponentPlayedIllegalMove }\n\tif (result.opponentPlayedIllegalMove) return;\n\n\tonlinegame.set_DrawOffers_DisconnectInfo_AutoAFKResign(message.participantState);\n\n\t// Must be set before editing the clocks.\n\tgamefileutility.setConclusion(gamefile.basegame, claimedGameConclusion);\n\n\t// Adjust the timer whos turn it is depending on ping.\n\tmovesendreceive.applyClockValues(gamefile, message.clockValues);\n\n\t// For online games, the server is boss, so if they say the game is over, conclude it here.\n\tif (gamefileutility.isGameOver(gamefile.basegame)) gameslot.concludeGame();\n}\n\n/**\n * Adds or deletes moves in the game until it matches the server's provided moves.\n * This can rarely happen when we move after the game is already over,\n * or if we're disconnected when our opponent made their move.\n * THIS CAN EVEN BE CALLED when our moves match the server's!\n * @param gamefile - The gamefile\n * @param moves - The moves list in the most compact form: `['1,2>3,4','5,6>7,8Q']`\n * @param claimedGameConclusion - The supposed game conclusion after synchronizing our opponents move\n * @param forceSync - If true, skip the early-exit re-submit path and force our move list to exactly match the server's\n * @returns A result object containg the property `opponentPlayedIllegalMove`. If that's true, we'll report it to the server.\n */\nfunction synchronizeMovesList(\n\tgamefile: FullGame,\n\tmesh: Mesh | undefined,\n\tmoves: MovePacket[],\n\tclaimedGameConclusion: GameConclusion | undefined,\n\tforceSync: boolean,\n): { opponentPlayedIllegalMove: boolean } {\n\tconst { boardsim } = gamefile;\n\t// console.log(\"Resyncing...\");\n\n\t// Early exit case. If we have played exactly 1 more move than the server,\n\t// and the rest of the moves list matches, don't modify our moves,\n\t// just re-submit our move!\n\t// Skip this if forceSync is set — the server wants us to match its state exactly\n\t// (e.g. it rejected our last move as illegal).\n\tconst hasOneMoreMoveThanServer = boardsim.moves.length === moves.length + 1;\n\tconst finalMoveIsOurMove =\n\t\tboardsim.moves.length > 0 &&\n\t\tmoveutil.getColorThatPlayedMoveIndex(gamefile.basegame, boardsim.moves.length - 1) ===\n\t\t\tonlinegame.getOurColor();\n\tconst previousMove =\n\t\tboardsim.moves.length > 1 ? boardsim.moves[boardsim.moves.length - 2] : undefined;\n\tconst previousMoveMatches =\n\t\t(moves.length === 0 && boardsim.moves.length === 1) ||\n\t\t(boardsim.moves.length > 1 &&\n\t\t\tmoves.length > 0 &&\n\t\t\tpreviousMove!.token === moves[moves.length - 1]!.token);\n\tif (\n\t\t!forceSync &&\n\t\t!claimedGameConclusion &&\n\t\thasOneMoreMoveThanServer &&\n\t\tfinalMoveIsOurMove &&\n\t\tpreviousMoveMatches\n\t) {\n\t\tconsole.log('Sending our move again after resyncing..');\n\t\tmovesendreceive.sendMove();\n\t\treturn { opponentPlayedIllegalMove: false };\n\t}\n\n\tconst originalMoveIndex = boardsim.state.local.moveIndex;\n\tmovesequence.viewFront(gamefile, mesh);\n\tlet aChangeWasMade = false;\n\n\t/** The index of the lastest move in the game we agree with the server on. -1 = starting position. */\n\tconst latestMatchingMoveIndex = findLastestMatchingMoveIndex(boardsim.moves, moves);\n\n\t// Rewind moves until we reach the first move we agree with the server on.\n\t// Catches our move if we moved RIGHT after the game ended but we haven't seen the conclusion.\n\tfor (let i = boardsim.moves.length - 1; i > latestMatchingMoveIndex; i--) {\n\t\tconsole.log(`Rewinding move index ${i} while resyncing to online game.`);\n\t\tmovesequence.rewindMove(gamefile, mesh);\n\t\taChangeWasMade = true;\n\t}\n\n\tlet opponentPlayedIllegalMove: boolean = false;\n\t/** Whether or not we forwarded at least one of OUR OWN moves the server had that we didn't. */\n\tlet atleastOneOfOurMovesWasForwarded: boolean = false;\n\n\t// Forward moves until we perfectly match the server's moves list.\n\tpremoves.performWithUnapplied(gamefile, mesh, () => {\n\t\tconst ourColor = onlinegame.getOurColor();\n\t\tfor (let i = latestMatchingMoveIndex + 1; i < moves.length; i++) {\n\t\t\t// Incrementally add the server's correct moves to our own moves list\n\t\t\tconst isLastMove = i === moves.length - 1;\n\t\t\tconst playerOfMove = moveutil.getColorThatPlayedMoveIndex(gamefile.basegame, i);\n\t\t\tconst isOpponentMove = playerOfMove !== ourColor;\n\n\t\t\tconst thisShortmove = moves[i]!; // '1,2>3,4=Q'  The shortmove from the server's move list to add\n\t\t\t// Convert the move from compact short format \"x,y>x,y=N\" to JSON.\n\t\t\t// Gauranteed by the server to be parsable.\n\t\t\tconst moveTagged: MoveTagged = icnconverter.parseTokenMove(thisShortmove.token);\n\n\t\t\tif (isOpponentMove) {\n\t\t\t\t// Perform legality checks\n\t\t\t\t// THIS ATTACHES ANY SPECIAL TAGS TO THE MOVE\n\t\t\t\tconst moveValidationResult = movevalidation.isOpponentsMoveLegal(\n\t\t\t\t\tgamefile,\n\t\t\t\t\tmoveTagged,\n\t\t\t\t\tclaimedGameConclusion,\n\t\t\t\t);\n\t\t\t\t// Only report cheating in games where the server won't delete the game instantly when it ends\n\t\t\t\tif (\n\t\t\t\t\tmovesendreceive.checkAndReportIllegalOpponentMove(\n\t\t\t\t\t\tgamefile,\n\t\t\t\t\t\tmoveValidationResult,\n\t\t\t\t\t\tthisShortmove.token,\n\t\t\t\t\t\ti + 1,\n\t\t\t\t\t)\n\t\t\t\t) {\n\t\t\t\t\topponentPlayedIllegalMove = true;\n\t\t\t\t\treturn false; // Don't physically play next premove\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tatleastOneOfOurMovesWasForwarded = true;\n\t\t\t}\n\n\t\t\tmovesequence.makeMoveAndAnimate(gamefile, mesh, moveTagged, {\n\t\t\t\tdoGameOverChecks: isLastMove,\n\t\t\t}); // Automatically cancels animations of forwarded moves in previous loops\n\n\t\t\tonlinegame.onMovePlayed({ isOpponents: isOpponentMove });\n\t\t\tif (isOpponentMove) guipause.onReceiveOpponentsMove(); // Update the pause screen buttons\n\n\t\t\tconsole.log('Forwarded one move while resyncing to online game.');\n\t\t\taChangeWasMade = true;\n\t\t}\n\n\t\t// Whether we're good to physically play the next premove depends on whether it is our turn or not,\n\t\t// AND whether we forwarded at least one of our own moves that the server had that we didn't.\n\t\tif (!atleastOneOfOurMovesWasForwarded && ourColor === gamefile.basegame.whosTurn) {\n\t\t\treturn true; // Good to physically play next premove\n\t\t} else {\n\t\t\treturn false; // Don't physically play next premove\n\t\t}\n\t});\n\n\t// If we happened to forward one of our own moves forwarded (not sure when our state\n\t// would be so behind to inherit this), then also cancel all premoves we had.\n\tif (atleastOneOfOurMovesWasForwarded) premoves.cancelPremoves(gamefile, mesh);\n\n\tif (opponentPlayedIllegalMove) return { opponentPlayedIllegalMove: true };\n\n\tif (!aChangeWasMade) movesequence.viewIndex(gamefile, mesh, originalMoveIndex);\n\telse selection.reselectPiece(); // Reselect the selected piece from before we resynced. Recalc its moves and recolor it if needed.\n\n\treturn { opponentPlayedIllegalMove: false }; // No cheating detected\n}\n\n/**\n * Finds the latest move index at which our moves and the server's moves match. Returns -1 if we only agree on the starting position.\n * @param ourMoves - Our moves list in compact form: `['1,2>3,4','5,6>7,8Q']`\n * @param serverMoves - The server's moves list in compact form: `[{ token: '1,2>3,4' }, { token: '5,6>7,8Q' }]`\n */\nfunction findLastestMatchingMoveIndex(ourMoves: MoveRecord[], serverMoves: MovePacket[]): number {\n\tif (ourMoves.length === 0) return -1; // We only agree with the starting position\n\tfor (let i = 0; i < ourMoves.length; i++) {\n\t\tif (ourMoves[i]!.token !== serverMoves[i]?.token) return i - 1; // We agree up to the previous move, but not this one\n\t}\n\treturn ourMoves.length - 1; // We agree with all\n}\n\n// Exports -------------------------------------------------------------------\n\nexport default {\n\thandleServerGameUpdate,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/misc/onlinegame/tabnameflash.ts",
    "content": "// src/client/scripts/esm/game/misc/onlinegame/tabnameflash.ts\n\n/**\n * This script controls the flashing of the tab name \"YOUR MOVE\"\n * when it is your turn and your in another tab.\n */\n\nimport bd from '@naviary/bigdecimal';\n\nimport moveutil from '../../../../../../shared/chess/util/moveutil.js';\n\nimport afk from './afk.js';\nimport gameslot from '../../chess/gameslot.js';\nimport gamesound from '../gamesound.js';\nimport loadbalancer from '../loadbalancer.js';\n\n/** The original tab title. We will always revert to this after temporarily changing the name name to alert player's it's their move. */\nconst originalDocumentTitle: string = document.title;\n\n/** How rapidly the tab title should flash \"YOUR MOVE\" */\nconst periodicityMillis = 1500;\n\n/** The ID of the timeout that can be used to cancel the timer that flips the tab title between \"YOUR MOVE\" and the default title. */\nlet timeoutID: ReturnType<typeof setTimeout> | undefined;\n\n/** The ID of the timeout that can be used to cancel the timer that will play a move sound effect to help you realize it's your move. Typically about 20 seconds. */\nlet moveSound_timeoutID: ReturnType<typeof setTimeout> | undefined;\n\nfunction onGameStart({ isOurMove }: { isOurMove: boolean }): void {\n\t// This will already flash the tab name\n\tonMovePlayed({ isOpponents: isOurMove });\n}\n\n/** Called when the online game is closed */\nfunction onGameClose(): void {\n\tcancelFlashTabTimer();\n\tcancelMoveSound();\n}\n\nfunction onMovePlayed({ isOpponents }: { isOpponents: boolean }): void {\n\tif (isOpponents) {\n\t\t// Flash the tab name\n\t\tflashTabNameYOUR_MOVE(true);\n\t\tscheduleMoveSound_timeoutID();\n\t} else {\n\t\t// our move\n\t\t// Stop flashing the tab name\n\t\tcancelFlashTabTimer();\n\t}\n}\n\n/**\n * Toggles the document title showing \"YOUR MOVE\",\n * and sets a timer for the next toggle.\n * @param parity - If true, the tab name becomes \"YOUR MOVE\", otherwise it reverts to the original title\n */\nfunction flashTabNameYOUR_MOVE(parity: boolean): void {\n\tif (!loadbalancer.isPageHidden()) {\n\t\t// The page is no longer hidden, restore the tab's original title,\n\t\t// and stop flashing \"YOUR MOVE\"\n\t\tdocument.title = originalDocumentTitle;\n\t\treturn;\n\t}\n\n\tdocument.title = parity ? 'YOUR MOVE' : originalDocumentTitle;\n\t// Set a timer for the next toggle\n\ttimeoutID = setTimeout(flashTabNameYOUR_MOVE, periodicityMillis, !parity);\n}\n\nfunction cancelFlashTabTimer(): void {\n\tdocument.title = originalDocumentTitle;\n\tclearTimeout(timeoutID);\n\ttimeoutID = undefined;\n}\n\nfunction scheduleMoveSound_timeoutID(): void {\n\tif (!loadbalancer.isPageHidden()) return; // Don't schedule it if the page is already visible\n\tif (!moveutil.isGameResignable(gameslot.getGamefile()!.basegame)) return;\n\tconst timeNextSoundFromNow = (afk.timeUntilAFKSecs * 1000) / 2;\n\tconst ZERO = bd.fromBigInt(0n);\n\tmoveSound_timeoutID = setTimeout(\n\t\t() => gamesound.playMove(ZERO, false, false),\n\t\ttimeNextSoundFromNow,\n\t);\n}\n\nfunction cancelMoveSound(): void {\n\tclearTimeout(moveSound_timeoutID);\n\tmoveSound_timeoutID = undefined;\n}\n\nexport default {\n\tonGameStart,\n\tonGameClose,\n\tonMovePlayed,\n\tcancelMoveSound,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/misc/space.ts",
    "content": "// src/client/scripts/esm/game/misc/space.ts\n\n/**\n * This script converts world-space coordinates to square coordinates, and vice verca.\n *\n * Where square coordinates are where the pieces are located,\n * world-space coordinates are where in space objects are actually rendered.\n *\n * There is also pixel space, which is the [x,y] coordinate of virtual pixels on the screen.\n *\n * Grid space: 1 unit = width of 1 square\n */\n\nimport type { BDCoords, Coords, DoubleCoords } from '../../../../../shared/chess/util/coordutil.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport board from '../rendering/boardtiles.js';\nimport camera from '../rendering/camera.js';\nimport boardpos from '../rendering/boardpos.js';\n\nconst HALF: BigDecimal = bd.fromNumber(0.5);\n\n/**\n * Since the camera is fixed in place, with the board moving and scaling below it,\n * this depends on your position and scale.\n */\nfunction convertWorldSpaceToCoords(worldCoords: DoubleCoords): BDCoords {\n\tconst boardPos: BDCoords = boardpos.getBoardPos();\n\tconst boardScale: BigDecimal = boardpos.getBoardScale();\n\treturn [\n\t\tconvertWorldSpaceToCoords_Axis(worldCoords[0], boardScale, boardPos[0]),\n\t\tconvertWorldSpaceToCoords_Axis(worldCoords[1], boardScale, boardPos[1]),\n\t];\n}\n\n/** Converts a single axis' coordinates from world space to squares. */\nfunction convertWorldSpaceToCoords_Axis(\n\tworldCoords: number,\n\tboardScale: BigDecimal,\n\tboardPos: BigDecimal,\n): BigDecimal {\n\tconst positionBD = bd.fromNumber(worldCoords);\n\treturn bd.add(bd.divideFloating(positionBD, boardScale), boardPos);\n}\n\n/** Returns the integer square coordinate that includes the floating point square coords inside its area. */\nfunction convertWorldSpaceToCoords_Rounded(worldCoords: DoubleCoords): Coords {\n\tconst coordsBD: BDCoords = convertWorldSpaceToCoords(worldCoords);\n\treturn roundCoords(coordsBD);\n}\n\n/** Returns the integer coordinate that contains the floating point coordinate provided. */\nfunction roundCoord(coord: BigDecimal): bigint {\n\tconst squareCenter = board.getSquareCenter();\n\treturn bd.toBigInt(bd.floor(bd.add(coord, squareCenter)));\n}\n\n/** Returns the integer coordinates that contain the floating point coordinate provided. */\nfunction roundCoords(coords: BDCoords): Coords {\n\treturn [roundCoord(coords[0]), roundCoord(coords[1])];\n}\n\n// Takes a square coordinate, returns the world-space location of the square's VISUAL center! Dependant on board.getSquareCenter().\nfunction convertCoordToWorldSpace(\n\tcoords: BDCoords,\n\tposition: BDCoords = boardpos.getBoardPos(),\n\tscale: BigDecimal = boardpos.getBoardScale(),\n): DoubleCoords {\n\tconst squareCenter = board.getSquareCenter();\n\n\tconst halfMinusSquareCenter = bd.subtract(HALF, squareCenter);\n\n\tfunction getAxis(coord: BigDecimal, position: BigDecimal): number {\n\t\tconst diff = bd.subtract(coord, position);\n\t\tconst diffPlusHalf = bd.add(diff, halfMinusSquareCenter);\n\t\tconst scaled = bd.multiplyFloating(diffPlusHalf, scale);\n\t\treturn bd.toNumber(scaled);\n\t}\n\n\t// (coords[0] - position[0] + 0.5 - squareCenter) * scale\n\treturn [getAxis(coords[0], position[0]), getAxis(coords[1], position[1])];\n}\n\nfunction convertCoordToWorldSpace_IgnoreSquareCenter(\n\tcoords: BDCoords,\n\tposition = boardpos.getBoardPos(),\n\tscale = boardpos.getBoardScale(),\n): DoubleCoords {\n\tfunction getAxis(coord: BigDecimal, position: BigDecimal): number {\n\t\tconst diff = bd.subtract(coord, position);\n\t\tconst scaled = bd.multiplyFloating(diff, scale);\n\t\treturn bd.toNumber(scaled);\n\t}\n\t// (coords[0] - position[0]) * scale\n\treturn [getAxis(coords[0], position[0]), getAxis(coords[1], position[1])];\n}\n\n/** Converts a measurement of virtual screen pixels to world space units. Dependant on the current screen height. */\nfunction convertPixelsToWorldSpace_Virtual(value: number): number {\n\tconst screenHeight = camera.getScreenHeightWorld(false);\n\treturn (value / camera.getCanvasHeightVirtualPixels()) * screenHeight;\n}\n\n/** Converts a measurement of world space units to virtual screen pixels. Dependant on the current screen height. */\nfunction convertWorldSpaceToPixels_Virtual(value: number): number {\n\tconst screenHeight = camera.getScreenHeightWorld(false);\n\treturn (value / screenHeight) * camera.getCanvasHeightVirtualPixels();\n}\n\n/** Tells you how many square units span the grid value you pass in. */\nfunction convertWorldSpaceToGrid(value: number): BigDecimal {\n\tconst valueBD = bd.fromNumber(value);\n\tconst scale = boardpos.getBoardScale();\n\t// value / scale\n\treturn bd.divideFloating(valueBD, scale);\n}\n\nexport default {\n\tconvertWorldSpaceToCoords,\n\tconvertWorldSpaceToCoords_Axis,\n\tconvertWorldSpaceToCoords_Rounded,\n\troundCoord,\n\troundCoords,\n\tconvertCoordToWorldSpace,\n\tconvertCoordToWorldSpace_IgnoreSquareCenter,\n\tconvertPixelsToWorldSpace_Virtual,\n\tconvertWorldSpaceToPixels_Virtual,\n\tconvertWorldSpaceToGrid,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/ColorFlowRenderer.ts",
    "content": "// src/client/scripts/esm/game/rendering/ColorFlowRenderer.ts\n\n/**\n * A modular renderer that paints a color flow effect across the\n * entire screen on demand, similar to the Iridescene Zone effect.\n * Intended for use as a background effect inside void for video footage.\n *\n * It is entirely self-contained, using its own shaders and buffers.\n * The shader written specifically for this script is: src/client/shaders/fullscreen_colorflow/fragment.glsl\n *\n * Usage:\n *   1. Instantiate with a WebGL2RenderingContext.\n *   2. Call render(deltaTime) each frame to draw the effect.\n */\nexport class ColorFlowRenderer {\n\tprivate gl: WebGL2RenderingContext;\n\tprivate program: WebGLProgram | null = null;\n\n\t// --- Buffers & VAO ---\n\tprivate quadBuffer: WebGLBuffer | null = null;\n\tprivate vao: WebGLVertexArrayObject | null = null;\n\n\t// --- Configuration (Matching IridescenceZone defaults) ---\n\tpublic flowSpeed: number = 0.07;\n\tpublic flowRotationSpeed: number = 0.0025;\n\tpublic gradientRepeat: number = 0.7;\n\tpublic alpha: number = 1.0;\n\n\t// Abyssal Ocean color palette\n\t// Deep, calming, mysterious blues and greens\n\tpublic colors: [number, number, number][] = [\n\t\t[0.0, 0.1, 0.3], // Midnight Blue\n\t\t[0.0, 0.3, 0.5], // Deep Teal\n\t\t[0.0, 0.6, 0.7], // Ocean Blue\n\t\t[0.0, 0.8, 0.6], // Seafoam Green\n\t\t[0.0, 0.4, 0.8], // Azure\n\t\t[0.1, 0.1, 0.4], // Dark Indigo\n\t];\n\n\t// --- State ---\n\tprivate flowDirection: number = Math.random() * Math.PI * 2;\n\tprivate flowDistance: number = 0;\n\n\t// --- Shader Source (Inlined for modularity) ---\n\tprivate readonly vsSource = `#version 300 es\n        in vec2 a_position;\n        void main() {\n            gl_Position = vec4(a_position, 0.0, 1.0);\n        }\n    `;\n\n\tprivate readonly fsSource = `#version 300 es\n        precision highp float;\n        uniform vec2 u_resolution;\n        uniform float u_flowDistance;\n        uniform vec2 u_flowDirectionVec;\n        uniform float u_gradientRepeat;\n        uniform float u_alpha;\n        uniform vec3 u_colors[6];\n        out vec4 fragColor;\n\n        vec3 getColorFromRamp(float t) {\n            float scaledT = t * 6.0;\n            int index = int(floor(scaledT));\n            float blend = fract(scaledT);\n            int nextIndex = (index + 1) % 6;\n            if (index >= 6) index = 0;\n            return mix(u_colors[index], u_colors[nextIndex], blend);\n        }\n\n        void main() {\n            vec2 uv = gl_FragCoord.xy / u_resolution;\n            float aspect = u_resolution.x / u_resolution.y;\n            uv.x *= aspect;\n            float projectedUv = dot(uv, u_flowDirectionVec);\n            float phase = (projectedUv * u_gradientRepeat) + u_flowDistance;\n            vec3 finalColor = getColorFromRamp(fract(phase));\n            fragColor = vec4(finalColor, u_alpha);\n        }\n    `;\n\n\tconstructor(gl: WebGL2RenderingContext) {\n\t\tthis.gl = gl;\n\t\tthis.init();\n\t}\n\n\tprivate init(): void {\n\t\t// 1. Compile Shaders\n\t\tconst vertexShader = this.createShader(this.gl.VERTEX_SHADER, this.vsSource);\n\t\tconst fragmentShader = this.createShader(this.gl.FRAGMENT_SHADER, this.fsSource);\n\n\t\tif (!vertexShader || !fragmentShader)\n\t\t\tthrow new Error('ColorFlowRenderer: Failed to create shaders');\n\n\t\t// 2. Create Program\n\t\tthis.program = this.gl.createProgram();\n\t\tif (!this.program) throw new Error('ColorFlowRenderer: Failed to create program');\n\n\t\tthis.gl.attachShader(this.program, vertexShader);\n\t\tthis.gl.attachShader(this.program, fragmentShader);\n\t\tthis.gl.linkProgram(this.program);\n\n\t\tif (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) {\n\t\t\tconsole.error(this.gl.getProgramInfoLog(this.program));\n\t\t\tthrow new Error('ColorFlowRenderer: Failed to link program');\n\t\t}\n\n\t\t// 3. Create Full-Screen Quad & VAO\n\t\tthis.vao = this.gl.createVertexArray();\n\t\tthis.gl.bindVertexArray(this.vao);\n\n\t\t// prettier-ignore\n\t\tconst vertices = new Float32Array([\n            -1, -1,  1, -1,\n            -1,  1, -1,  1,\n             1, -1,  1,  1,\n        ]);\n\n\t\tthis.quadBuffer = this.gl.createBuffer();\n\t\tthis.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.quadBuffer);\n\t\tthis.gl.bufferData(this.gl.ARRAY_BUFFER, vertices, this.gl.STATIC_DRAW);\n\n\t\t// Configure attributes INSIDE the VAO\n\t\tconst positionLoc = this.gl.getAttribLocation(this.program, 'a_position');\n\t\tthis.gl.enableVertexAttribArray(positionLoc);\n\t\tthis.gl.vertexAttribPointer(positionLoc, 2, this.gl.FLOAT, false, 0, 0);\n\n\t\t// Clean up: Unbind everything\n\t\tthis.gl.bindVertexArray(null);\n\t\tthis.gl.bindBuffer(this.gl.ARRAY_BUFFER, null);\n\t}\n\n\tprivate createShader(type: number, source: string): WebGLShader | null {\n\t\tconst shader = this.gl.createShader(type);\n\t\tif (!shader) return null;\n\t\tthis.gl.shaderSource(shader, source);\n\t\tthis.gl.compileShader(shader);\n\t\tif (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {\n\t\t\tconsole.error(this.gl.getShaderInfoLog(shader));\n\t\t\tthis.gl.deleteShader(shader);\n\t\t\treturn null;\n\t\t}\n\t\treturn shader;\n\t}\n\n\t/**\n\t * Updates internal animation state and draws the effect to the current framebuffer.\n\t * @param deltaTime Time in seconds since the last frame\n\t */\n\tpublic render(deltaTime: number): void {\n\t\tif (!this.program || !this.vao) return;\n\n\t\t// --- 1. Update Animation State ---\n\t\tthis.flowDirection += this.flowRotationSpeed * deltaTime;\n\t\tif (this.flowDirection > Math.PI * 2) this.flowDirection -= Math.PI * 2;\n\t\tthis.flowDistance += this.flowSpeed * deltaTime;\n\t\tconst flowDirectionVec = [Math.cos(this.flowDirection), Math.sin(this.flowDirection)];\n\n\t\t// --- 2. SAVE PREVIOUS STATE ---\n\t\tconst prevProgram = this.gl.getParameter(this.gl.CURRENT_PROGRAM);\n\t\tconst prevVAO = this.gl.getParameter(this.gl.VERTEX_ARRAY_BINDING);\n\t\tconst prevArrayBuffer = this.gl.getParameter(this.gl.ARRAY_BUFFER_BINDING);\n\t\tconst prevBlend = this.gl.isEnabled(this.gl.BLEND);\n\t\tconst prevDepthTest = this.gl.isEnabled(this.gl.DEPTH_TEST);\n\t\tconst prevDepthMask = this.gl.getParameter(this.gl.DEPTH_WRITEMASK);\n\t\t// Save blend function parameters\n\t\tconst prevSrcRGB = this.gl.getParameter(this.gl.BLEND_SRC_RGB);\n\t\tconst prevDstRGB = this.gl.getParameter(this.gl.BLEND_DST_RGB);\n\t\tconst prevSrcAlpha = this.gl.getParameter(this.gl.BLEND_SRC_ALPHA);\n\t\tconst prevDstAlpha = this.gl.getParameter(this.gl.BLEND_DST_ALPHA);\n\n\t\t// --- 3. SETUP & DRAW ---\n\t\tthis.gl.useProgram(this.program);\n\t\tthis.gl.bindVertexArray(this.vao);\n\n\t\t// Ensure we draw over everything and don't write to depth buffer\n\t\tthis.gl.disable(this.gl.DEPTH_TEST);\n\t\tthis.gl.depthMask(false);\n\n\t\t// Set Uniforms\n\t\tconst uResolution = this.gl.getUniformLocation(this.program, 'u_resolution');\n\t\tconst uFlowDistance = this.gl.getUniformLocation(this.program, 'u_flowDistance');\n\t\tconst uFlowDirectionVec = this.gl.getUniformLocation(this.program, 'u_flowDirectionVec');\n\t\tconst uGradientRepeat = this.gl.getUniformLocation(this.program, 'u_gradientRepeat');\n\t\tconst uAlpha = this.gl.getUniformLocation(this.program, 'u_alpha');\n\t\tconst uColors = this.gl.getUniformLocation(this.program, 'u_colors');\n\n\t\tthis.gl.uniform2f(uResolution, this.gl.canvas.width, this.gl.canvas.height);\n\t\tthis.gl.uniform1f(uFlowDistance, this.flowDistance);\n\t\tthis.gl.uniform2fv(uFlowDirectionVec, flowDirectionVec);\n\t\tthis.gl.uniform1f(uGradientRepeat, this.gradientRepeat);\n\t\tthis.gl.uniform1f(uAlpha, this.alpha);\n\n\t\tconst flatColors: number[] = [];\n\t\tfor (let i = 0; i < 6; i++) {\n\t\t\tconst col = this.colors[i] || [0, 0, 0];\n\t\t\tflatColors.push(...col);\n\t\t}\n\t\tthis.gl.uniform3fv(uColors, new Float32Array(flatColors));\n\n\t\t// Handle Blending\n\t\tif (this.alpha < 1.0) {\n\t\t\tthis.gl.enable(this.gl.BLEND);\n\t\t\tthis.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA);\n\t\t} else {\n\t\t\tthis.gl.disable(this.gl.BLEND);\n\t\t}\n\n\t\tthis.gl.drawArrays(this.gl.TRIANGLES, 0, 6);\n\n\t\t// --- 4. RESTORE STATE ---\n\t\tthis.gl.depthMask(prevDepthMask);\n\t\tif (prevDepthTest) this.gl.enable(this.gl.DEPTH_TEST);\n\t\telse this.gl.disable(this.gl.DEPTH_TEST);\n\n\t\tif (prevBlend) {\n\t\t\tthis.gl.enable(this.gl.BLEND);\n\t\t\tthis.gl.blendFuncSeparate(prevSrcRGB, prevDstRGB, prevSrcAlpha, prevDstAlpha);\n\t\t} else {\n\t\t\tthis.gl.disable(this.gl.BLEND);\n\t\t}\n\n\t\tthis.gl.bindVertexArray(prevVAO);\n\t\tthis.gl.bindBuffer(this.gl.ARRAY_BUFFER, prevArrayBuffer);\n\t\tthis.gl.useProgram(prevProgram);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/WaterRipples.ts",
    "content": "// src/client/scripts/esm/game/rendering/WaterRipples.ts\n\n/**\n * This scripts managers the animated water ripple effect for extremely large moves.\n */\n\nimport type { ProgramManager } from '../../webgl/ProgramManager';\nimport type { PostProcessPass } from '../../webgl/post_processing/PostProcessingPipeline';\n\nimport bounds from '../../../../../shared/util/math/bounds';\nimport bdcoords from '../../../../../shared/chess/util/bdcoords';\nimport { players as p } from '../../../../../shared/chess/util/typeutil';\nimport coordutil, { Coords } from '../../../../../shared/chess/util/coordutil';\n\nimport space from '../misc/space';\nimport camera from './camera';\nimport boardpos from './boardpos';\nimport drawrays from './highlights/annotations/drawrays';\nimport gameloader from '../chess/gameloader';\nimport perspective from './perspective';\nimport frametracker from './frametracker';\nimport { RippleState, WaterRipplePass } from '../../webgl/post_processing/passes/WaterRipplePass';\n\n// Constants --------------------------------------------------------------------------------\n\n/**\n * The distance beyond the screen edge that ripples are capped at, in virtual pixels,\n * PER virtual pixel of screen height, as the ripple speed is proportional to screen height.\n */\nconst RIPPLE_DIST_FROM_EDGE = 0.54; // Default: 0.54\n/** The lifetime offset applied to ripples beyond the screen edge so that we see their ripple sooner. */\nconst ELAPSED_TIME_OFFSET = -230; // Default: -230\n\n/**\n * How long each ripple lasts before being removed, in seconds,\n * on a PERFECTLY SQUARE canvas.\n */\nconst RIPPLE_LIFETIME_BASE = 1.1;\n/** How much longer ripples last per screen ratio of width/height. */\nconst RIPPLE_LIFETIME_MULTIPLIER = 0.5;\n\n// Variables --------------------------------------------------------------------------------\n\nlet waterRipplePass: WaterRipplePass;\n\nconst activeDroplets: RippleState[] = [];\n\n/**\n * ACTUAL ripple lifetime, dependent on screen ratio, as the more\n * wider the screen is taller, the longer drops take to travel across.\n */\nlet rippleLifetime: number;\n\n// Functions --------------------------------------------------------------------------------\n\nfunction init(programManager: ProgramManager, width: number, height: number): void {\n\twaterRipplePass = new WaterRipplePass(programManager, width, height);\n\n\tupdateRippleLifetime(width, height);\n\n\t// The post processing effect relies on the dimensions of the canvas.\n\t// Init listener for screen resize\n\tdocument.addEventListener('canvas_resize', (event) => {\n\t\tconst { width, height } = event.detail;\n\t\twaterRipplePass.setResolution(width, height);\n\t\tupdateRippleLifetime(width, height);\n\t});\n}\n\nfunction updateRippleLifetime(width: number, height: number): void {\n\trippleLifetime = RIPPLE_LIFETIME_BASE + RIPPLE_LIFETIME_MULTIPLIER * (width / height);\n\t// console.log(`ripple lifetime adjusted to ${rippleLifetime.toFixed(2)}s`);\n}\n\n/**\n * Adds a ripple droplet at the given source coordinates.\n * Caps the ripple to be just off-screen if the source is significantly off-screen.\n */\nfunction addRipple(sourceCoords: Coords): void {\n\t// Convert coords to world space\n\tconst sourceWorldSpace = space.convertCoordToWorldSpace(bdcoords.FromCoords(sourceCoords));\n\n\tconst screenHeight = camera.canvas.height / window.devicePixelRatio;\n\tconst pixelPadding = RIPPLE_DIST_FROM_EDGE * screenHeight;\n\tconst rippleWorldFromEdge = space.convertPixelsToWorldSpace_Virtual(pixelPadding);\n\t// The screen rectangle in world space\n\tconst screenBox = camera.getScreenBoundingBox(false);\n\tconst paddedScreenBox = {\n\t\tleft: screenBox.left - rippleWorldFromEdge,\n\t\tright: screenBox.right + rippleWorldFromEdge,\n\t\ttop: screenBox.top + rippleWorldFromEdge,\n\t\tbottom: screenBox.bottom - rippleWorldFromEdge,\n\t};\n\n\tlet rippleX: number = sourceWorldSpace[0];\n\tlet rippleY: number = sourceWorldSpace[1];\n\tlet elapsedTimeOffset: number = 0;\n\n\t// Don't let the ripple source be too far off-screen\n\tif (!bounds.boxContainsSquareDouble(paddedScreenBox, sourceWorldSpace)) {\n\t\t// console.log(\"Ripple source outside of padded screen.\");\n\t\tconst vectorToSource = coordutil.subtractBDCoords(\n\t\t\tbdcoords.FromCoords(sourceCoords),\n\t\t\tboardpos.getBoardPos(),\n\t\t);\n\t\tconst closestVector = drawrays.findClosestPredefinedVector(vectorToSource, false); // [-1-1, -1-1]\n\n\t\tif (closestVector[0] === 0n) {\n\t\t\trippleX = 0;\n\t\t\tif (closestVector[1] === -1n) rippleY = paddedScreenBox.bottom;\n\t\t\telse if (closestVector[1] === 1n) rippleY = paddedScreenBox.top;\n\t\t} else if (closestVector[0] === 1n) {\n\t\t\trippleX = paddedScreenBox.right;\n\t\t\tif (closestVector[1] === 0n) rippleY = 0;\n\t\t\telse if (closestVector[1] === 1n) rippleY = paddedScreenBox.top;\n\t\t\telse if (closestVector[1] === -1n) rippleY = paddedScreenBox.bottom;\n\t\t} else if (closestVector[0] === -1n) {\n\t\t\trippleX = paddedScreenBox.left;\n\t\t\tif (closestVector[1] === 0n) rippleY = 0;\n\t\t\telse if (closestVector[1] === 1n) rippleY = paddedScreenBox.top;\n\t\t\telse if (closestVector[1] === -1n) rippleY = paddedScreenBox.bottom;\n\t\t}\n\n\t\t// More offset for diagonals to account for greater distance from screen edge to ripple source\n\t\tconst isDiagonal = closestVector[0] !== 0n && closestVector[1] !== 0n;\n\t\telapsedTimeOffset = isDiagonal ? ELAPSED_TIME_OFFSET * 1.7 : ELAPSED_TIME_OFFSET;\n\t}\n\n\tconst screenWidthWorld = screenBox.right - screenBox.left;\n\tconst screenHeightWorld = screenBox.top - screenBox.bottom;\n\n\t// Convert world coordinates to UV coordinates [0-1]\n\tlet u = (rippleX - screenBox.left) / screenWidthWorld;\n\tlet v = (rippleY - screenBox.bottom) / screenHeightWorld;\n\n\t// If we're playing black, negate the UV coordinates\n\tif (!gameloader.areInLocalGame() && gameloader.getOurColor() === p.BLACK) {\n\t\tu = 1 - u;\n\t\tv = 1 - v;\n\t}\n\n\t// Create a new droplet\n\tactiveDroplets.push({ center: [u, v], timeCreated: Date.now() + elapsedTimeOffset });\n}\n\nfunction update(): void {\n\tconst now = Date.now();\n\n\t// Filter out old droplets\n\tfor (let i = activeDroplets.length - 1; i >= 0; i--) {\n\t\tconst droplet = activeDroplets[i]!;\n\t\tif (now >= droplet.timeCreated + rippleLifetime * 1000) {\n\t\t\t// Convert seconds to milliseconds\n\t\t\tactiveDroplets.splice(i, 1);\n\t\t\t// console.log(\"Removed ripple droplet.\");\n\t\t}\n\t}\n\n\t// Don't render ripple effect in perspective mode, as it is a pure\n\t// 2D post processing effect, not an effect on the rendered board.\n\tconst framesActiveDrops = perspective.getEnabled() ? [] : activeDroplets;\n\n\t// FEED the active list to the pass\n\twaterRipplePass.updateDroplets(framesActiveDrops);\n\n\t// Only call for an animation frame if there are active droplets\n\tif (activeDroplets.length > 0) frametracker.onVisualChange();\n}\n\n/**\n * Returns the WaterRipplePass instance this frame to be added to\n * the post-processing pipeline, if there are any visible drops.\n */\nfunction getPass(): PostProcessPass[] {\n\tif (activeDroplets.length === 0) return [];\n\treturn [waterRipplePass];\n}\n\nexport default {\n\tinit,\n\taddRipple,\n\tupdate,\n\tgetPass,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/animation.ts",
    "content": "// src/client/scripts/esm/game/rendering/animation.ts\n\n/**\n * This script handles the animation of pieces.\n * It also plays the sounds.\n */\n\nimport type { Piece } from '../../../../../shared/chess/util/boardutil.js';\nimport type { Color } from '../../../../../shared/util/math/math.js';\nimport type { BDCoords, Coords, DoubleCoords } from '../../../../../shared/chess/util/coordutil.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport math from '../../../../../shared/util/math/math.js';\nimport bdcoords from '../../../../../shared/chess/util/bdcoords.js';\nimport coordutil from '../../../../../shared/chess/util/coordutil.js';\nimport vectors, { Vec3 } from '../../../../../shared/util/math/vectors.js';\nimport typeutil, { RawType, TypeGroup } from '../../../../../shared/chess/util/typeutil.js';\n\nimport toast from '../gui/toast.js';\nimport meshes from './meshes.js';\nimport splines from '../../util/splines.js';\nimport boardpos from './boardpos.js';\nimport gamesound from '../misc/gamesound.js';\nimport arrowshifts from './arrows/arrowshifts.js';\nimport piecemodels from './piecemodels.js';\nimport perspective from './perspective.js';\nimport { GameBus } from '../GameBus.js';\nimport frametracker from './frametracker.js';\nimport texturecache from '../../chess/rendering/texturecache.js';\nimport WaterRipples from './WaterRipples.js';\nimport instancedshapes from './instancedshapes.js';\nimport { createRenderable, createRenderable_Instanced_GivenInfo } from '../../webgl/Renderable.js';\n\n// Types ----------------------------------------------------------------------------------\n\n/** Represents an animation segment between two waypoints. */\ninterface AnimationSegment {\n\tstart: BDCoords;\n\tend: BDCoords;\n\t/** The length of the individual segment. */\n\tlength: BigDecimal;\n\n\t/** The precalculated difference going from start to the end. */\n\tdifference: BDCoords;\n\t/** The precalculated ratio of the x difference to the distance (hypotenuse, total length). Doesn't need extreme precision. */\n\txRatio: number;\n\t/** The precalculated ratio of the y difference to the distance (hypotenuse, total length). Doesn't need extreme precision. */\n\tyRatio: number;\n}\n\n/** Information about the progress of a current animation. */\ntype SegmentInfo = {\n\t/**\n\t * The INTEGER segment number along the entire animation path, 0-based.\n\t * 0 means it is at or beyond the first waypoint, 1 means it is at or beyond the second waypoint, etc.\n\t */\n\tsegmentNum: number;\n\t/**\n\t * The distance along the segment the animation currently is, in squares.\n\t * This is more ideal than a percentage between 0-1 since its hard to\n\t * predict how much precision you'll need to represent that percentage\n\t * in order to get a non-gittery animation for long distance animations.\n\t */\n\tdistance: BigDecimal;\n\t/** Whether the distance is from the start of the segment, or the end backwards. */\n\tforward: boolean;\n};\n\n/** Represents an animation of a piece. */\ninterface Animation {\n\t/** The type of piece to animate. */\n\ttype: number;\n\t/** The original integer coordinates of the piece's path. Minimum: 2 */\n\tpath: Coords[];\n\t/** The high resolution waypoints the piece will pass throughout the animation. */\n\tpath_smooth: BDCoords[];\n\t/** The segments between each waypoint */\n\tsegments: AnimationSegment[];\n\t/** Pieces that need to be shown, up until a set path point is reached. Usually needed for captures. 0 is the start of the path. */\n\tshowKeyframes: Map<number, Piece[]>;\n\t/** Pieces that need to be hidded, up until a set path point is reached. Usually needed for reversing captures and hiding the moved piece. 0 is the start of the path. */\n\thideKeyframes: Map<number, Coords[]>;\n\t/** The time the animation started. */\n\tstartTimeMillis: number;\n\t/** The duration of the animation. */\n\tdurationMillis: number;\n\t/** The total distance the piece will travel throughout the animation across all waypoints. */\n\ttotalDistance: BigDecimal;\n\t/** Whether the animation is for a premove. */\n\tpremove: boolean;\n\t/** Whether the sound has been played yet. */\n\tsoundPlayed: boolean;\n\t/** The id of the timeout that will play the sound a little before the animation finishes, so there isn't a delay. */\n\tsoundTimeoutId?: ReturnType<typeof setTimeout>;\n\t/** The id of the timeout that will remove the animation from the list once it's over. */\n\tscheduledRemovalId?: ReturnType<typeof setTimeout>;\n}\n\n// Constants -------------------------------------------------------------------\n\nconst ZERO = bd.fromBigInt(0n);\nconst ONE = bd.fromBigInt(1n);\n\n/** Config for the splines. */\nconst SPLINES: {\n\t/** The number of points per segment of the spline. */\n\tRESOLUTION: number;\n\t/** The thickness of the spline. Used when debug rendering. */\n\tWIDTH: number;\n\t/** The color of the spline. Used when debug rendering. */\n\tCOLOR: [number, number, number, number];\n} = {\n\tRESOLUTION: 10, // Default: 10\n\tWIDTH: 0.15, // Default: 0.15\n\tCOLOR: [1, 0, 0, 1], // Default: [1, 0, 0, 1]\n};\n\n/**\n * The z offset of the transparent square meant to block out the default\n * rendering of the pieces while the animation is visible.\n *\n * THIS MUST BE GREATER THAN THE Z AT WHICH PIECES ARE RENDERED.\n */\nconst TRANSPARENT_SQUARE_Z: number = 0.01;\n/** By adding a negative offset, the sound doesn't appear delayed. */\nconst SOUND_OFFSET: number = 0; // TODO: Delete after next update, after some time with zero delay, to make sure we still like it.\n/** The maximum distance an animation can be without teleporting mid-animation. */\nconst MAX_DISTANCE_BEFORE_TELEPORT: number = 80; // 80\n\n/** Used for calculating the duration of move animations. */\nconst MOVE_ANIMATION_DURATION = {\n\t/** The base amount of duration, in millis. */\n\tbaseMillis: 150, // Default: 150\n\t/** The multiplier amount of duration, in millis, multiplied by the capped move distance. */\n\tmultiplierMillis: 6,\n\t/** The multiplierMillis when there's at least 3+ waypoints */\n\tmultiplierMillis_Curved: 12, // Default: 12\n\t/** Replaces {@link MOVE_ANIMATION_DURATION.baseMillis} when {@link DEBUG} is true. */\n\tbaseMillis_Debug: 2000,\n\t/** Replaces {@link MOVE_ANIMATION_DURATION.multiplierMillis} when {@link DEBUG} is true. */\n\tmultiplierMillis_Debug: 15,\n\t/** Replaces {@link MOVE_ANIMATION_DURATION.multiplierMillis_Curved} when {@link DEBUG} is true. */\n\tmultiplierMillis_Curved_Debug: 30,\n};\n\n// Variables -------------------------------------------------------------------------------\n\n/** The list of all current animations */\nconst animations: Animation[] = [];\n\n/** If this is enabled, the spline of the animations will be rendered, and the animations' duration increased. */\nlet DEBUG = false;\n\n// Events ----------------------------------------------------------------------------------------\n\nGameBus.addEventListener('game-unloaded', () => {\n\t// Clear all animations from the last game\n\tclearAnimations();\n});\n\n// Adding / Clearing Animations -----------------------------------------------------------------------\n\n/**\n * Animates a single piece after moving it. One king/rook in castling counts as one animation.\n * One animation can hide the animated piece at its destination square, and show captured pieces.\n * @param type - The type of piece to animate\n * @param path - The waypoints the piece will pass throughout the animation. Minimum: 2\n * @param showKeyframes\n * @param hideKeyframes\n * @param instant - Whether the animation should be instantanious, only playing the SOUND. If this is true, the animation will not be added to the list of animations, and will not be rendered.\n * @param resetAnimations - If false, allows animation of multiple pieces at once. Useful for castling. Default: true\n */\nfunction animatePiece(\n\ttype: number,\n\tpath: Coords[],\n\tshowKeyframes: Map<number, Piece[]>,\n\thideKeyframes: Map<number, Coords[]>,\n\tinstant?: boolean,\n\tresetAnimations = false,\n\tpremove = false,\n): void {\n\tif (path.length < 2) throw new Error('Animation requires at least 2 waypoints');\n\tif (resetAnimations) clearAnimations(true);\n\n\t// Generate smooth spline waypoints\n\tconst path_smooth = splines.generateSplinePath(path, SPLINES.RESOLUTION);\n\tconst segments = createAnimationSegments(path_smooth);\n\t// Calculates the total length of the path traveled by the piece in the animation.\n\tconst totalDistance: BigDecimal = segments.reduce((sum, seg) => bd.add(sum, seg.length), ZERO);\n\n\t// The hideShowKeyframes need to be stretched to match the resolution of the spline.\n\thideKeyframes = stretchKeyframesForResolution(hideKeyframes, SPLINES.RESOLUTION, path.length);\n\tshowKeyframes = stretchKeyframesForResolution(showKeyframes, SPLINES.RESOLUTION, path.length);\n\n\t// If this animation involves rendering a piece that doesn't have an SVG (void),\n\t// we can't animate/render it. Make it an instant animationinstead.\n\tconst typesInvolved: Set<RawType> = new Set([typeutil.getRawType(type)]);\n\tshowKeyframes.forEach((w) => w.forEach((p) => typesInvolved.add(typeutil.getRawType(p.type))));\n\tif (\n\t\tnew Set([...typesInvolved, ...typeutil.SVGLESS_TYPES]).size <\n\t\ttypesInvolved.size + typeutil.SVGLESS_TYPES.size\n\t)\n\t\tinstant = true; // Instant animations still play the sound\n\n\t// Handle instant animation (piece was dropped): Play the SOUND ONLY, but don't animate.\n\tif (instant)\n\t\treturn gamesound.playMove(\n\t\t\ttotalDistance,\n\t\t\tshowKeyframes.size !== 0,\n\t\t\tpremove,\n\t\t\tpath[path.length - 1]!,\n\t\t);\n\n\tconst newAnimation: Animation = {\n\t\ttype,\n\t\tpath,\n\t\tpath_smooth,\n\t\tsegments,\n\t\tshowKeyframes,\n\t\thideKeyframes,\n\t\tstartTimeMillis: performance.now(),\n\t\tdurationMillis: calculateAnimationDuration(totalDistance, path_smooth.length),\n\t\ttotalDistance,\n\t\tpremove,\n\t\tsoundPlayed: false,\n\t};\n\n\tscheduleSoundPlayback(newAnimation);\n\tscheduleAnimationRemoval(newAnimation);\n\tanimations.push(newAnimation);\n}\n\n/**\n * Terminates all animations.\n *\n * Should be called when we're skipping through moves quickly\n * (in that scenario we immediately play the sound),\n * or when the game is unloaded.\n */\nfunction clearAnimations(playSounds = false): void {\n\tanimations.forEach((animation) => {\n\t\tclearTimeout(animation.soundTimeoutId); // Don't play it twice..\n\t\tclearTimeout(animation.scheduledRemovalId); // Don't remove it twice..\n\t\tif (playSounds && !animation.soundPlayed) playAnimationSound(animation); // .. play it NOW.\n\t});\n\tanimations.length = 0; // Empties existing animations\n}\n\nfunction toggleDebug(): void {\n\tDEBUG = !DEBUG;\n\ttoast.show(`Toggled animation splines: ${DEBUG}`, { durationMultiplier: 0.5 });\n}\n\n// Helper Functions -----------------------------------------------------------\n\n/**\n * Stretches a {@link Animation.showKeyframes} or {@link Animation.hideKeyframes}\n * to match the resolution of the animation spline.\n */\nfunction stretchKeyframesForResolution<T>(\n\tkeyframes: Map<number, T>,\n\tresolution: number,\n\twaypointCount: number,\n): Map<number, T> {\n\tif (waypointCount < 3) return keyframes;\n\tconst t: Map<number, T> = new Map();\n\tfor (const [k, v] of keyframes) {\n\t\tt.set(k * resolution, v);\n\t}\n\treturn t;\n}\n\n/** Creates the segments between each waypoint. */\nfunction createAnimationSegments(waypoints: BDCoords[]): AnimationSegment[] {\n\tconst segments: AnimationSegment[] = [];\n\tfor (let i = 0; i < waypoints.length - 1; i++) {\n\t\tconst start = waypoints[i]!;\n\t\tconst end = waypoints[i + 1]!;\n\t\tconst difference: BDCoords = coordutil.subtractBDCoords(end, start);\n\t\t// Since the difference can be arbitrarily large, we need to normalize it\n\t\t// NEAR the range 0-1 (don't matter if it's not exact) so that we can use javascript numbers.\n\t\tconst normalizedVector: DoubleCoords = vectors.normalizeVectorBD(difference);\n\t\tconst normalizedVectorHypot: number = Math.hypot(...normalizedVector);\n\t\tsegments.push({\n\t\t\tstart,\n\t\t\tend,\n\t\t\tlength: vectors.euclideanDistanceBD(start, end),\n\t\t\tdifference: difference,\n\t\t\txRatio: normalizedVector[0] / normalizedVectorHypot,\n\t\t\tyRatio: normalizedVector[1] / normalizedVectorHypot,\n\t\t});\n\t}\n\treturn segments;\n}\n\n/** Calculates the duration in milliseconds a particular move would take to animate. */\nfunction calculateAnimationDuration(totalDistance: BigDecimal, waypointCount: number): number {\n\tconst baseMillis = DEBUG\n\t\t? MOVE_ANIMATION_DURATION.baseMillis_Debug\n\t\t: MOVE_ANIMATION_DURATION.baseMillis;\n\tconst cappedDist = Math.min(bd.toNumber(totalDistance), MAX_DISTANCE_BEFORE_TELEPORT);\n\tlet multiplier: number;\n\tif (DEBUG)\n\t\tmultiplier =\n\t\t\twaypointCount > 2\n\t\t\t\t? MOVE_ANIMATION_DURATION.multiplierMillis_Curved_Debug\n\t\t\t\t: MOVE_ANIMATION_DURATION.multiplierMillis_Debug;\n\telse\n\t\tmultiplier =\n\t\t\twaypointCount > 2\n\t\t\t\t? MOVE_ANIMATION_DURATION.multiplierMillis_Curved\n\t\t\t\t: MOVE_ANIMATION_DURATION.multiplierMillis;\n\tconst additionMillis = cappedDist * multiplier;\n\n\treturn baseMillis + additionMillis;\n}\n\n/** Schedules the playback of the sound of the animation. */\nfunction scheduleSoundPlayback(animation: Animation): void {\n\tconst playbackTime = Math.max(0, animation.durationMillis + SOUND_OFFSET);\n\tanimation.soundTimeoutId = setTimeout(() => playAnimationSound(animation), playbackTime);\n}\n\n/** Schedules the removal of an animation after it's over. */\nfunction scheduleAnimationRemoval(animation: Animation): void {\n\tanimation.scheduledRemovalId = setTimeout(() => {\n\t\tconst index = animations.indexOf(animation);\n\t\tif (index === -1) return; // Already removed\n\t\tanimations.splice(index, 1);\n\t\tframetracker.onVisualChange();\n\t}, animation.durationMillis);\n}\n\n/**\n * Plays the sound of the animation.\n * @param animation - The animation to play the sound for.\n * @param dampen - Whether to dampen the sound. This should be true if we're skipping through moves quickly.\n */\nfunction playAnimationSound(animation: Animation): void {\n\tgamesound.playMove(\n\t\tanimation.totalDistance,\n\t\tanimation.showKeyframes.size !== 0,\n\t\tanimation.premove,\n\t\tanimation.path[animation.path.length - 1]!,\n\t);\n\tanimation.soundPlayed = true;\n}\n\n// Updating -------------------------------------------------------------------------------\n\n/** Flags the frame to be rendered if there are any animations, and adds an arrow indicator animation for each */\nfunction update(): void {\n\tWaterRipples.update();\n\n\tif (animations.length === 0) return;\n\n\tframetracker.onVisualChange();\n\tanimations.forEach((animation) => shiftArrowIndicatorOfAnimatedPiece(animation)); // Animate the arrow indicator\n}\n\n/** Animates the arrow indicator */\nfunction shiftArrowIndicatorOfAnimatedPiece(animation: Animation): void {\n\tconst segmentInfo = getCurrentSegment(animation);\n\t// Delete the arrows of the hidden pieces\n\tforEachActiveKeyframe(animation.hideKeyframes, segmentInfo.segmentNum, (coords) =>\n\t\tcoords.forEach((c) => arrowshifts.deleteArrow(c)),\n\t);\n\tconst animationCurrentCoords = getCurrentAnimationPosition(animation.segments, segmentInfo);\n\t// Add the arrow of the animated piece (also removes the arrow it off its destination square)\n\tarrowshifts.animateArrow(\n\t\tanimation.path[animation.path.length - 1]!,\n\t\tanimationCurrentCoords,\n\t\tanimation.type,\n\t);\n\t// Add the arrows of the captured pieces only after we've shifted the piece that captured it\n\tforEachActiveKeyframe(animation.showKeyframes, segmentInfo.segmentNum, (pieces) =>\n\t\tpieces.forEach((p) => arrowshifts.addArrow(p.type, p.coords)),\n\t);\n}\n\n// Rendering -------------------------------------------------------------------------------\n\n/**\n * [ZOOMED IN] Renders the transparent squares that block out the default rendering of the pieces while the animation is visible.\n * This works because they are higher in the depth buffer than the pieces.\n */\nfunction renderTransparentSquares(): void {\n\tif (!animations.length) return;\n\n\tconst color: Color = [0, 0, 0, 0];\n\t// Calls map() on each animation, and then flats() the results into a single array.\n\tconst data = animations.flatMap((animation) => {\n\t\tconst hidesData: number[] = [];\n\t\tconst segmentNum = getCurrentSegment(animation).segmentNum;\n\t\tforEachActiveKeyframe(animation.hideKeyframes, segmentNum, (v) => {\n\t\t\tv.forEach((coord) => hidesData.push(...meshes.QuadWorld_Color(coord, color)));\n\t\t});\n\t\treturn hidesData;\n\t});\n\n\tcreateRenderable(data, 2, 'TRIANGLES', 'color', true).render([0, 0, TRANSPARENT_SQUARE_Z]);\n}\n\n/** [ZOOMED IN] Renders the animations of the pieces. */\nfunction renderAnimations(): void {\n\tif (animations.length === 0) return;\n\n\tif (DEBUG)\n\t\tanimations.forEach((animation) =>\n\t\t\tsplines.renderSplineDebug(animation.path_smooth, SPLINES.WIDTH, SPLINES.COLOR),\n\t\t);\n\n\t/**\n\t * Move away from the depricated spritesheet!\n\t *\n\t * We need to generate one instanced buffer model\n\t * for each type of piece included in the animations.\n\t */\n\n\tconst boardPos = boardpos.getBoardPos();\n\n\t/** Whether the textures should be inverted or not, based on whether we're viewing black's perspective. */\n\tconst inverted = perspective.getIsViewingBlackPerspective();\n\n\tconst vertexData = instancedshapes.getDataTexture(inverted);\n\n\t// We need two separate data groups to control render order.\n\t// 1. Captured pieces (which should be rendered underneath)\n\t// 2. The main moving pieces (which should be rendered on top)\n\tconst capturedPiecesInstanceData: TypeGroup<number[]> = {};\n\tconst movingPiecesInstanceData: TypeGroup<number[]> = {};\n\n\tanimations.forEach((animation) => {\n\t\tconst segmentInfo = getCurrentSegment(animation);\n\t\tconst currentPos = getCurrentAnimationPosition(animation.segments, segmentInfo);\n\n\t\t// Populate the moving piece data\n\t\tprocessPiece(animation.type, currentPos, movingPiecesInstanceData);\n\n\t\t// Populate the captured piece data\n\t\tforEachActiveKeyframe(animation.showKeyframes, segmentInfo.segmentNum, (pieces) => {\n\t\t\t// Render all captured pieces in place\n\t\t\tpieces.forEach((p) => {\n\t\t\t\tconst coordsBD = bdcoords.FromCoords(p.coords);\n\t\t\t\tprocessPiece(p.type, coordsBD, capturedPiecesInstanceData);\n\t\t\t});\n\t\t});\n\t});\n\n\t/** Helper for pushing a piece's instancedata to a specified data group. */\n\tfunction processPiece(\n\t\ttype: number,\n\t\tcoords: BDCoords,\n\t\ttargetInstanceData: TypeGroup<number[]>,\n\t): void {\n\t\tconst relativePosition: DoubleCoords = bdcoords.coordsToDoubles(\n\t\t\tcoordutil.subtractBDCoords(coords, boardPos),\n\t\t);\n\t\tif (!(type in targetInstanceData)) targetInstanceData[type] = []; // Initialize\n\t\ttargetInstanceData[type]!.push(...relativePosition);\n\t}\n\n\t// Render all\n\n\tconst boardScale = boardpos.getBoardScaleAsNumber();\n\tconst scale: Vec3 = [boardScale, boardScale, 1];\n\n\t/** Renders an entire group of pieces, organized by type. */\n\tfunction renderTypeGroup(instanceData: TypeGroup<number[]>): void {\n\t\tfor (const [typeStr, instance_data] of Object.entries(instanceData)) {\n\t\t\tconst type = Number(typeStr);\n\t\t\tconst texture = texturecache.getTexture(type);\n\t\t\tcreateRenderable_Instanced_GivenInfo(\n\t\t\t\tvertexData,\n\t\t\t\tinstance_data,\n\t\t\t\tpiecemodels.ATTRIBUTE_INFO,\n\t\t\t\t'TRIANGLES',\n\t\t\t\t'textureInstanced',\n\t\t\t\t[{ texture, uniformName: 'u_sampler' }],\n\t\t\t).render(undefined, scale);\n\t\t}\n\t}\n\n\t// 1. Render captured pieces FIRST on bottom.\n\trenderTypeGroup(capturedPiecesInstanceData);\n\t// 2. Render moving pieces SECOND, so they always appear on top.\n\trenderTypeGroup(movingPiecesInstanceData);\n}\n\n// Animation Calculations -----------------------------------------------------\n\n/**\n * Calculates which segment of the animation the animated piece is currently on,\n * and its distance along that specific segment.\n * @param animation - The animation to calculate the current segment for.\n * @param maxDistB4TeleportNumber - The maximum distance the animation should be allowed to travel\n * \t\t\t\t\t\t\t\t\tbefore teleporting mid-animation near the end of its destination.\n * \t\t\t\t\t\t\t\t\tThis should be specified if we're animating a miniimage, since when\n * \t\t\t\t\t\t\t\t\twe're zoomed out, the animation moving faster is perceivable.\n * \t\t\t\t\t\t\t\t\tCan be arbitrarily large.\n * @returns The animation's segment information.\n */\nfunction getCurrentSegment(\n\tanimation: Animation,\n\tmaxDistB4Teleport: BigDecimal = bd.fromNumber(MAX_DISTANCE_BEFORE_TELEPORT),\n): SegmentInfo {\n\tconst elapsed = performance.now() - animation.startTimeMillis;\n\t/** The interpolated progress of the animation. */\n\tconst t = Math.min(elapsed / animation.durationMillis, 1);\n\t/** The eased progress of the animation. */\n\tconst easedT = math.easeInOut(t);\n\tconst easedTBD = bd.fromNumber(easedT);\n\n\t/** The total distance along the animation path the animated piece should currently be at. */\n\tlet targetDistance: BigDecimal;\n\tlet forward = true;\n\n\tif (bd.compare(animation.totalDistance, maxDistB4Teleport) <= 0) {\n\t\t// Total distance is short enough to animate the whole path\n\t\ttargetDistance = bd.multiplyFloating(animation.totalDistance, easedTBD);\n\t} else {\n\t\t// The total distance is great enough to merit teleporting: Skip the middle of the path\n\t\tif (easedT < 0.5) {\n\t\t\t// First half\n\t\t\ttargetDistance = bd.multiply(maxDistB4Teleport, easedTBD);\n\t\t} else {\n\t\t\t// easedT >= 0.5\n\t\t\t// Second half: animate final portion of path\n\t\t\tconst inverseEasedT = bd.subtract(ONE, easedTBD);\n\t\t\ttargetDistance = bd.multiply(maxDistB4Teleport, inverseEasedT);\n\t\t\tforward = false;\n\t\t}\n\t}\n\n\t// Return the segment the piece should be at, based on the target distance,\n\t// and how far along the segment it currently is.\n\tlet accumulated: BigDecimal = bd.fromBigInt(0n);\n\tif (forward) {\n\t\tfor (let i = 0; i < animation.segments.length; i++) {\n\t\t\tconst segmentInfo = iterateSegment(i);\n\t\t\tif (segmentInfo) return segmentInfo;\n\t\t}\n\t\treturn { segmentNum: animation.segments.length, distance: ZERO, forward }; // At the end of the path\n\t} else {\n\t\tfor (let i = animation.segments.length - 1; i >= 0; i--) {\n\t\t\tconst segmentInfo = iterateSegment(i);\n\t\t\tif (segmentInfo) return segmentInfo;\n\t\t}\n\t\treturn { segmentNum: 0, distance: ZERO, forward }; // At the start of the path\n\t}\n\n\t/** Helper for iterating over each segment, accumulating the distance traveled until we reach the target distance. */\n\tfunction iterateSegment(i: number): SegmentInfo | undefined {\n\t\tconst segment = animation.segments[i]!;\n\t\tconst newAccumulated = bd.add(accumulated, segment.length);\n\t\tif (bd.compare(targetDistance, newAccumulated) <= 0) {\n\t\t\t// The piece is in this segment\n\t\t\t/**\n\t\t\t * Once we've found the segment we're on, this is how far we travel along that\n\t\t\t * segment until we reach our target distance of the animation from the very start.\n\t\t\t */\n\t\t\tconst distanceAlongSegment = bd.subtract(targetDistance, accumulated);\n\t\t\treturn { segmentNum: i, distance: distanceAlongSegment, forward };\n\t\t}\n\n\t\taccumulated = newAccumulated;\n\t\treturn undefined; // ts gets mad without this\n\t}\n}\n\n/**\n * Calculates the position of the moved piece from the progress of the animation.\n * @param segments - The segments of the animation.\n * @param segmentNum - The segment number, which is the progress of the animation from {@link getCurrentSegment}.\n * @returns the coordinate the animation's piece should be rendered this frame.\n */\nfunction getCurrentAnimationPosition(\n\tsegments: AnimationSegment[],\n\tsegmentInfo: SegmentInfo,\n): BDCoords {\n\tif (segmentInfo.segmentNum >= segments.length) return segments[segments.length - 1]!.end;\n\tconst segment = segments[segmentInfo.segmentNum]!;\n\n\tconst startPoint = segmentInfo.forward ? segment.start : segment.end;\n\n\tconst xTraversalAlongSegment = bd.multiplyFloating(\n\t\tsegmentInfo.distance,\n\t\tbd.fromNumber(segment.xRatio),\n\t);\n\tconst yTraversalAlongSegment = bd.multiplyFloating(\n\t\tsegmentInfo.distance,\n\t\tbd.fromNumber(segment.yRatio),\n\t);\n\n\tconst addOrSubtract: Function = segmentInfo.forward ? bd.add : bd.subtract;\n\n\treturn [\n\t\taddOrSubtract(startPoint[0], xTraversalAlongSegment),\n\t\taddOrSubtract(startPoint[1], yTraversalAlongSegment),\n\t];\n}\n\n// -----------------------------------------------------------------------------------------\n\n/**\n * Iterates over all keyframes that have not been passed by the animation.\n * This is all showKeyframes that are still being shown, or all hideKeyframes that are still being hidden.\n */\nfunction forEachActiveKeyframe<T>(\n\tkeyframes: Map<number, T>,\n\tsegment: number,\n\tcallback: (_value: T) => void,\n): void {\n\tfor (const [k, v] of keyframes) {\n\t\tif (k < segment) continue;\n\t\tcallback(v);\n\t}\n}\n\nexport default {\n\tanimations,\n\tanimatePiece,\n\tclearAnimations,\n\ttoggleDebug,\n\tupdate,\n\trenderTransparentSquares,\n\trenderAnimations,\n\tgetCurrentSegment,\n\tgetCurrentAnimationPosition,\n\tforEachActiveKeyframe,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/area.ts",
    "content": "// src/client/scripts/esm/game/rendering/area.ts\n\n/**\n * This script handles the calculation of the \"Area\"s on screen that\n * will contain the desired list of piece coordinates when at a specific\n * camera position and scale (zoom), which can be used to tell\n * {@link Transition} where to teleport to.\n */\n\nimport type { BDCoords, Coords } from '../../../../../shared/chess/util/coordutil.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport jsutil from '../../../../../shared/util/jsutil.js';\nimport bounds, { BoundingBoxBD } from '../../../../../shared/util/math/bounds.js';\n\nimport space from '../misc/space.js';\nimport camera from './camera.js';\nimport meshes from './meshes.js';\nimport boardpos from './boardpos.js';\nimport boardtiles from './boardtiles.js';\nimport guigameinfo from '../gui/guigameinfo.js';\nimport guinavigation from '../gui/guinavigation.js';\nimport Transition, { ZoomTransition } from './transitions/Transition.js';\n\n/**\n * An area object, containing the information {@link Transition} needs\n * to teleport/transition to this location on the board.\n */\nexport interface Area {\n\t/** The coordinates of the area. */\n\tcoords: BDCoords;\n\t/** The camera scale (zoom) of the area. */\n\tscale: BigDecimal;\n\t/** The bounding box that contains the area of interest. */\n\tboundingBox: BoundingBoxBD;\n}\n\nconst TWO = bd.fromNumber(2.0);\n\nconst padding: number = 0.03; // As a percentage of the screen WIDTH/HEIGHT (subtract the navigation bars height)\nconst paddingMiniimage: number = 0.2; // The padding to use when miniimages are visible (zoomed out far)\n/**\n * The minimum number of squares that should be visible when transitioning somewhere.\n * This is so that it doesn't zoom too close-up on a single piece or small group.\n */\nconst areaMinHeightSquares: number = 17; // Divided by screen width\n\n// Just the action of adding padding, changes the required scale to have that amount of padding,\n// so we need to iterate it a few times for more accuracy.\n// MUST BE GREATER THAN 0!\nconst iterationsToRecalcPadding: number = 10;\n\n/**\n * Returns a new bounding box, with added padding so the pieces\n * aren't too close to the edge or underneath the navigation bar.\n * @param box - The source bounding box, floating point edges.\n * @returns The new bounding box\n */\nfunction applyPaddingToBox(box: BoundingBoxBD): BoundingBoxBD {\n\t// { left, right, bottom, top }\n\n\tconst boxCopy: BoundingBoxBD = jsutil.deepCopyObject(box);\n\n\tconst topNavHeight = guinavigation.getHeightOfNavBar();\n\tconst bottomNavHeight = guigameinfo.getHeightOfGameInfoBar();\n\tconst navHeight = topNavHeight + bottomNavHeight;\n\tconst canvasHeightVirtualSubNav = camera.getCanvasHeightVirtualPixels() - navHeight;\n\n\t/** Start with a copy with zero padding. */\n\tlet paddedBox: BoundingBoxBD = jsutil.deepCopyObject(boxCopy);\n\tlet scaleBD: BigDecimal = calcScaleToMatchSides(paddedBox);\n\n\t// Iterate until we have desired padding\n\tfor (let i = 0; i < iterationsToRecalcPadding; i++) {\n\t\tconst paddingToUse: number =\n\t\t\tbd.compare(scaleBD, camera.getScaleWhenZoomedOut()) < 0 ? paddingMiniimage : padding;\n\t\tconst paddingHorzPixels = camera.getCanvasWidthVirtualPixels() * paddingToUse;\n\t\tconst paddingVertPixels = canvasHeightVirtualSubNav * paddingToUse + bottomNavHeight;\n\n\t\tconst paddingHorzWorldBD = bd.fromNumber(\n\t\t\tspace.convertPixelsToWorldSpace_Virtual(paddingHorzPixels),\n\t\t);\n\t\tconst paddingVertWorldBD = bd.fromNumber(\n\t\t\tspace.convertPixelsToWorldSpace_Virtual(paddingVertPixels),\n\t\t);\n\t\tconst paddingHorz: BigDecimal = bd.divide(paddingHorzWorldBD, scaleBD);\n\t\tconst paddingVert: BigDecimal = bd.divide(paddingVertWorldBD, scaleBD);\n\n\t\tpaddedBox = {\n\t\t\tleft: bd.subtract(boxCopy.left, paddingHorz),\n\t\t\tright: bd.add(boxCopy.right, paddingHorz),\n\t\t\tbottom: bd.subtract(boxCopy.bottom, paddingVert),\n\t\t\ttop: bd.add(boxCopy.top, paddingVert),\n\t\t};\n\n\t\t// Prep for next iteration\n\t\tscaleBD = calcScaleToMatchSides(paddedBox);\n\t}\n\n\treturn paddedBox;\n}\n\n/**\n * Calculates an Area object from the given bounding box.\n * The box must come PRE-PADDED.\n * @param box - The bounding box\n * @returns The area object\n */\nfunction calculateFromBox(box: BoundingBoxBD): Area {\n\t// { left, right, bottom, top }\n\t// The new boardPos is the middle point\n\tconst newBoardPos = bounds.calcCenterOfBoundingBox(box);\n\n\t// What is the scale required to match the sides?\n\tconst newScale = calcScaleToMatchSides(box);\n\n\t// Now maximize the bounding box to fill entire screen when at position and scale, so that\n\t// we don't have long thin slices of a bounding box that will fail the bounds.boxContainsSquare() function EVEN\n\t// if the square is visible on screen!\n\tconst maximizedBox = boardtiles.getBoundingBoxOfBoard(newBoardPos, newScale, false);\n\t// PROBLEM WITH this enabled is since it changes the size of the boundingBox, new coords are not centered.\n\n\treturn {\n\t\tcoords: newBoardPos,\n\t\tscale: newScale,\n\t\tboundingBox: maximizedBox,\n\t};\n}\n\nfunction getBoundingBoxHalfDimensions(boundingBox: BoundingBoxBD): {\n\txHalfLength: BigDecimal;\n\tyHalfLength: BigDecimal;\n} {\n\tconst xDiff = bd.subtract(boundingBox.right, boundingBox.left);\n\tconst yDiff = bd.subtract(boundingBox.top, boundingBox.bottom);\n\treturn {\n\t\txHalfLength: bd.divide(xDiff, TWO),\n\t\tyHalfLength: bd.divide(yDiff, TWO),\n\t};\n}\n\n/**\n * Calculates the camera scale (zoom) needed to fit\n * the provided board bounding box within the canvas.\n * @param boundingBox - The bounding box\n * @returns The scale (zoom) required\n */\nfunction calcScaleToMatchSides(boundingBox: BoundingBoxBD): BigDecimal {\n\tconst { xHalfLength, yHalfLength } = getBoundingBoxHalfDimensions(boundingBox);\n\n\tconst screenBoundingBox = camera.getScreenBoundingBox(false); // Get the screen bounding box without the navigation bars\n\tconst screenBoundingBoxBD: BoundingBoxBD =\n\t\tbounds.castDoubleBoundingBoxToBigDecimal(screenBoundingBox);\n\n\t// What is the scale required to match the sides?\n\tconst xScale = bd.divideFloating(screenBoundingBoxBD.right, xHalfLength);\n\tconst yScale = bd.divideFloating(screenBoundingBoxBD.top, yHalfLength);\n\tconst screenHeight = screenBoundingBox.top - screenBoundingBox.bottom;\n\t// Can afterward cast to BigDecimal since they are small numbers.\n\tconst capScale = bd.fromNumber(screenHeight / areaMinHeightSquares);\n\n\tlet newScale = bd.min(xScale, yScale);\n\tnewScale = bd.min(newScale, capScale); // Cap the scale to not zoom in too close for comfort\n\n\treturn newScale;\n}\n\n/**\n * Calculates the area object that contains every coordinate in the provided list, *with padding added*,\n * and contains the optional {@link existingBox} bounding box.\n * @param coordsList - An array of coordinates, typically of the pieces.\n * @returns The area object\n */\nfunction calculateFromCoordsList(coordsList: Coords[]): Area {\n\tif (coordsList.length === 0) throw Error('Cannot calculate area from an empty coords list.');\n\n\tconst box = bounds.getBoxFromCoordsList(coordsList); // Unpadded\n\tconst boxFloating = meshes.expandTileBoundingBoxToEncompassWholeSquare(box);\n\n\treturn calculateFromUnpaddedBox(boxFloating);\n}\n\n/**\n * Calulates the area object from the provided bounding box, *with padding added*.\n * @param box - A BoundingBox object.\n * @returns The area object\n */\nfunction calculateFromUnpaddedBox(box: BoundingBoxBD): Area {\n\tconst paddedBox = applyPaddingToBox(box);\n\treturn calculateFromBox(paddedBox);\n}\n\n/**\n * High level function that initaties one or two zoom transitions\n * with the goal of getting the target Area on screen.\n * @param thisArea - The Area object to get on screen.\n * @param [ignoreHistory] Whether to skip adding this teleport to the teleport history.\n */\nfunction initTransitionFromArea(thisArea: Area, ignoreHistory: boolean): void {\n\tconst thisAreaBox = thisArea.boundingBox;\n\n\tconst startCoords = boardpos.getBoardPos();\n\tconst endCoords = thisArea.coords;\n\n\tconst currentBoardBoundingBox = boardtiles.gboundingBoxFloat(); // Tile/board space, NOT world-space\n\n\t// Will a teleport to this area be a zoom out or in?\n\tconst isAZoomOut = bd.compare(thisArea.scale, boardpos.getBoardScale()) < 0;\n\n\tlet firstArea: Area | undefined;\n\n\tif (isAZoomOut) {\n\t\t// If our current screen isn't within the final area, create new area to teleport to first\n\t\tif (!bounds.boxContainsSquareBD(thisAreaBox, startCoords)) {\n\t\t\tbounds.expandBDBoxToContainSquare(thisAreaBox, startCoords); // Unpadded\n\t\t\tfirstArea = calculateFromUnpaddedBox(thisAreaBox);\n\t\t}\n\t\t// Version that fits the entire screen on the zoom out\n\t\t// if (!bounds.boxContainsBoxBD(thisAreaBox, currentBoardBoundingBox)) {\n\t\t//     const mergedBoxes = bounds.mergeBoundingBoxBDs(currentBoardBoundingBox, thisAreaBox);\n\t\t//     firstArea = calculateFromBox(mergedBoxes);\n\t\t// }\n\t} else {\n\t\t// zoom-in. If the end area isn't visible on screen now, create new area to teleport to first\n\t\tif (!bounds.boxContainsSquareBD(currentBoardBoundingBox, endCoords)) {\n\t\t\tbounds.expandBDBoxToContainSquare(currentBoardBoundingBox, endCoords); // Unpadded\n\t\t\tfirstArea = calculateFromUnpaddedBox(currentBoardBoundingBox);\n\t\t}\n\t\t// Version that fits the entire screen on the zoom out\n\t\t// if (!bounds.boxContainsBoxBD(currentBoardBoundingBox, thisAreaBox)) {\n\t\t//     const mergedBoxes = bounds.mergeBoundingBoxBDs(currentBoardBoundingBox, thisAreaBox);\n\t\t//     firstArea = calculateFromBox(mergedBoxes);\n\t\t// }\n\t}\n\n\tconst trans1: ZoomTransition | undefined = firstArea\n\t\t? { destinationCoords: firstArea.coords, destinationScale: firstArea.scale }\n\t\t: undefined;\n\tconst trans2: ZoomTransition = {\n\t\tdestinationCoords: thisArea.coords,\n\t\tdestinationScale: thisArea.scale,\n\t};\n\n\tif (trans1) Transition.startZoomTransition(trans1, trans2, ignoreHistory);\n\telse Transition.startZoomTransition(trans2, undefined, ignoreHistory);\n}\n\nexport default {\n\tcalculateFromCoordsList,\n\tcalculateFromUnpaddedBox,\n\tinitTransitionFromArea,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/arrows/arrowlegalmovehighlights.ts",
    "content": "// src/client/scripts/esm/game/rendering/arrows/arrowlegalmovehighlights.ts\n\n/**\n * This script keeps track of and renders the\n * legal moves of all arrow indicators being hovered over.\n */\n\nimport type { Color } from '../../../../../../shared/util/math/math.js';\nimport type { Piece } from '../../../../../../shared/chess/util/boardutil.js';\nimport type { LegalMoves } from '../../../../../../shared/chess/logic/legalmoves.js';\nimport type { RenderableInstanced } from '../../../webgl/Renderable.js';\n\nimport typeutil from '../../../../../../shared/chess/util/typeutil.js';\nimport moveutil from '../../../../../../shared/chess/util/moveutil.js';\nimport bdcoords from '../../../../../../shared/chess/util/bdcoords.js';\nimport legalmoves from '../../../../../../shared/chess/logic/legalmoves.js';\nimport coordutil, { Coords } from '../../../../../../shared/chess/util/coordutil.js';\n\nimport meshes from '../meshes.js';\nimport gameslot from '../../chess/gameslot.js';\nimport selection from '../../chess/selection.js';\nimport gameloader from '../../chess/gameloader.js';\nimport preferences from '../../../components/header/preferences.js';\nimport boardeditor from '../../boardeditor/boardeditor.js';\nimport { GameBus } from '../../GameBus.js';\nimport legalmovemodel from '../highlights/legalmovemodel.js';\nimport arrows, { ArrowPiece } from './arrows.js';\n\n// Types ------------------------------------------------------------------------------------------------------\n\n/** Contains the legal moves, and other info, about the piece an arrow indicator is pointing to. */\ninterface ArrowLegalMoves {\n\t/** The Piece this arrow is pointing to, including its coords & type. */\n\tpiece: Piece;\n\t/** The calculated legal moves of the piece. */\n\tlegalMoves: LegalMoves;\n\t/** The buffer model for rendering the non-capturing legal moves of the piece. */\n\tmodel_NonCapture: RenderableInstanced;\n\t/** The buffer model for rendering the capturing legal moves of the piece. */\n\tmodel_Capture: RenderableInstanced;\n\t/** The [r,b,g,a] values these legal move highlights should be rendered.\n\t * Depends on whether the piece is ours, a premove, or an opponent's piece. */\n\tcolor: Color;\n}\n\n/**\n * An array storing the LegalMoves, model and other info, for rendering the legal move highlights\n * of piece arrow indicators currently being hovered over!\n *\n * THIS IS UPDATED AFTER OTHER SCRIPTS have a chance to add/delete pieces to show arrows for,\n * as hovered arrows have a chance of being removed before rendering!\n */\nconst hoveredArrowsLegalMoves: ArrowLegalMoves[] = [];\n\n// Events ----------------------------------------------------------------------------------------------\n\nGameBus.addEventListener('physical-move', () => {\n\t// Whenever a move is made in the game, the color of the legal move highlights\n\t// of the hovered arrows often changes.\n\t// Erase the list so they can be regenerated next frame with the correct color.\n\treset();\n});\n\n// Functions -------------------------------------------------------------------------------------------\n\n/**\n * This makes sure that the legal moves of all of the hovered arrows this\n * frame are already calculated.\n *\n * Pieces that are consecutively hovered over each frame have their\n * legal moves cached.\n */\nfunction update(): void {\n\tconst gamefile = gameslot.getGamefile()!;\n\n\t// Do not render line highlights upon arrow hover, when game is rewinded,\n\t// since calculating their legal moves means overwriting game's move history.\n\tif (!moveutil.areWeViewingLatestMove(gamefile.boardsim)) {\n\t\thoveredArrowsLegalMoves.length = 0;\n\t\treturn;\n\t}\n\n\tconst hoveredArrows = arrows.getHoveredArrows();\n\n\t// Iterate through all pieces in piecesHoveredOver, if they aren't being\n\t// hovered over anymore, delete them. Stop rendering their legal moves.\n\tfor (let i = hoveredArrowsLegalMoves.length - 1; i >= 0; i--) {\n\t\t// Iterate backwards because we are removing elements as we go\n\t\tconst thisHoveredArrow = hoveredArrowsLegalMoves[i]!;\n\t\t// Is this arrow still being hovered over?\n\t\tif (\n\t\t\t!hoveredArrows.some((arrow) => {\n\t\t\t\tif (arrow.piece.floating) return false;\n\t\t\t\tconst integerCoords = bdcoords.coordsToBigInt(arrow.piece.coords);\n\t\t\t\treturn coordutil.areCoordsEqual(integerCoords, thisHoveredArrow.piece.coords);\n\t\t\t})\n\t\t)\n\t\t\thoveredArrowsLegalMoves.splice(i, 1); // No longer being hovered over. Delete its legal moves.\n\t}\n\n\tfor (const pieceHovered of hoveredArrows) {\n\t\tonPieceIndicatorHover(pieceHovered.piece); // Generate their legal moves and highlight model\n\t}\n}\n\n/**\n * Call when a piece's arrow is hovered over.\n * Calculates their legal moves and model for rendering them.\n * @param piece - The piece this arrow is pointing to\n */\nfunction onPieceIndicatorHover(arrowPiece: ArrowPiece): void {\n\t// SHOULD WE JUST RETURN HERE INSTEAD OF ERROR???\n\tif (!bdcoords.areCoordsIntegers(arrowPiece.coords))\n\t\tthrow Error(\n\t\t\t'We should not be calculating legal moves for a hovered arrow pointing to a piece at floating point coordinates!',\n\t\t);\n\n\t// Check if their legal moves and mesh have already been stored\n\tif (\n\t\thoveredArrowsLegalMoves.some((hoveredArrow) => {\n\t\t\tconst integerCoords = bdcoords.coordsToBigInt(arrowPiece.coords);\n\t\t\treturn coordutil.areCoordsEqual(hoveredArrow.piece.coords, integerCoords);\n\t\t})\n\t)\n\t\treturn; // Legal moves and mesh already calculated.\n\n\tconst integerCoords: Coords = bdcoords.coordsToBigInt(arrowPiece.coords);\n\tconst piece: Piece = {\n\t\ttype: arrowPiece.type,\n\t\tcoords: integerCoords,\n\t\tindex: arrowPiece.index,\n\t};\n\n\t// Calculate their legal moves and mesh!\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst thisPieceLegalMoves = legalmoves.calculateAll(gamefile, piece);\n\n\t// Calculate the mesh...\n\n\t// Determine what color the legal move highlights should be...\n\tconst pieceColor = typeutil.getColorFromType(piece.type);\n\tconst ourColor =\n\t\tgameloader.areInLocalGame() || boardeditor.areInBoardEditor()\n\t\t\t? gamefile.basegame.whosTurn\n\t\t\t: gameloader.getOurColor();\n\tconst isOpponentPiece = pieceColor !== ourColor;\n\tconst isOurTurn = gamefile.basegame.whosTurn === pieceColor;\n\tconst color = preferences.getLegalMoveHighlightColor({\n\t\tisOpponentPiece,\n\t\tisPremove: !isOurTurn,\n\t});\n\n\tconst { NonCaptureModel, CaptureModel } =\n\t\tlegalmovemodel.generateModelsForPiecesLegalMoveHighlights(\n\t\t\tpiece.coords,\n\t\t\tthisPieceLegalMoves,\n\t\t\tpieceColor,\n\t\t\tcolor,\n\t\t);\n\t// Store both these objects inside piecesHoveredOver\n\thoveredArrowsLegalMoves.push({\n\t\tpiece,\n\t\tlegalMoves: thisPieceLegalMoves,\n\t\tmodel_NonCapture: NonCaptureModel,\n\t\tmodel_Capture: CaptureModel,\n\t\tcolor,\n\t});\n}\n\n/** Renders the pre-cached legal move highlights of all arrow indicators being hovered over */\nfunction renderEachHoveredPieceLegalMoves(): void {\n\tif (hoveredArrowsLegalMoves.length === 0) return; // No legal moves to render\n\n\tconst { position, scale } = meshes.getBoardRenderTransform(legalmovemodel.getOffset());\n\n\thoveredArrowsLegalMoves.forEach((hoveredArrow) => {\n\t\t// Skip it if the piece being hovered over IS the piece selected! (Its legal moves are already being rendered)\n\t\tif (selection.isAPieceSelected()) {\n\t\t\tconst pieceSelectedCoords = selection.getPieceSelected()!.coords;\n\t\t\tif (coordutil.areCoordsEqual(hoveredArrow.piece.coords, pieceSelectedCoords)) return; // Skip (already rendering its legal moves, because it's selected)\n\t\t}\n\t\thoveredArrow.model_NonCapture.render(position, scale);\n\t\thoveredArrow.model_Capture.render(position, scale);\n\t});\n}\n\n/**\n * Regenerates the mesh of the piece arrow indicators hovered legal moves.\n *\n * Call when our highlights offset, or render range bounding box, changes,\n * so we account for the new offset.\n */\nfunction regenModelsOfHoveredPieces(): void {\n\tif (hoveredArrowsLegalMoves.length === 0) return; // No arrows being hovered over\n\n\tconsole.log(\"Updating models of hovered piece's legal moves..\");\n\n\thoveredArrowsLegalMoves.forEach((hoveredArrow) => {\n\t\t// Calculate the mesh...\n\t\tconst pieceColor = typeutil.getColorFromType(hoveredArrow.piece.type);\n\t\tconst { NonCaptureModel, CaptureModel } =\n\t\t\tlegalmovemodel.generateModelsForPiecesLegalMoveHighlights(\n\t\t\t\thoveredArrow.piece.coords,\n\t\t\t\thoveredArrow.legalMoves,\n\t\t\t\tpieceColor,\n\t\t\t\thoveredArrow.color,\n\t\t\t);\n\t\t// Overwrite the model inside piecesHoveredOver\n\t\thoveredArrow.model_NonCapture = NonCaptureModel;\n\t\thoveredArrow.model_Capture = CaptureModel;\n\t});\n}\n\n/** Erases the cached legal moves of the hovered arrow indicators */\nfunction reset(): void {\n\thoveredArrowsLegalMoves.length = 0; // Erase, otherwise their legal move highlights continue to render\n}\n\n// -------------------------------------------------------------------------------------------------------------\n\nexport default {\n\tupdate,\n\treset,\n\trenderEachHoveredPieceLegalMoves,\n\tregenModelsOfHoveredPieces,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/arrows/arrows.ts",
    "content": "// src/client/scripts/esm/game/rendering/arrows/arrows.ts\n\n/**\n * This script manages the state of arrow indicators on the sides of the screen,\n * pointing to pieces off-screen that are in that direction.\n *\n * If the pictures are clicked, we initiate a teleport to that piece.\n *\n * Other scripts may add/remove arrows in between update() and render() calls.\n * Calculation is handled by arrowscalculator, shifting by arrowshifts,\n * and rendering by arrowsgraphics.\n */\n\nimport type { Vec2, Vec2Key } from '../../../../../../shared/util/math/vectors.js';\nimport type {\n\tBDCoords,\n\tCoords,\n\tDoubleCoords,\n} from '../../../../../../shared/chess/util/coordutil.js';\n\nimport gameslot from '../../chess/gameslot.js';\nimport arrowshifts from './arrowshifts.js';\nimport frametracker from '../frametracker.js';\nimport arrowscalculator from './arrowscalculator.js';\nimport arrowlegalmovehighlights from './arrowlegalmovehighlights.js';\n\n// Types -------------------------------------------------------------------------------\n\n/** An object containing all the arrow lines of a single frame. */\nexport interface SlideArrows {\n\t/** An object containing all existing arrows for a specific slide direction */\n\t[vec2Key: Vec2Key]: {\n\t\t/**\n\t\t * A single line containing what arrows ARE visible on the\n\t\t * sides of the screen for offscreen pieces.\n\t\t */\n\t\t[lineKey: string]: ArrowsLine;\n\t};\n}\n\n/**\n * An object containing the arrows that should actually be present,\n * for a single organized line intersecting through our screen.\n *\n * The FIRST index in each of these left/right arrays, is the picture\n * which gets rendered at the default location.\n * The FINAL index in each of these, is the picture of the piece\n * that is CLOSEST to you (screen center) on the line!\n */\nexport interface ArrowsLine {\n\t/** Pieces on this line that intersect the screen with a positive dot product.\n\t * SORTED in order of closest to the screen to farthest. */\n\tposDotProd: Arrow[];\n\t/** Pieces on this line that intersect the screen with a negative dot product.\n\t * SORTED in order of closest to the screen to farthest.\n\t * The arrow direction for these will be flipped to the other side. */\n\tnegDotProd: Arrow[];\n}\n\n/** A single piece-based arrow indicator, with enough information to be able to render it. */\nexport interface Arrow extends BaseArrow {\n\tpiece: ArrowPiece;\n\t/** Opacity to render this arrow at when not hovered. Defaults to the module-level opacity constant. */\n\topacity: number;\n\t/**\n\t * The direction the arrow triangle points.\n\t * Equals `negateVector(processPiece vector)`, used directly as the render angle.\n\t */\n\tdirection: Vec2;\n\t/**\n\t * Index within the adjacent-picture stack on this line. 0 for the primary (outermost)\n\t * indicator; > 0 for stacked indicators closer to the screen center.\n\t */\n\tstackIndex: number;\n}\n\n/**\n * Reflection of the {@link Piece} type, but with extra decimal precision\n * for the coordinates (needed for animated arrows).\n */\nexport interface ArrowPiece {\n\ttype: number;\n\tcoords: BDCoords;\n\tindex: number;\n\t/** Whether the piece is at a floating point coordinate. */\n\tfloating: boolean;\n}\n\n/** Shared base for all screen-edge arrow indicators. */\ninterface BaseArrow {\n\t/** The world-space position of this indicator on the screen edge. */\n\tworldLocation: DoubleCoords;\n\t/** Whether this indicator is being hovered over by the mouse. */\n\thovered: boolean;\n}\n\n/** Hovered-arrow event: identifies which arrow indicator is currently being hovered. */\nexport interface HoveredArrow {\n\t/** A reference to the piece it is pointing to */\n\tpiece: ArrowPiece;\n\t/**\n\t * The direction this arrow points (from the screen edge toward the piece).\n\t * Matches the slide direction the arrow indicator represents.\n\t */\n\tdirection: Vec2;\n\t/** The world-space position of this arrow indicator on the screen edge. */\n\tworldLocation: DoubleCoords;\n\t/**\n\t * Whether the piece can generally slide in the arrow direction.\n\t * IS NOT calculated for shifted arrows (always true).\n\t */\n\townsSlide: boolean;\n}\n\n/** An arrow indicator for an off-screen individual legal move, shown when in check. */\nexport interface HintArrow extends BaseArrow {\n\t/** Direction this indicator points, from the screen edge toward its target. */\n\tdirection: Vec2;\n\t/** The target square this hint arrow points to. */\n\ttargetSquare: Coords;\n}\n\n// Constants ---------------------------------------------------------------------------\n\n/** The maximum number of pieces in a game before we disable arrow indicator rendering, for performance. */\nconst MAX_PIECES = 40_000;\n/** The maximum number of lines in a game before we disable arrow indicator rendering, for performance. */\nconst MAX_LINES = 8;\n\n// State -------------------------------------------------------------------------------\n\n/**\n * The mode the arrow indicators on the edges of the screen is currently in.\n * 0 = Off,\n * 1 = Defense,\n * 2 = All (orthogonals & diagonals)\n * 3 = All (including hippogonals, only used in variants using hippogonals)\n */\nlet mode: 0 | 1 | 2 | 3 = 1;\n\n/**\n * A list of all arrows present for the current frame.\n *\n * Other scripts are given an opportunity to add/remove\n * arrows from this list before rendering, but they must\n * do so between the update() and render() calls.\n */\nlet slideArrows: SlideArrows = {};\n\n/**\n * A list of all piece-arrows being hovered over this frame (excludes move hints),\n * with a reference to the piece they are pointing to.\n * Other scripts may access this so they can add interaction with them.\n */\nlet hoveredArrows: HoveredArrow[] = [];\n\n/**\n * A list of all animated arrows IN MOTION for the current frame.\n *\n * This does not include still ones, for example rendered from\n * the piece captured being rendered in place.\n * Still animation's lines are recalculated manually.\n */\nlet animatedArrows: Arrow[] = [];\n\n/**\n * A list of all hint arrows computed for the current frame.\n * Each hints at an off-screen individual legal move destination.\n */\nlet hintArrows: HintArrow[] = [];\n\n// Mode management ---------------------------------------------------------------------\n\n/** Returns the mode the arrow indicators on the edges of the screen is currently in. */\nfunction getMode(): typeof mode {\n\treturn mode;\n}\n\n/** Sets the current mode of the arrow indicators. */\nfunction setMode(value: typeof mode): void {\n\tmode = value;\n\tif (mode === 0) {\n\t\treset();\n\t\tarrowlegalmovehighlights.reset(); // Erase, otherwise their legal move highlights continue to render\n\t}\n}\n\n/** Rotates the current mode of the arrow indicators. */\nfunction toggleArrows(): void {\n\tframetracker.onVisualChange();\n\t// Have to do it weirdly like this, instead of using '++', because typescript complains that nextMode is of type number.\n\tlet nextMode: typeof mode =\n\t\tmode === 0 ? 1 : mode === 1 ? 2 : mode === 2 ? 3 : /* mode === 3 ? */ 0;\n\t// Calculate the cap\n\tconst cap = gameslot.getGamefile()!.boardsim.pieces.hippogonalsPresent ? 3 : 2;\n\tif (nextMode > cap) nextMode = 0; // Wrap back to zero\n\tsetMode(nextMode);\n}\n\n// Getters -----------------------------------------------------------------------------\n\n/**\n * Returns all Arrow objects currently in the slide arrows structure.\n * Does NOT include animated arrows.\n * Callers may mutate arrow properties (e.g. opacity) before rendering.\n */\nfunction getAllArrows(): Arrow[] {\n\tconst result: Arrow[] = [];\n\tfor (const linesOfDirection of Object.values(slideArrows)) {\n\t\tfor (const line of Object.values(linesOfDirection as { [lineKey: string]: ArrowsLine })) {\n\t\t\tfor (const arrow of line.posDotProd) result.push(arrow);\n\t\t\tfor (const arrow of line.negDotProd) result.push(arrow);\n\t\t}\n\t}\n\treturn result;\n}\n\n/** Returns the current slide arrows state for this frame. */\nfunction getSlideArrows(): SlideArrows {\n\treturn slideArrows;\n}\n\n/** Returns the current animated arrows for this frame. */\nfunction getAnimatedArrows(): Arrow[] {\n\treturn animatedArrows;\n}\n\n/**\n * Returns the list of arrow indicators hovered over this frame,\n * with references to the piece they are pointing to.\n */\nfunction getHoveredArrows(): HoveredArrow[] {\n\treturn hoveredArrows;\n}\n\n/** Returns the current hint arrows for this frame. */\nfunction getHintArrows(): HintArrow[] {\n\treturn hintArrows;\n}\n\n/**\n * Whether the mouse is currently hovering over at least one\n * arrow indicator of any type (piece or move hint) on the screen.\n */\nfunction areHoveringAtleastOneArrow(): boolean {\n\treturn hoveredArrows.length > 0 || hintArrows.some((ha) => ha.hovered);\n}\n\n/**\n * Returns the world-space locations of all arrow indicators present for the current frame.\n * Must be called after update().\n */\nfunction getAllArrowWorldLocations(): DoubleCoords[] {\n\treturn [...getAllArrows(), ...animatedArrows, ...hintArrows].map((a) => a.worldLocation);\n}\n\n/**\n * Whether the piece arrows should be calculated and rendered this frame.\n * Excludes move hint arrows--those are always active so long as we're zoomed in enough.\n */\nfunction areArrowsActiveThisFrame(): boolean {\n\t// false if the arrows are off, or if the board is too zoomed out\n\treturn mode !== 0 && arrowscalculator.areZoomedInEnoughForArrows();\n}\n\n// Frame lifecycle ---------------------------------------------------------------------\n\n/**\n * Resets the arrows lists in prep for the next frame.\n */\nfunction reset(): void {\n\tslideArrows = {};\n\tanimatedArrows = [];\n\thoveredArrows = [];\n\thintArrows = [];\n\tarrowshifts.reset();\n}\n\n/**\n * Calculates what arrows should be visible this frame.\n *\n * Needs to be done every frame, even if the mouse isn't moved,\n * since actions such as rewinding/forwarding may change them,\n * or board velocity.\n */\nfunction update(): void {\n\treset();\n\n\tconst result = arrowscalculator.calculateArrows(mode); // { active, slideArrows, hoveredArrows, hintArrows }\n\n\tif (result.hintArrows) hintArrows = result.hintArrows;\n\n\tif (!result.active) {\n\t\t// Arrow indicators are off, nothing is visible.\n\t\tarrowlegalmovehighlights.reset(); // Also reset this\n\t\treturn;\n\t}\n\n\thoveredArrows = result.hoveredArrows!;\n\tslideArrows = result.slideArrows!;\n}\n\n// Exports -----------------------------------------------------------------------------\n\nexport default {\n\t// Constants\n\tMAX_PIECES,\n\tMAX_LINES,\n\t// Mode management\n\tgetMode,\n\tsetMode,\n\ttoggleArrows,\n\t// Getters\n\tgetAllArrows,\n\tgetSlideArrows,\n\tgetAnimatedArrows,\n\tgetHoveredArrows,\n\tgetHintArrows,\n\tareHoveringAtleastOneArrow,\n\tgetAllArrowWorldLocations,\n\tareArrowsActiveThisFrame,\n\t// Frame lifecycle\n\tupdate,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/arrows/arrowscalculator.ts",
    "content": "// src/client/scripts/esm/game/rendering/arrows/arrowscalculator.ts\n\n/**\n * This script calculates which arrow indicators should be visible on the\n * screen edges each frame, where they should be positioned, and which\n * are being hovered over.\n *\n * It also computes hint arrows for off-screen legal move destinations.\n */\n\nimport type { Board, FullGame } from '../../../../../../shared/chess/logic/gamefile.js';\nimport type { BoundingBox, BoundingBoxBD } from '../../../../../../shared/util/math/bounds.js';\nimport type {\n\tBDCoords,\n\tCoords,\n\tDoubleCoords,\n} from '../../../../../../shared/chess/util/coordutil.js';\nimport type {\n\tArrow,\n\tArrowPiece,\n\tHoveredArrow,\n\tHintArrow,\n\tArrowsLine,\n\tSlideArrows,\n} from './arrows.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport jsutil from '../../../../../../shared/util/jsutil.js';\nimport bimath from '../../../../../../shared/util/math/bimath.js';\nimport bounds from '../../../../../../shared/util/math/bounds.js';\nimport typeutil from '../../../../../../shared/chess/util/typeutil.js';\nimport geometry from '../../../../../../shared/util/math/geometry.js';\nimport bdcoords from '../../../../../../shared/chess/util/bdcoords.js';\nimport coordutil from '../../../../../../shared/chess/util/coordutil.js';\nimport boardutil from '../../../../../../shared/chess/util/boardutil.js';\nimport legalmoves from '../../../../../../shared/chess/logic/legalmoves.js';\nimport { rawTypes as r } from '../../../../../../shared/chess/util/typeutil.js';\nimport vectors, { Vec2, Vec2Key } from '../../../../../../shared/util/math/vectors.js';\nimport organizedpieces, { LineKey } from '../../../../../../shared/chess/logic/organizedpieces.js';\n\nimport space from '../../misc/space.js';\nimport mouse from '../../../util/mouse.js';\nimport arrows from './arrows.js';\nimport gameslot from '../../chess/gameslot.js';\nimport boardpos from '../boardpos.js';\nimport movehints from '../highlights/movehints.js';\nimport boardtiles from '../boardtiles.js';\nimport Transition from '../transitions/Transition.js';\nimport perspective from '../perspective.js';\nimport guigameinfo from '../../gui/guigameinfo.js';\nimport guinavigation from '../../gui/guinavigation.js';\nimport { listener_overlay } from '../../chess/game.js';\nimport { InputListener, Mouse, MouseButton } from '../../input.js';\n\n// Types -------------------------------------------------------------------------------\n\n/**\n * An object containing all the arrow lines of a single frame,\n * BEFORE removing excess arrows due to our mode.\n */\ninterface SlideArrowsDraft {\n\t/** An object containing all existing arrows for a specific slide direction */\n\t[vec2Key: Vec2Key]: {\n\t\t/**\n\t\t * A single line containing what arrows should be visible on the\n\t\t * sides of the screen for offscreen pieces.\n\t\t */\n\t\t[lineKey: string]: ArrowsLineDraft;\n\t};\n}\n\n/**\n * An object containing the arrows that should actually be present,\n * for a single organized line intersecting through our screen,\n * BEFORE removing excess arrows due to our mode.\n *\n * The FIRST index in each of these left/right arrays, is the picture\n * which gets rendered at the default location.\n * The FINAL index in each of these, is the picture of the piece\n * that is CLOSEST to you (screen center) on the line!\n */\ninterface ArrowsLineDraft {\n\t/** Pieces on this line that intersect the screen with a positive dot product.\n\t * SORTED in order of closest to the screen to farthest. */\n\tposDotProd: ArrowDraft[];\n\t/** Pieces on this line that intersect the screen with a negative dot product.\n\t * SORTED in order of closest to the screen to farthest.\n\t * The arrow direction for these will be flipped to the other side. */\n\tnegDotProd: ArrowDraft[];\n\t/** An array of the points this line intersects the screen bounding box,\n\t * in order of ascending dot product. */\n\tintersections: [BDCoords, BDCoords];\n}\n\n/** A single arrow indicator DRAFT. This may be removed depending on our mode. */\ntype ArrowDraft = {\n\tpiece: ArrowPiece;\n\t/** Whether the piece the arrow is pointing to can legally slide at least\n\t * partially into the screen, not whether it can slide in that direction EVER. */\n\tcanSlideOntoScreen: boolean;\n};\n\n// Constants ---------------------------------------------------------------------------\n\n/** The width of all pictures of the pieces and their arrows, in percentage of 1 tile. */\nconst WIDTH = 0.65;\n/** How much padding to include between the pictures of the arrow\n * indicators and the edge of the screen, in percentage of 1 tile. */\nconst EDGE_GAP = 0.15; // Default: 0.15   0.1 Lines up the tip of the arrows right against the edge\n/** The precalculated distance one picture's center should be from the screen edge. */\nconst IMAGE_EDGE_DIST: BigDecimal = bd.fromNumber(WIDTH / 2 + EDGE_GAP);\n/** How much separation should be between stacked pictures pointing\n * to multiple pieces on the same line, in percentage of 1 tile. */\nconst STACK_PADDING = 0.35;\n/** Opacity of the mini images of the pieces and arrows. */\nexport const OPACITY = 0.6;\n/** The smallest width squares can be in virtual pixels\n * before skipping rendering arrow indicators (too small). */\nconst MIN_SQUARE_SIZE: BigDecimal = bd.fromBigInt(12n);\n/** The distance in perspective mode to render the arrow indicators from the camera.\n * We need this because there is no normal edge of the screen like in 2D mode. */\nconst PERSPECTIVE_EDGE_DIST = 17;\n\nconst HALF = bd.fromNumber(0.5);\n\n// State -------------------------------------------------------------------------------\n\n/**\n * The bounding box of the screen for this frame, with padding added so\n * that arrow indicators aren't touching the very edge of the screen.\n */\nlet boundingBoxFloat: BoundingBoxBD | undefined;\n/**\n * The bounding box of the screen for this frame,\n * rounded outward to contain the entirety of\n * any square even partially visible.\n */\nlet boundingBoxInt: BoundingBox | undefined;\n\n// Getters -----------------------------------------------------------------------------\n\nexport function getBoundingBoxFloat(): BoundingBoxBD | undefined {\n\treturn boundingBoxFloat;\n}\n\n/** Whether ANY arrow (piece or move hint) should be calculated and rendered this frame. */\nexport function areZoomedInEnoughForArrows(): boolean {\n\treturn bd.compare(boardtiles.gtileWidth_Pixels(false), MIN_SQUARE_SIZE) >= 0;\n}\n\n/**\n * Returns the world-space half-width of each arrow indicator's square hitbox for the current frame.\n * This is the Chebyshev-distance radius used to detect hover/opacity changes.\n */\nexport function getArrowIndicatorHalfWidth(): number {\n\treturn (WIDTH * boardpos.getBoardScaleAsNumber()) / 2;\n}\n\n// Main entry point --------------------------------------------------------------------\n\n/**\n * Calculates which arrows should be visible for a frame.\n * Always computes bounding boxes and hint arrows.\n * Only computes slide arrows when the mode is non-zero and zoom is sufficient.\n */\nexport function calculateArrows(mode: 0 | 1 | 2 | 3): {\n\t/** Whether piece arrows are active this frame. */\n\tactive: boolean;\n\tslideArrows?: SlideArrows;\n\thoveredArrows?: HoveredArrow[];\n\thintArrows?: HintArrow[];\n} {\n\tupdateBoundingBoxesOfVisibleScreen();\n\n\tif (!areZoomedInEnoughForArrows()) return { active: false };\n\n\tconst hintArrows = updateHintArrows();\n\n\tif (!arrows.areArrowsActiveThisFrame()) {\n\t\treturn { active: false, hintArrows: hintArrows };\n\t}\n\n\tconst slideArrowsDraft = generateArrowsDraft();\n\tremoveUnnecessaryArrows(slideArrowsDraft, mode);\n\tconst { slideArrows, hoveredArrows } = calculatePieceArrows(slideArrowsDraft);\n\treturn { active: true, slideArrows, hoveredArrows, hintArrows };\n}\n\n// Bounding box ------------------------------------------------------------------------\n\n/**\n * Calculates the visible bounding box of the screen for this frame,\n * both the integer-rounded-away, and the exact floating point one.\n *\n * These boxes are used to test whether a piece is visible on-screen or not.\n * As if it's not, it should get an arrow.\n */\nfunction updateBoundingBoxesOfVisibleScreen(): void {\n\tboundingBoxFloat = perspective.getEnabled()\n\t\t? boardtiles.generatePerspectiveBoundingBox(PERSPECTIVE_EDGE_DIST)\n\t\t: boardtiles.gboundingBoxFloat();\n\n\t// Apply the padding of the navigation and gameinfo bars to the screen bounding box.\n\tif (!perspective.getEnabled()) {\n\t\t// Perspective is OFF\n\t\tlet headerPad = space.convertPixelsToWorldSpace_Virtual(guinavigation.getHeightOfNavBar());\n\t\tlet footerPad = space.convertPixelsToWorldSpace_Virtual(\n\t\t\tguigameinfo.getHeightOfGameInfoBar(),\n\t\t);\n\t\t// Reverse header and footer pads if we're viewing black's side\n\t\tif (!gameslot.isLoadedGameViewingWhitePerspective())\n\t\t\t[headerPad, footerPad] = [footerPad, headerPad]; // Swap values\n\t\t// Apply the paddings to the bounding box\n\t\tboundingBoxFloat.top = bd.subtract(\n\t\t\tboundingBoxFloat.top,\n\t\t\tspace.convertWorldSpaceToGrid(headerPad),\n\t\t);\n\t\tboundingBoxFloat.bottom = bd.add(\n\t\t\tboundingBoxFloat.bottom,\n\t\t\tspace.convertWorldSpaceToGrid(footerPad),\n\t\t);\n\t}\n\n\t// If any part of the square is on screen, this box rounds outward to contain it.\n\tboundingBoxInt = boardtiles.roundAwayBoundingBox(boundingBoxFloat);\n\n\t/**\n\t * Adds a little bit of padding to the bounding box, so that the arrows of the\n\t * arrows indicators aren't touching the edge of the screen.\n\t */\n\tboundingBoxFloat.left = bd.add(boundingBoxFloat.left, IMAGE_EDGE_DIST);\n\tboundingBoxFloat.right = bd.subtract(boundingBoxFloat.right, IMAGE_EDGE_DIST);\n\tboundingBoxFloat.bottom = bd.add(boundingBoxFloat.bottom, IMAGE_EDGE_DIST);\n\tboundingBoxFloat.top = bd.subtract(boundingBoxFloat.top, IMAGE_EDGE_DIST);\n}\n\n// Arrow draft generation --------------------------------------------------------------\n\n/**\n * Generates a draft of all the arrows for a game, as if All (plus hippogonals) mode was on.\n * This contains minimal information, as some may be removed later.\n */\nfunction generateArrowsDraft(): SlideArrowsDraft {\n\t/** The running list of arrows that should be visible */\n\tconst slideArrowsDraft: SlideArrowsDraft = {};\n\tconst gamefile = gameslot.getGamefile()!;\n\tgamefile.boardsim.pieces.slides.forEach((slide: Vec2) => {\n\t\t// For each slide direction in the game...\n\t\tconst slideKey: Vec2Key = vectors.getKeyFromVec2(slide);\n\n\t\t// Find the 2 points on opposite sides of the bounding box\n\t\t// that will contain all organized lines of the given vector\n\t\t// intersecting the box between them.\n\n\t\tconst containingPoints = geometry.findCrossSectionalWidthPoints(slide, boundingBoxInt!);\n\t\tconst containingPointsLineC = containingPoints.map((point) =>\n\t\t\tvectors.getLineCFromCoordsAndVec(point, slide),\n\t\t) as [bigint, bigint];\n\t\t// Any line of this slope of which its C value is not within these 2 are outside of our screen,\n\t\t// so no arrows will be visible for the piece.\n\t\tcontainingPointsLineC.sort((a, b) => bimath.compare(a, b)); // Sort them so C is ascending. Then index 0 will be the minimum and 1 will be the max.\n\n\t\t// For all our lines in the game with this slope...\n\t\tconst organizedLinesOfDir = gamefile.boardsim.pieces.lines.get(slideKey)!;\n\t\tfor (const [lineKey, organizedLine] of organizedLinesOfDir) {\n\t\t\t// The C of the lineKey (`C|X`) with this slide at the very left & right sides of the screen.\n\t\t\tconst C: bigint = organizedpieces.getCFromKey(lineKey);\n\t\t\tif (\n\t\t\t\tbimath.compare(C, containingPointsLineC[0]) < 0 ||\n\t\t\t\tbimath.compare(C, containingPointsLineC[1]) > 0\n\t\t\t)\n\t\t\t\tcontinue; // Next line, this one is off-screen, so no piece arrows are visible\n\n\t\t\t// Calculate the ACTUAL arrows that should be visible for this specific organized line.\n\t\t\tconst arrowsLine = calcArrowsLineDraft(gamefile, slide, slideKey, organizedLine);\n\t\t\tif (arrowsLine === undefined) continue;\n\t\t\tif (!slideArrowsDraft[slideKey]) slideArrowsDraft[slideKey] = {}; // Make sure this exists first\n\t\t\tslideArrowsDraft[slideKey][lineKey] = arrowsLine; // Add this arrows line to our object containing all arrows for this frame\n\t\t}\n\t});\n\n\treturn slideArrowsDraft;\n}\n\n/**\n * Calculates what arrows should be visible for a single\n * organized line of pieces intersecting our screen.\n *\n * In a game with Huygens, there may be multiple arrows\n * stacked on each other in the same line, since Huygens\n * can jump/skip over other pieces.\n */\nexport function calcArrowsLineDraft(\n\tgamefile: FullGame,\n\tslideDir: Vec2,\n\tslideKey: Vec2Key,\n\torganizedline: number[],\n): ArrowsLineDraft | undefined {\n\tconst negDotProd: ArrowDraft[] = [];\n\tconst posDotProd: ArrowDraft[] = [];\n\n\t/** The innermost piece on the side that is closest to us (screen center). */\n\tlet closestPosDotProd: ArrowDraft | undefined;\n\t/** The innermost piece on the side that is closest to us (screen center). */\n\tlet closestNegDotProd: ArrowDraft | undefined;\n\n\tconst axis = slideDir[0] === 0n ? 1 : 0;\n\n\tconst firstPiece = boardutil.getPieceFromIdx(gamefile.boardsim.pieces, organizedline[0]!)!;\n\n\t/**\n\t * The 2 intersections points of the whole organized line, consistent for every piece on it.\n\t * The only difference is each piece may have a different dot product,\n\t * which just means it's on the opposite side.\n\t */\n\tconst intersections = geometry\n\t\t.findLineBoxIntersectionsBD(\n\t\t\tbdcoords.FromCoords(firstPiece.coords),\n\t\t\tslideDir,\n\t\t\tboundingBoxFloat!,\n\t\t)\n\t\t.map((c) => c.coords);\n\tif (intersections.length < 2) return; // Arrow line intersected screen box exactly on the corner!! Let's skip constructing this line. No arrow will be visible\n\n\tconst boundingBoxIntBD = bounds.castBoundingBoxToBigDecimal(boundingBoxInt!);\n\n\torganizedline.forEach((idx) => {\n\t\tconst piece = boardutil.getPieceFromIdx(gamefile.boardsim.pieces, idx)!;\n\t\tconst arrowPiece: ArrowPiece = {\n\t\t\ttype: piece.type,\n\t\t\tcoords: bdcoords.FromCoords(piece.coords),\n\t\t\tindex: piece.index,\n\t\t\tfloating: false,\n\t\t};\n\n\t\t// Is the piece off-screen?\n\t\tif (bounds.boxContainsSquareBD(boundingBoxIntBD, arrowPiece.coords)) return; // On-screen, no arrow needed\n\n\t\t// Piece is guaranteed off-screen...\n\n\t\tconst thisPieceIntersections = geometry.findLineBoxIntersectionsBD(\n\t\t\tarrowPiece.coords,\n\t\t\tslideDir,\n\t\t\tboundingBoxFloat!,\n\t\t);\n\t\tif (thisPieceIntersections.length < 2) return;\n\t\tconst positiveDotProduct = thisPieceIntersections[0]!.positiveDotProduct; // We know the dot product of both intersections will be identical, because the piece is off-screen.\n\n\t\tconst arrowDraft: ArrowDraft = { piece: arrowPiece, canSlideOntoScreen: false };\n\n\t\t// Update the piece that is closest to the screen box.\n\t\tif (positiveDotProduct) {\n\t\t\tif (closestPosDotProd === undefined) closestPosDotProd = arrowDraft;\n\t\t\telse if (bd.compare(arrowPiece.coords[axis], closestPosDotProd.piece.coords[axis]) > 0)\n\t\t\t\tclosestPosDotProd = arrowDraft;\n\t\t} else {\n\t\t\t// negativeDotProduct\n\t\t\tif (closestNegDotProd === undefined) closestNegDotProd = arrowDraft;\n\t\t\telse if (bd.compare(arrowPiece.coords[axis], closestNegDotProd.piece.coords[axis]) < 0)\n\t\t\t\tclosestNegDotProd = arrowDraft;\n\t\t}\n\n\t\t/**\n\t\t * Calculate its maximum slide.\n\t\t *\n\t\t * If it is able to slide (ignoring ignore function, and ignoring check respection)\n\t\t * into our screen area, then it should be guaranteed an arrow,\n\t\t * EVEN if it's not the closest piece to us on the line\n\t\t * (which would mean it phased/skipped over pieces due to a custom blocking function)\n\t\t */\n\n\t\t// prettier-ignore\n\t\tconst slideLegalLimit = legalmoves.calcPiecesLegalSlideLimitOnSpecificLine(gamefile.boardsim, gamefile.basegame.gameRules.worldBorder, piece, slideDir, slideKey, organizedline);\n\t\tif (slideLegalLimit === undefined) return; // This piece can't slide along the direction of travel\n\n\t\t/**\n\t\t * It CAN slide along our direction of travel...\n\t\t * But can it slide far enough where it can reach our screen?\n\t\t *\n\t\t * We already know the intersection points of its slide with the screen box.\n\t\t *\n\t\t * Next, how do we test if its legal slide protrudes into the screen?\n\t\t *\n\t\t * All we do is test if the piece's distance to the furthest point it can\n\t\t * slide is GREATER than its distance to the first intersection of the screen...\n\t\t */\n\n\t\t// If the vector is in the opposite direction, then the first intersection is swapped\n\t\tconst firstIntersection = positiveDotProduct\n\t\t\t? thisPieceIntersections[0]!\n\t\t\t: thisPieceIntersections[1]!;\n\n\t\t// What is the distance to the first intersection point?\n\t\tlet firstIntersectionDist = vectors.chebyshevDistanceBD(\n\t\t\tarrowPiece.coords,\n\t\t\tfirstIntersection.coords,\n\t\t);\n\t\t// Subtract the padding from the intersection so we get the distance to the intersection of the SCREEN EDGE.\n\t\tfirstIntersectionDist = bd.subtract(firstIntersectionDist, IMAGE_EDGE_DIST);\n\n\t\t// What is the distance to the farthest point this piece can slide along this direction?\n\t\tlet farthestSlidePoint: Coords | null;\n\t\tif (positiveDotProduct) {\n\t\t\tfarthestSlidePoint =\n\t\t\t\tslideLegalLimit[1] === null\n\t\t\t\t\t? null\n\t\t\t\t\t: [\n\t\t\t\t\t\t\t// Multiply by the number of steps the piece can do in that direction\n\t\t\t\t\t\t\tpiece.coords[0] + slideDir[0] * slideLegalLimit[1],\n\t\t\t\t\t\t\tpiece.coords[1] + slideDir[1] * slideLegalLimit[1],\n\t\t\t\t\t\t];\n\t\t} else {\n\t\t\t// Negative dot product\n\t\t\tfarthestSlidePoint =\n\t\t\t\tslideLegalLimit[0] === null\n\t\t\t\t\t? null\n\t\t\t\t\t: [\n\t\t\t\t\t\t\tpiece.coords[0] - slideDir[0] * slideLegalLimit[0],\n\t\t\t\t\t\t\tpiece.coords[1] - slideDir[1] * slideLegalLimit[0],\n\t\t\t\t\t\t];\n\t\t}\n\t\tconst farthestSlidePointDist: bigint | null =\n\t\t\tfarthestSlidePoint === null\n\t\t\t\t? null\n\t\t\t\t: vectors.chebyshevDistance(piece.coords, farthestSlidePoint);\n\n\t\t// If the farthest slide point distance is greater than the first intersection\n\t\t// distance, then the piece is able to slide into the screen bounding box!\n\n\t\tif (farthestSlidePointDist !== null) {\n\t\t\tlet farthestSlidePointDistBD = bd.fromBigInt(farthestSlidePointDist);\n\t\t\t// Add the additional distance from the center of the square to its edge\n\t\t\t// This is so that if any part of the furthest square highlight to\n\t\t\t// move to is visible on screen, we will still render the arrow!\n\t\t\tfarthestSlidePointDistBD = bd.add(farthestSlidePointDistBD, HALF);\n\n\t\t\t// If the farthest slide point distance is less than the first intersection distance,\n\t\t\t// then this piece cannot slide onto the screen, so we skip it.\n\t\t\tif (bd.compare(farthestSlidePointDistBD, firstIntersectionDist) < 0) return; // This piece cannot slide so far as to intersect the screen bounding box\n\t\t}\n\n\t\t// This piece CAN slide far enough to enter our screen...\n\t\tarrowDraft.canSlideOntoScreen = true;\n\n\t\t// Add the piece to the arrow line\n\t\tif (positiveDotProduct) posDotProd.push(arrowDraft);\n\t\telse /* Opposite side */ negDotProd.push(arrowDraft);\n\t});\n\n\t/**\n\t * Add the closest left/right pieces if they haven't been added already\n\t * (which would only be the case if they can slide onto our screen),\n\t * And DON'T add them if they are a VOID square!\n\t */\n\tif (\n\t\tclosestPosDotProd !== undefined &&\n\t\t!posDotProd.includes(closestPosDotProd) &&\n\t\ttypeutil.getRawType(closestPosDotProd.piece.type) !== r.VOID\n\t)\n\t\tposDotProd.push(closestPosDotProd);\n\tif (\n\t\tclosestNegDotProd !== undefined &&\n\t\t!negDotProd.includes(closestNegDotProd) &&\n\t\ttypeutil.getRawType(closestNegDotProd.piece.type) !== r.VOID\n\t)\n\t\tnegDotProd.push(closestNegDotProd);\n\n\tif (posDotProd.length === 0 && negDotProd.length === 0) return; // If both are empty, return undefined\n\n\t// Now sort them.\n\tposDotProd.sort((entry1, entry2) =>\n\t\tbd.compare(entry1.piece.coords[axis], entry2.piece.coords[axis]),\n\t);\n\tnegDotProd.sort((entry1, entry2) =>\n\t\tbd.compare(entry2.piece.coords[axis], entry1.piece.coords[axis]),\n\t);\n\n\treturn { negDotProd, posDotProd, intersections: intersections as [BDCoords, BDCoords] };\n}\n\n// Mode-based filtering ----------------------------------------------------------------\n\n/**\n * Removes arrows based on the mode.\n *\n * mode == 1: Removes arrows to ONLY include the pieces which can legally slide into our screen (which may include hippogonals)\n * mode == 2: Everything in mode 1, PLUS all orthogonals and diagonals, whether or not the piece can slide into our screen\n * mode == 3: Everything in mode 1 & 2, PLUS all hippogonals, whether or not the piece can slide into our screen\n */\nfunction removeUnnecessaryArrows(slideArrowsDraft: SlideArrowsDraft, mode: 0 | 1 | 2 | 3): void {\n\tif (mode === 3) return; // Don't remove anything\n\n\tconst slideExceptions = getSlideExceptions(mode);\n\n\tfor (const direction in slideArrowsDraft) {\n\t\tif (slideExceptions.includes(direction as Vec2Key)) continue; // Keep it anyway, our arrows mode is high enough\n\t\t// Remove types that can't slide onto the screen...\n\t\tconst arrowsByDir = slideArrowsDraft[direction as Vec2Key];\n\t\tfor (const key in arrowsByDir) {\n\t\t\t// LineKey\n\t\t\tconst line: ArrowsLineDraft = arrowsByDir[key]!;\n\t\t\tremoveTypesThatCantSlideOntoScreenFromLineDraft(line);\n\t\t\tif (line.negDotProd.length === 0 && line.posDotProd.length === 0)\n\t\t\t\tdelete arrowsByDir[key as LineKey];\n\t\t}\n\t\tif (jsutil.isEmpty(slideArrowsDraft[direction as Vec2Key]!))\n\t\t\tdelete slideArrowsDraft[direction as Vec2Key];\n\t}\n}\n\n/** Checks if a single animated arrow is needed, based on our current mode, and its direction. */\nexport function isAnimatedArrowUnnecessary(\n\tboardsim: Board,\n\ttype: number,\n\tdirection: Vec2,\n\tdirKey: Vec2Key,\n\tmode: 0 | 1 | 2 | 3,\n): boolean {\n\tif (mode === 3) return false; // Keep it, whether hippogonal orthogonal or diagonal\n\tif (mode === 2) return vectors.chebyshevDistance([0n, 0n], direction) !== 1n; // Only keep orthogonals and diagonals, NO hippogonals.\n\n\t// mode must === 1, only keep it if it can slide in the direction, whether blocked or not\n\tconst thisPieceMoveset = legalmoves.getPieceMoveset(boardsim, type); // Default piece moveset\n\tif (!thisPieceMoveset.sliding) return true; // This piece can't slide at all\n\tif (!thisPieceMoveset.sliding[dirKey]) return true; // This piece can't slide ALONG the provided line\n\t// This piece CAN slide along the provided line...\n\treturn false;\n}\n\n/**\n * IF we're in mode 2, this returns an array of all orthogonal and diagonal vectors.\n * We don't return anything if it's mode 3, since EVERYTHING is an exception anyway.\n * If it's mode 1, we don't return anything either, because it depends on whether\n * the piece can slide into the direction of the vector, and onto our screen.\n */\nexport function getSlideExceptions(mode: 0 | 1 | 2 | 3): Vec2Key[] {\n\tconst gamefile = gameslot.getGamefile()!;\n\tlet slideExceptions: Vec2Key[] = [];\n\t// If we're in mode 2, retain all orthogonals and diagonals, EVEN if they can't slide in that direction.\n\tif (mode === 2)\n\t\tslideExceptions = gamefile.boardsim.pieces.slides\n\t\t\t.filter((slideDir: Vec2) => vectors.chebyshevDistance([0n, 0n], slideDir) === 1n) // Filter out all hippogonal and greater vectors\n\t\t\t.map((v) => vectors.getKeyFromVec2(v));\n\treturn slideExceptions;\n}\n\nexport function removeTypesThatCantSlideOntoScreenFromLineDraft(line: ArrowsLineDraft): void {\n\t// The only pieces in a line that WOULDN'T be able to slide onto the screen\n\t// is the piece closest to us. ALL other pieces we wouldn't have added otherwise.\n\tif (line.negDotProd.length > 0) {\n\t\tconst arrowDraft: ArrowDraft = line.negDotProd[line.negDotProd.length - 1]!;\n\t\tif (!arrowDraft.canSlideOntoScreen) line.negDotProd.pop();\n\t}\n\tif (line.posDotProd.length > 0) {\n\t\tconst arrowDraft: ArrowDraft = line.posDotProd[line.posDotProd.length - 1]!;\n\t\tif (!arrowDraft.canSlideOntoScreen) line.posDotProd.pop();\n\t}\n}\n\n// Finalizing arrows -------------------------------------------------------------------\n\n/**\n * Converts all arrow drafts into fully computed arrows with world-space positions\n * and hover detection. Collects all hovered arrows.\n */\nfunction calculatePieceArrows(slideArrowsDraft: SlideArrowsDraft): {\n\tslideArrows: SlideArrows;\n\thoveredArrows: HoveredArrow[];\n} {\n\tconst slideArrows: SlideArrows = {};\n\tconst hoveredArrows: HoveredArrow[] = [];\n\n\tconst worldHalfWidth = getArrowIndicatorHalfWidth();\n\tconst pointerWorlds = mouse.getAllPointerWorlds();\n\n\tfor (const vec2Key of Object.keys(slideArrowsDraft) as Vec2Key[]) {\n\t\tconst linesOfDirectionDraft = slideArrowsDraft[vec2Key]!;\n\t\tconst slideDir = vectors.getVec2FromKey(vec2Key);\n\t\tconst linesOfDirection: { [lineKey: string]: ArrowsLine } = {};\n\n\t\tfor (const lineKey of Object.keys(linesOfDirectionDraft)) {\n\t\t\tconst arrowLineDraft = linesOfDirectionDraft[lineKey]!;\n\t\t\t// prettier-ignore\n\t\t\tconst { line, newHoveredArrows } = convertLineDraftToLine(arrowLineDraft, slideDir, vec2Key, worldHalfWidth, pointerWorlds, true);\n\t\t\tlinesOfDirection[lineKey] = line;\n\t\t\thoveredArrows.push(...newHoveredArrows);\n\t\t}\n\n\t\tslideArrows[vec2Key] = linesOfDirection;\n\t}\n\n\treturn { slideArrows, hoveredArrows };\n}\n\n/**\n * Converts an {@link ArrowsLineDraft} into a fully computed {@link ArrowsLine},\n * resolving world-space positions and hover detection for each arrow.\n * @param appendHover - When true, also computes ownsSlide and collects hovered arrows.\n */\nexport function convertLineDraftToLine(\n\tdraft: ArrowsLineDraft,\n\tslideDir: Vec2,\n\tvec2Key: Vec2Key,\n\tworldHalfWidth: number,\n\tpointerWorlds: DoubleCoords[],\n\tappendHover: boolean,\n): { line: ArrowsLine; newHoveredArrows: HoveredArrow[] } {\n\tconst negVector = vectors.negateVector(slideDir);\n\tconst boardsim = gameslot.getGamefile()!.boardsim!;\n\tconst newHoveredArrows: HoveredArrow[] = [];\n\n\tconst toArrow = (\n\t\tdir: Vec2,\n\t\tintersection: BDCoords,\n\t\tarrowDraft: ArrowDraft,\n\t\tindex: number,\n\t): Arrow => {\n\t\t// prettier-ignore\n\t\tconst arrow = processPiece(arrowDraft.piece, dir, intersection, index, worldHalfWidth, pointerWorlds);\n\t\tif (appendHover && arrow.hovered) {\n\t\t\tconst moveset = legalmoves.getPieceMoveset(boardsim, arrowDraft.piece.type);\n\t\t\tconst ownsSlide = !!(moveset.sliding && moveset.sliding[vec2Key]);\n\n\t\t\tnewHoveredArrows.push({\n\t\t\t\tpiece: arrow.piece,\n\t\t\t\tdirection: arrow.direction,\n\t\t\t\tworldLocation: arrow.worldLocation,\n\t\t\t\townsSlide,\n\t\t\t});\n\t\t}\n\t\treturn arrow;\n\t};\n\n\tconst line: ArrowsLine = {\n\t\tposDotProd: draft.posDotProd.map((ad, i) =>\n\t\t\ttoArrow(slideDir, draft.intersections[0], ad, i),\n\t\t),\n\t\tnegDotProd: draft.negDotProd.map((ad, i) =>\n\t\t\ttoArrow(negVector, draft.intersections[1], ad, i),\n\t\t),\n\t};\n\treturn { line, newHoveredArrows };\n}\n\n/**\n * Calculates the detailed information about a single arrow indicator, enough to be able to render.\n * @param piece\n * @param vector - A vector pointing TOWARD the piece (from screen edge outward). Used for adjacent-picture offsets and click transitions.\n * @param intersection - The intersection with the screen window that the line the piece is on intersects.\n * @param stackIndex - If there are adjacent pictures, this may be > 0\n * @param worldHalfWidth\n * @param pointerWorlds - A list of all world coordinates every existing pointer is over.\n */\nexport function processPiece(\n\tpiece: ArrowPiece,\n\tvector: Vec2,\n\tintersection: BDCoords,\n\tstackIndex: number,\n\tworldHalfWidth: number,\n\tpointerWorlds: DoubleCoords[],\n): Arrow {\n\tconst renderCoords = intersection; // Don't think we need to deep copy?\n\n\tconst worldLocation: DoubleCoords =\n\t\tspace.convertCoordToWorldSpace_IgnoreSquareCenter(renderCoords);\n\n\t// If this picture is a stacked picture, adjust it's positioning\n\tif (stackIndex > 0) {\n\t\tconst scale = boardpos.getBoardScaleAsNumber();\n\t\tworldLocation[0] += Number(vector[0]) * stackIndex * STACK_PADDING * scale;\n\t\tworldLocation[1] += Number(vector[1]) * stackIndex * STACK_PADDING * scale;\n\t}\n\n\t// Does the mouse hover over the piece?\n\tlet hovered = false;\n\tfor (const pointerWorld of pointerWorlds) {\n\t\tconst chebyshevDist = vectors.chebyshevDistanceDoubles(worldLocation, pointerWorld);\n\t\tif (chebyshevDist < worldHalfWidth) hovered = true; // Mouse inside the picture bounding box\n\t}\n\t// Teleports toward the given piece if its arrow indicator is clicked this frame.\n\ttransitionTowardTargetIfClicked(piece.coords, vector, worldLocation, worldHalfWidth);\n\n\tconst direction = vectors.negateVector(vector);\n\treturn { worldLocation, piece, hovered, opacity: OPACITY, direction, stackIndex };\n}\n\n/**\n * If a recognized click falls within worldHalfWidth of\n * worldLocation, claims it and pans towards the target coordinates.\n */\nfunction transitionTowardTargetIfClicked(\n\ttargetCoords: BDCoords,\n\tdirection: Vec2,\n\tworldLocation: DoubleCoords,\n\tworldHalfWidth: number,\n): void {\n\tlet button: MouseButton;\n\tlet listener: typeof mouse | InputListener;\n\n\t// Left mouse button\n\tif (mouse.isMouseClicked(Mouse.LEFT)) {\n\t\tbutton = Mouse.LEFT;\n\t\tlistener = mouse;\n\t}\n\t// Finger simulating right mouse down (annotations mode ON)\n\telse if (\n\t\tlistener_overlay.isMouseClicked(Mouse.RIGHT) &&\n\t\tlistener_overlay.isMouseTouch(Mouse.RIGHT)\n\t) {\n\t\tbutton = Mouse.RIGHT;\n\t\tlistener = listener_overlay;\n\t} else return; // No recognized click\n\n\tconst clickWorld = mouse.getMouseWorld(button);\n\tif (!clickWorld) return; // Maybe we're looking into sky?\n\tif (vectors.chebyshevDistanceDoubles(worldLocation, clickWorld) >= worldHalfWidth) return;\n\t// Mouse is inside the picture bounding box...\n\tlistener.claimMouseClick(button); // Don't let annotations erase/draw\n\n\t// Pan along parallel direction to the perpendicular foot of targetCoords, NOT straight to the piece.\n\n\tconst startCoords = boardpos.getBoardPos();\n\t// The direction we will follow when teleporting\n\tconst line1GeneralForm = vectors.getLineGeneralFormFromCoordsAndVecBD(startCoords, direction);\n\t// The line perpendicular to the target piece === The Normal\n\tconst perpendicularSlideDir: Vec2 = vectors.getPerpendicularVector(direction);\n\tconst line2GeneralForm = vectors.getLineGeneralFormFromCoordsAndVecBD(\n\t\ttargetCoords,\n\t\tperpendicularSlideDir,\n\t);\n\t// The target teleport coords\n\tconst telCoords = geometry.calcIntersectionPointOfLinesBD(\n\t\t...line1GeneralForm,\n\t\t...line2GeneralForm,\n\t)!; // We know it will be defined because they are PERPENDICULAR\n\n\tTransition.startPanTransition(telCoords, false);\n}\n\n// Hint Arrows -------------------------------------------------------------------------\n\n/**\n * Computes hint arrows for the current frame.\n * For each off-screen square returned by {@link movehints.getSquares},\n * creates a hint arrow at the nearest screen edge pointing toward that square.\n *\n * Respects the zoom threshold but ignores the current arrow mode,\n * so hint arrows are visible even when mode is 0 (off).\n */\nfunction updateHintArrows(): HintArrow[] {\n\tconst hintSquares = movehints.getSquares();\n\tif (hintSquares.length === 0) return [];\n\n\tconst pieceCoords = movehints.getPieceCoords()!;\n\n\tconst worldHalfWidth = getArrowIndicatorHalfWidth();\n\tconst pointerWorlds = mouse.getAllPointerWorlds();\n\tconst newHintArrows: HintArrow[] = [];\n\n\tfor (const hintSquare of hintSquares) {\n\t\tconst hintSquareBD = bdcoords.FromCoords(hintSquare);\n\n\t\t// Skip if the hint square is already visible on screen\n\t\tif (bounds.boxContainsSquare(boundingBoxInt!, hintSquare)) continue;\n\n\t\t// Direction from the selected piece toward the hint square\n\t\tconst difference = coordutil.subtractCoords(hintSquare, pieceCoords);\n\t\tlet direction: Vec2 = vectors.normalizeVector(difference);\n\n\t\t// Calculate the world space position of the near-side screen edge intersection\n\t\t// along the line from the piece to the hint square.\n\t\tconst intersections = geometry.findLineBoxIntersectionsBD(\n\t\t\thintSquareBD,\n\t\t\tdirection,\n\t\t\tboundingBoxFloat!,\n\t\t);\n\t\tif (intersections.length < 2) continue; // Arrow line does not intersect screen.\n\t\tconst nearSide = intersections[0]!.positiveDotProduct\n\t\t\t? intersections[0]!.coords\n\t\t\t: intersections[1]!.coords;\n\t\tconst worldLocation = space.convertCoordToWorldSpace_IgnoreSquareCenter(nearSide);\n\n\t\t// If we've panned past the hint square, flip the triangle so it still points toward the square\n\t\tif (intersections[0]!.positiveDotProduct) direction = vectors.negateVector(direction);\n\n\t\t// Whether any pointer is within worldHalfWidth of the given world location.\n\t\tconst hovered = pointerWorlds.some(\n\t\t\t(p) => vectors.chebyshevDistanceDoubles(worldLocation, p) < worldHalfWidth,\n\t\t);\n\t\t// Prevent dragging the board when clicking on the move hint arrow.\n\t\tif (hovered && mouse.isMouseDown(Mouse.LEFT)) mouse.claimMouseDown(Mouse.LEFT);\n\n\t\ttransitionTowardTargetIfClicked(hintSquareBD, direction, worldLocation, worldHalfWidth);\n\n\t\tnewHintArrows.push({ worldLocation, direction, targetSquare: hintSquare, hovered });\n\t}\n\n\treturn newHintArrows;\n}\n\n// Exports -----------------------------------------------------------------------------\n\nexport default {\n\t// Constants\n\tOPACITY,\n\t// Getters\n\tgetBoundingBoxFloat,\n\tareZoomedInEnoughForArrows,\n\tgetArrowIndicatorHalfWidth,\n\t// Main entry point\n\tcalculateArrows,\n\t// Arrow draft generation\n\tcalcArrowsLineDraft,\n\t// Mode-based filtering\n\tisAnimatedArrowUnnecessary,\n\tgetSlideExceptions,\n\tremoveTypesThatCantSlideOntoScreenFromLineDraft,\n\t// Finalizing arrows\n\tconvertLineDraftToLine,\n\tprocessPiece,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/arrows/arrowsgraphics.ts",
    "content": "// src/client/scripts/esm/game/rendering/arrows/arrowsgraphics.ts\n\n/**\n * This script renders all arrow indicators on the screen edges,\n * including piece indicators (pointing to off-screen pieces)\n * and hint arrows (pointing to off-screen legal move squares).\n */\n\nimport type { Color } from '../../../../../../shared/util/math/math.js';\nimport type { Arrow, ArrowsLine } from './arrows.js';\nimport type { AttributeInfoInstanced } from '../../../webgl/Renderable.js';\n\nimport vectors from '../../../../../../shared/util/math/vectors.js';\n\nimport arrows from './arrows.js';\nimport meshes from '../meshes.js';\nimport primitives from '../primitives.js';\nimport preferences from '../../../components/header/preferences.js';\nimport drawsquares from '../highlights/annotations/drawsquares.js';\nimport texturecache from '../../../chess/rendering/texturecache.js';\nimport instancedshapes from '../instancedshapes.js';\nimport arrowscalculator from './arrowscalculator.js';\nimport {\n\tcreateRenderable_Instanced,\n\tcreateRenderable_Instanced_GivenInfo,\n} from '../../../webgl/Renderable.js';\n\n// Constants ---------------------------------------------------------------------------\n\n/** The size of arrow triangles as a fraction of the arrow indicator half-width. */\nexport const ARROW_SIZE_RATIO = 0.3;\n\n/** Attribute layout for the instanced piece-image renderable. */\nconst ATTRIB_INFO_PICTURES: AttributeInfoInstanced = {\n\tvertexDataAttribInfo: [\n\t\t{ name: 'a_position', numComponents: 2 },\n\t\t{ name: 'a_texturecoord', numComponents: 2 },\n\t],\n\tinstanceDataAttribInfo: [\n\t\t{ name: 'a_instanceposition', numComponents: 2 },\n\t\t{ name: 'a_instancecolor', numComponents: 4 },\n\t],\n};\n\n/** Attribute layout for the instanced arrow-triangle renderable. */\nconst ATTRIB_INFO_ARROWS: AttributeInfoInstanced = {\n\tvertexDataAttribInfo: [{ name: 'a_position', numComponents: 2 }],\n\tinstanceDataAttribInfo: [\n\t\t{ name: 'a_instanceposition', numComponents: 2 },\n\t\t{ name: 'a_instancecolor', numComponents: 4 },\n\t\t{ name: 'a_instancerotation', numComponents: 1 },\n\t],\n};\n\n// Functions ---------------------------------------------------------------------------\n\n/** Renders all the arrow indicators for this frame. */\nexport function render(): void {\n\tconst slideArrows = arrows.getSlideArrows();\n\tconst animatedArrows = arrows.getAnimatedArrows();\n\tconst hintArrows = arrows.getHintArrows();\n\tconst worldHalfWidth = arrowscalculator.getArrowIndicatorHalfWidth();\n\tif (\n\t\tObject.keys(slideArrows).length === 0 &&\n\t\tanimatedArrows.length === 0 &&\n\t\thintArrows.length === 0\n\t)\n\t\treturn; // No visible arrows, don't generate the model\n\n\t// Position data of the single quad instance\n\tconst left = -worldHalfWidth;\n\tconst right = worldHalfWidth;\n\tconst bottom = -worldHalfWidth;\n\tconst top = worldHalfWidth;\n\t// Texture data of the single quad instance\n\tconst { texleft, texbottom, texright, textop } = meshes.getPieceTexCoords();\n\n\t// Initialize the data arrays...\n\n\tconst vertexData_Pictures: number[] = primitives.Quad_Texture(left, bottom, right, top, texleft, texbottom, texright, textop); // prettier-ignore\n\t/** Maps each piece type to its list of instance data (position + color per instance). */\n\tconst instanceDataByType = new Map<number, number[]>();\n\n\tconst vertexData_Arrows: number[] = getVertexDataOfArrow(worldHalfWidth);\n\tconst instanceData_Arrows: number[] = [];\n\n\t// Add the data...\n\n\tfor (const linesOfDirection of Object.values(slideArrows)) {\n\t\tfor (const line of Object.values(linesOfDirection) as ArrowsLine[]) {\n\t\t\tfor (const arrow of line.posDotProd)\n\t\t\t\tconcatData(instanceDataByType, instanceData_Arrows, arrow);\n\t\t\tfor (const arrow of line.negDotProd)\n\t\t\t\tconcatData(instanceDataByType, instanceData_Arrows, arrow);\n\t\t}\n\t}\n\tfor (const arrow of animatedArrows) {\n\t\tconcatData(instanceDataByType, instanceData_Arrows, arrow);\n\t}\n\n\t// Render hint squares first (below piece images)\n\tif (hintArrows.length > 0) {\n\t\tconst hintColor = preferences.getLegalMoveHighlightColor({\n\t\t\tisOpponentPiece: false,\n\t\t\tisPremove: false,\n\t\t});\n\n\t\tconst size = worldHalfWidth * 2;\n\n\t\t// Green squares at screen edge for each hint arrow\n\t\tconst hintSquaresInstanceData: number[] = hintArrows.flatMap((ha) => ha.worldLocation);\n\t\tcreateRenderable_Instanced(\n\t\t\tinstancedshapes.getDataLegalMoveSquare(hintColor),\n\t\t\thintSquaresInstanceData,\n\t\t\t'TRIANGLES',\n\t\t\t'highlights',\n\t\t\ttrue,\n\t\t).render(undefined, undefined, { u_size: size });\n\n\t\t// Re-render hovered hint squares at increased opacity on top\n\t\tconst hoveredHintSquaresInstanceData: number[] = hintArrows.filter((ha) => ha.hovered).flatMap((ha) => ha.worldLocation); // prettier-ignore\n\t\tif (hoveredHintSquaresInstanceData.length > 0) {\n\t\t\tconst hoveredHintColor: Color = [...hintColor];\n\t\t\thoveredHintColor[3] = drawsquares.HOVER_OPACITY;\n\t\t\tcreateRenderable_Instanced(\n\t\t\t\tinstancedshapes.getDataLegalMoveSquare(hoveredHintColor),\n\t\t\t\thoveredHintSquaresInstanceData,\n\t\t\t\t'TRIANGLES',\n\t\t\t\t'highlights',\n\t\t\t\ttrue,\n\t\t\t).render(undefined, undefined, { u_size: size });\n\t\t}\n\n\t\t// Append hint direction triangles into the shared arrow triangle array\n\t\tfor (const ha of hintArrows) {\n\t\t\tconst dirAsDoubles = vectors.convertVectorToDoubles(ha.direction);\n\t\t\tconst angle = Math.atan2(dirAsDoubles[1], dirAsDoubles[0]);\n\t\t\tconst a = ha.hovered ? 1 : arrowscalculator.OPACITY;\n\t\t\tinstanceData_Arrows.push(...ha.worldLocation, 0, 0, 0, a, angle);\n\t\t}\n\t}\n\n\t// Render piece images for regular (piece) arrow indicators, one draw call per type.\n\tfor (const [type, instanceData] of instanceDataByType) {\n\t\tcreateRenderable_Instanced_GivenInfo(\n\t\t\tvertexData_Pictures,\n\t\t\tinstanceData,\n\t\t\tATTRIB_INFO_PICTURES,\n\t\t\t'TRIANGLES',\n\t\t\t'arrowImages',\n\t\t\t[{ texture: texturecache.getTexture(type), uniformName: 'u_sampler' }],\n\t\t).render();\n\t}\n\n\t// Render all arrow direction triangles (regular piece arrows + hint arrows) together\n\tif (instanceData_Arrows.length > 0) {\n\t\tcreateRenderable_Instanced_GivenInfo(\n\t\t\tvertexData_Arrows,\n\t\t\tinstanceData_Arrows,\n\t\t\tATTRIB_INFO_ARROWS,\n\t\t\t'TRIANGLES',\n\t\t\t'arrows',\n\t\t).render();\n\t}\n}\n\n/**\n * Takes a piece arrow, appends its picture instance data into the per-type map\n * and (if not stacked) appends its triangle instance data to the arrows array.\n */\nfunction concatData(\n\tinstanceDataByType: Map<number, number[]>,\n\tinstanceData_Arrows: number[],\n\tarrow: Arrow,\n): void {\n\t/**\n\t * Our pictures' instance data needs to contain:\n\t *\n\t * position offset (2 numbers)\n\t * unique color (4 numbers)\n\t */\n\n\tconst a = arrow.hovered ? 1 : arrow.opacity;\n\n\tlet typeData = instanceDataByType.get(arrow.piece.type);\n\tif (typeData === undefined) {\n\t\ttypeData = [];\n\t\tinstanceDataByType.set(arrow.piece.type, typeData);\n\t}\n\ttypeData.push(...arrow.worldLocation, 1, 1, 1, a);\n\n\t// Next append the data of the little arrow!\n\n\tif (arrow.stackIndex > 0) return; // We can skip, since it is a stacked picture! Each stack gets just one arrow.\n\n\t/**\n\t * Our arrow's instance data needs to contain:\n\t *\n\t * position offset (2 numbers)\n\t * unique color (4 numbers)\n\t * rotation offset (1 number)\n\t */\n\n\tconst dirAsDoubles = vectors.convertVectorToDoubles(arrow.direction);\n\tconst angle = Math.atan2(dirAsDoubles[1], dirAsDoubles[0]); // Y value first\n\t//\t\t\t\t\t\t\t\tposition\t\t   color\trotation\n\tinstanceData_Arrows.push(...arrow.worldLocation, 0, 0, 0, a, angle);\n}\n\n/**\n * Returns the vertex data of a single arrow instance,\n * for this frame, only containing positional information.\n * @param halfWorldWidth - Half of the width of the arrow indicators for the current frame (dependant on scale).\n */\nfunction getVertexDataOfArrow(halfWorldWidth: number): number[] {\n\tconst size = halfWorldWidth * ARROW_SIZE_RATIO;\n\t// prettier-ignore\n\treturn [\n\t\thalfWorldWidth,       -size,\n\t\thalfWorldWidth,        size,\n\t\thalfWorldWidth + size, 0,\n\t];\n}\n\n// Exports -----------------------------------------------------------------------------\n\nexport default {\n\t// Frame lifecycle\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/arrows/arrowshifts.ts",
    "content": "// src/client/scripts/esm/game/rendering/arrows/arrowshifts.ts\n\n/**\n * This script manages mid-frame arrow modifications (arrow shifts).\n *\n * Other scripts call deleteArrow(), moveArrow(), animateArrow(), and addArrow()\n * between the arrows update() and render() calls. Those \"shifts\" are then\n * applied all at once by executeArrowShifts() before rendering.\n */\n\nimport type { Piece } from '../../../../../../shared/chess/util/boardutil.js';\nimport type { Change } from '../../../../../../shared/chess/logic/boardchanges.js';\nimport type { Vec2Key } from '../../../../../../shared/util/math/vectors.js';\nimport type { FullGame } from '../../../../../../shared/chess/logic/gamefile.js';\nimport type { Arrow, ArrowPiece, SlideArrows } from './arrows.js';\nimport type {\n\tBDCoords,\n\tCoords,\n\tDoubleCoords,\n} from '../../../../../../shared/chess/util/coordutil.js';\n\nimport bd from '@naviary/bigdecimal';\n\nimport bounds from '../../../../../../shared/util/math/bounds.js';\nimport vectors from '../../../../../../shared/util/math/vectors.js';\nimport geometry from '../../../../../../shared/util/math/geometry.js';\nimport coordutil from '../../../../../../shared/chess/util/coordutil.js';\nimport boardutil from '../../../../../../shared/chess/util/boardutil.js';\nimport boardchanges from '../../../../../../shared/chess/logic/boardchanges.js';\nimport organizedpieces from '../../../../../../shared/chess/logic/organizedpieces.js';\n\nimport mouse from '../../../util/mouse.js';\nimport arrows from './arrows.js';\nimport gameslot from '../../chess/gameslot.js';\nimport arrowscalculator from './arrowscalculator.js';\n\n// Types ----------------------------------------------------\n\n/** An Arrow Shift/Modification. */\ntype Shift =\n\t| {\n\t\t\tkind: 'delete';\n\t\t\tstart: Coords;\n\t  }\n\t| {\n\t\t\tkind: 'move';\n\t\t\tstart: Coords;\n\t\t\tend: Coords;\n\t  }\n\t| {\n\t\t\tkind: 'animate';\n\t\t\tstart: Coords;\n\t\t\tend: BDCoords;\n\t\t\ttype: number;\n\t  }\n\t| {\n\t\t\tkind: 'add';\n\t\t\ttype: number;\n\t\t\tend: Coords;\n\t  };\n\n// Constants ----------------------------------------------\n\nconst ONE = bd.fromBigInt(1n);\n\n// State --------------------------------------------------\n\n/**\n * A list of arrow modifications made by other\n * scripts after update() and before render().\n */\nlet shifts: Shift[] = [];\n\n// Functions -------------------------------------------------------------\n\n/** Clears the pending shifts list. Called from arrows.reset() at the start of each frame. */\nexport function reset(): void {\n\tshifts.length = 0;\n}\n\n/**\n * Piece deleted from start coords\n * => Arrow line recalculated\n */\nexport function deleteArrow(start: Coords): void {\n\tif (!arrows.areArrowsActiveThisFrame()) return;\n\toverwriteArrows(start);\n\tshifts.push({ kind: 'delete', start });\n}\n\n/**\n * Piece deleted on start coords and added on end coords\n * => Arrow lines recalculated\n */\nexport function moveArrow(start: Coords, end: Coords): void {\n\tif (!arrows.areArrowsActiveThisFrame()) return;\n\toverwriteArrows(start);\n\tshifts.push({ kind: 'move', start, end });\n}\n\n/**\n * Piece deleted on start coords. Uniquely animate arrow on floating point end coords.\n * => Recalculate start coords arrow lines.\n * @param start\n * @param end - Floating point coords of the current animation position\n * @param type - The piece type, so we know what type of piece the arrow should be.\n * \t\t\t\tWe CANNOT just read the type of piece at the destination square, because\n * \t\t\t\tthe piece is not guaranteed to be there. In Atomic Chess, the piece can\n * \t\t\t\tmove, and then explode itself, leaving its destination square empty.\n */\nexport function animateArrow(start: Coords, end: BDCoords, type: number): void {\n\tif (!arrows.areArrowsActiveThisFrame()) return;\n\toverwriteArrows(start);\n\tshifts.push({ kind: 'animate', start, end, type });\n}\n\n/**\n * Piece added on end coords.\n * => Arrow lines recalculated\n */\nexport function addArrow(type: number, end: Coords): void {\n\tif (!arrows.areArrowsActiveThisFrame()) return;\n\tshifts.push({ kind: 'add', type, end });\n}\n\n/**\n * Erases existing arrow shifts that should be overwritten by the new arrow.\n * Should only be called when shifting a new arrow.\n */\nfunction overwriteArrows(start: Coords): void {\n\t/**\n\t * For each previous shift, if either their start or end\n\t * is on this start (deletion coords), then delete it!\n\t *\n\t * check to see if the start is the same as this end coords.\n\t * If so, replace that shift with a delete action, and retain the same order.\n\t */\n\tshifts = shifts.filter((shift) => {\n\t\t// All shift kinds with a `start` property\n\t\tif (shift.kind === 'delete' || shift.kind === 'move' || shift.kind === 'animate') {\n\t\t\tif (coordutil.areCoordsEqual(shift.start, start)) return false; // Filter\n\t\t}\n\t\t// All shift kinds with a Coords `end` property.\n\t\tif (shift.kind === 'move' || shift.kind === 'add') {\n\t\t\tif (coordutil.areCoordsEqual(shift.end, start)) return false; // Filter\n\t\t}\n\t\treturn true; // Pass\n\t});\n}\n\n/** Execute any pending arrow shift modifications. */\nexport function executeArrowShifts(): void {\n\tconst slideArrows = arrows.getSlideArrows();\n\tconst animatedArrows = arrows.getAnimatedArrows();\n\tconst mode = arrows.getMode();\n\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst changes: Change[] = [];\n\n\tconst worldHalfWidth = arrowscalculator.getArrowIndicatorHalfWidth();\n\tconst pointerWorlds = mouse.getAllPointerWorlds();\n\tconst slideExceptions = arrowscalculator.getSlideExceptions(mode);\n\n\tshifts.forEach((shift) => {\n\t\tif (shift.kind === 'delete') {\n\t\t\tdeletePiece(shift.start);\n\t\t} else if (shift.kind === 'add') {\n\t\t\taddPiece(shift.type, shift.end); // Add the piece to the gamefile, so that we can calculate the arrow lines correctly\n\t\t} else if (shift.kind === 'move') {\n\t\t\tconst type = deletePiece(shift.start);\n\t\t\tif (type === undefined)\n\t\t\t\tthrow Error(\n\t\t\t\t\t\"Arrow shift: When moving arrow, no piece found at its start coords. Don't know what type of piece to add at the end coords!\",\n\t\t\t\t); // If this ever happens, maybe give movePiece a type argument along just as animateArrow() has.\n\t\t\taddPiece(type, shift.end);\n\t\t} else if (shift.kind === 'animate') {\n\t\t\tdeletePiece(shift.start); // Delete the piece if it is present (may not be if in Atomic Chess it blew itself up)\n\n\t\t\t// This is an arrow animation for a piece IN MOTION, not a still animation.\n\t\t\t// Add an animated arrow for it, since it is gonna be at a floating point coordinate\n\n\t\t\t// Only add the arrow if the piece is JUST off-screen.\n\t\t\t// Add 1 square on each side of the screen box first.\n\t\t\tconst boundingBoxFloat = arrowscalculator.getBoundingBoxFloat()!;\n\t\t\tconst expandedFloatingBox = {\n\t\t\t\tleft: bd.subtract(boundingBoxFloat.left, ONE),\n\t\t\t\tright: bd.add(boundingBoxFloat.right, ONE),\n\t\t\t\tbottom: bd.subtract(boundingBoxFloat.bottom, ONE),\n\t\t\t\ttop: bd.add(boundingBoxFloat.top, ONE),\n\t\t\t};\n\t\t\t// True if its square is at least PARTIALLY visible on screen.\n\t\t\t// We need no arrows for the animated piece, no matter the vector!\n\t\t\tif (bounds.boxContainsSquareBD(expandedFloatingBox, shift.end)) return;\n\n\t\t\tconst piece: ArrowPiece = {\n\t\t\t\ttype: shift.type,\n\t\t\t\tcoords: shift.end,\n\t\t\t\tindex: -1,\n\t\t\t\tfloating: true,\n\t\t\t}; // Create a piece object for the arrow\n\n\t\t\t// Add an arrow for every applicable direction\n\t\t\tfor (const lineKey of gamefile.boardsim.pieces.lines.keys()) {\n\t\t\t\tlet line = vectors.getVec2FromKey(lineKey);\n\n\t\t\t\t// prettier-ignore\n\t\t\t\tif (arrowscalculator.isAnimatedArrowUnnecessary(gamefile.boardsim, piece.type, line, lineKey, mode))\n\t\t\t\t\tcontinue; // Arrow mode isn't high enough, and the piece can't slide in the vector direction\n\n\t\t\t\t// Determine the line's dot product with the screen box.\n\t\t\t\t// Flip the vector if need be, to point it in the right direction.\n\t\t\t\tconst thisPieceIntersections = geometry.findLineBoxIntersectionsBD(piece.coords, line, boundingBoxFloat); // prettier-ignore\n\t\t\t\tif (thisPieceIntersections.length < 2) continue; // Slide direction doesn't intersect with screen box, no arrow needed\n\n\t\t\t\tconst positiveDotProduct = thisPieceIntersections[0]!.positiveDotProduct; // We know the dot product of both intersections will be identical, because the piece is off-screen.\n\t\t\t\t// Negate the vector if it is pointing AWAY from the screen (negative dot product side),\n\t\t\t\t// so that `processPiece` always receives a vector pointing TOWARD the piece.\n\t\t\t\tif (!positiveDotProduct) line = vectors.negateVector(line);\n\t\t\t\t// At what point does it intersect the screen?\n\t\t\t\tconst intersect = positiveDotProduct\n\t\t\t\t\t? thisPieceIntersections[0]!.coords\n\t\t\t\t\t: thisPieceIntersections[1]!.coords;\n\n\t\t\t\tconst arrow: Arrow = arrowscalculator.processPiece(piece, line, intersect, 0, worldHalfWidth, pointerWorlds); // prettier-ignore\n\t\t\t\tanimatedArrows.push(arrow);\n\t\t\t}\n\t\t}\n\t});\n\n\t/** Helper function to delete an arrow's start piece off the board. */\n\tfunction deletePiece(start: Coords): number | undefined {\n\t\t// Delete the piece from the gamefile, so that we can calculate the arrow lines correctly\n\t\tconst originalPiece = boardutil.getPieceFromCoords(gamefile.boardsim.pieces, start);\n\t\tif (originalPiece === undefined) return; // The piece may have been blown up by itself.\n\t\tboardchanges.queueDeletePiece(changes, true, originalPiece);\n\t\treturn originalPiece.type;\n\t}\n\n\t/** Helper function to add an arrow's end piece on the board. */\n\tfunction addPiece(type: number, end: Coords): void {\n\t\t// Add the piece to the gamefile, so that we can calculate the arrow lines correctly\n\t\tconst piece: Piece = { type, coords: end, index: -1 };\n\t\tboardchanges.queueAddPiece(changes, piece);\n\t}\n\n\t// Apply the board changes\n\tboardchanges.runChanges(gamefile, changes, boardchanges.changeFuncs, true);\n\n\t// Recalculate the arrow lines for each shift\n\tshifts.forEach((shift) => {\n\t\tif (shift.kind === 'delete' || shift.kind === 'move' || shift.kind === 'animate') {\n\t\t\t// Recalculate the lines through the start coordinate\n\t\t\trecalculateLinesThroughCoords(slideArrows, gamefile, shift.start, worldHalfWidth, pointerWorlds, slideExceptions); // prettier-ignore\n\t\t}\n\t\tif (shift.kind === 'add' || shift.kind === 'move') {\n\t\t\t// Recalculate the lines through the end coordinate\n\t\t\trecalculateLinesThroughCoords(slideArrows, gamefile, shift.end, worldHalfWidth, pointerWorlds, slideExceptions); // prettier-ignore\n\t\t}\n\t});\n\n\t// Restore the board state\n\tboardchanges.runChanges(gamefile, changes, boardchanges.changeFuncs, false);\n}\n\n/**\n * Recalculates all of the arrow lines the given piece\n * is on, adding them to this frame's list of arrows.\n */\nfunction recalculateLinesThroughCoords(\n\tslideArrows: SlideArrows,\n\tgamefile: FullGame,\n\tcoords: Coords,\n\tworldHalfWidth: number,\n\tpointerWorlds: DoubleCoords[],\n\tslideExceptions: Vec2Key[],\n): void {\n\tfor (const [slideKey, linegroup] of gamefile.boardsim.pieces.lines) {\n\t\t// For each slide direction in the game...\n\t\tconst slide = coordutil.getCoordsFromKey(slideKey);\n\n\t\tconst lineKey = organizedpieces.getKeyFromLine(slide, coords);\n\n\t\t// Delete the original arrow line if it exists\n\t\tif (slideKey in slideArrows) {\n\t\t\tdelete slideArrows[slideKey]![lineKey];\n\t\t\tif (Object.keys(slideArrows[slideKey]!).length === 0) delete slideArrows[slideKey];\n\t\t}\n\n\t\t// Recalculate the arrow line...\n\n\t\t// Fetch the organized line that our piece is on this direction.\n\t\tconst organizedLine = linegroup.get(lineKey);\n\t\tif (organizedLine === undefined) continue; // No pieces on line, empty\n\n\t\tconst arrowsLineDraft = arrowscalculator.calcArrowsLineDraft(gamefile, slide, slideKey, organizedLine); // prettier-ignore\n\t\tif (arrowsLineDraft === undefined) continue; // Only intersects the corner of our screen, not visible.\n\n\t\t// Remove Unnecessary arrows...\n\t\tif (!slideExceptions.includes(slideKey)) {\n\t\t\tarrowscalculator.removeTypesThatCantSlideOntoScreenFromLineDraft(arrowsLineDraft);\n\t\t\tif (arrowsLineDraft.negDotProd.length === 0 && arrowsLineDraft.posDotProd.length === 0)\n\t\t\t\tcontinue; // No more pieces on this line\n\t\t}\n\n\t\tconst { line } = arrowscalculator.convertLineDraftToLine(arrowsLineDraft, slide, slideKey, worldHalfWidth, pointerWorlds, false); // prettier-ignore\n\t\tslideArrows[slideKey] = slideArrows[slideKey] ?? {}; // Make sure this exists first.\n\t\tslideArrows[slideKey][lineKey] = line;\n\t}\n}\n\n// Exports -----------------------------------------------------------------------------\n\nexport default {\n\t// State management\n\treset,\n\t// Queuing modifications\n\tdeleteArrow,\n\tmoveArrow,\n\tanimateArrow,\n\taddArrow,\n\t// Executing modifications\n\texecuteArrowShifts,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/boarddrag.ts",
    "content": "// src/client/scripts/esm/game/rendering/boarddrag.ts\n\n/**\n * This script handles the dragging of the board,\n * and throwing it after letting go.\n */\n\nimport type { BDCoords, DoubleCoords } from '../../../../../shared/chess/util/coordutil.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport vectors from '../../../../../shared/util/math/vectors.js';\nimport bdcoords from '../../../../../shared/chess/util/bdcoords.js';\nimport coordutil from '../../../../../shared/chess/util/coordutil.js';\n\nimport mouse from '../../util/mouse.js';\nimport boardpos from './boardpos.js';\nimport drawrays from './highlights/annotations/drawrays.js';\nimport keybinds from '../misc/keybinds.js';\nimport selection from '../chess/selection.js';\nimport Transition from './transitions/Transition.js';\nimport drawarrows from './highlights/annotations/drawarrows.js';\nimport perspective from './perspective.js';\nimport etoolmanager from '../boardeditor/tools/etoolmanager.js';\nimport guipromotion from '../gui/guipromotion.js';\nimport { listener_overlay } from '../chess/game.js';\n\n// Types -------------------------------------------------------------\n\n/**\n * A board position/scale entry, used for calculating its velocity\n * for throwing the board after dragging it.\n */\ninterface PositionHistoryEntry {\n\ttime: number;\n\tboardPos: BDCoords;\n\tboardScale: BigDecimal;\n}\n\n// Variables -------------------------------------------------------------\n\n/** Whether we currently dragging the board */\nlet boardIsGrabbed: boolean = false;\n\n/** Equal to the board scale the moment a 2nd finger touched down. (pinching the board) */\nlet scale_WhenBoardPinched: BigDecimal | undefined;\n/** Equal to the distance between 2 fingers the moment they touched down. (pinching the board) */\nlet fingerPixelDist_WhenBoardPinched: number | undefined;\n\n/** The ID of the first pointer that grabbed the board */\nlet pointer1Id: string | undefined;\n/** The ID of the second pointer that grabbed the board */\nlet pointer2Id: string | undefined;\n\n/** What coordinates 1 finger has grabbed the board, if it has. */\nlet pointer1BoardPosGrabbed: BDCoords | undefined;\n/** What coordinates a 2nd finger has grabbed the board, if it has. */\nlet pointer2BoardPosGrabbed: BDCoords | undefined;\n\n/** Stores past board positions from the last few frames. Used to calculate throw velocity after dragging. */\nconst positionHistory: PositionHistoryEntry[] = [];\nconst positionHistoryWindowMillis: number = 80; // The amount of milliseconds to look back into for board velocity calculation.\n\n// Functions -------------------------------------------------------------------\n\n/** Whether the board is currently being dragged by one or more pointers. */\nfunction isBoardDragging(): boolean {\n\treturn boardIsGrabbed;\n}\n\n/**\n * Returns the ids of all pointers that started pressing down this frame\n * that are capable of dragging the board. That is:\n * A. Left mouse button pointers\n * B. Touch pointers\n */\nfunction getBoardDraggablePointersDown(): string[] {\n\tconst mouseKeybind = keybinds.getBoardDragMouseButton();\n\tif (mouseKeybind === undefined) return [];\n\t// Prevent duplicates by using a Set\n\treturn [\n\t\t...new Set([\n\t\t\t...listener_overlay.getPointersDown(mouseKeybind),\n\t\t\t...listener_overlay.getTouchPointersDown(),\n\t\t]),\n\t];\n}\n\n/**\n * Returns the ids of all existing pointers that are capable of dragging the board. That is:\n * A. Left mouse button pointers\n * B. Touch pointers\n */\nfunction getBoardDraggablePointers(): string[] {\n\tconst mouseKeybind = keybinds.getBoardDragMouseButton();\n\tif (mouseKeybind === undefined) return [];\n\t// Prevent duplicates by using a Set\n\treturn [\n\t\t...new Set([\n\t\t\t...listener_overlay.getAllPointers(mouseKeybind),\n\t\t\t...listener_overlay.getAllTouchPointers(),\n\t\t]),\n\t];\n}\n\n/**\n * Checks if the board needs to PINCHED (DOUBLE-GRABBED) by any new pointers presssed down this frame.\n * Will NOT initiate a single-pointer grab!\n */\nfunction checkIfBoardPinched(): void {\n\tif (perspective.getEnabled() || Transition.areTransitioning() || guipromotion.isUIOpen())\n\t\treturn;\n\n\tif (pointer2Id !== undefined) return; // Already pinched\n\n\t// All existing pointers that are either left mouse button, or a touch. May have been previously claimed (steal).\n\tconst allExistingPointers = getBoardDraggablePointers();\n\n\t// This allows us to still start a pinch if we:\n\t// A. Are dragging a piece or drawing an annote with one finger.\n\t// B. Then put down a second finger to pinch the board.\n\t// Desired behavior: Terminate the drag/annote, and start pinching the board.\n\tif (!boardIsGrabbed && allExistingPointers.length < 2) return; // Not grabbed, and not enough pointers to pinch.\n\n\t// We know we have enough pointers to pinch the board!\n\t// If one of the pointers happens to already be in use, steal it!\n\n\t// All pointers pressed down this frame that are either left mouse button, or a touch\n\tconst allPointersDown = getBoardDraggablePointersDown();\n\n\t// For every existing pointer... (STEAL)\n\tfor (const pointerId of allExistingPointers) {\n\t\tif (allPointersDown.includes(pointerId)) continue; // This pointer will be handled in the next loop, where it will also be claimed.\n\t\t// This pointer may have been claimed elsewhere, STEAL it.\n\t\tif (!boardIsGrabbed) {\n\t\t\tinitSinglePointerDrag(pointerId);\n\t\t\tstealPointer(pointerId);\n\t\t}\n\t}\n\t// For every new pointer touched down / created this frame...\n\tfor (const pointerId of allPointersDown) {\n\t\tif (!boardIsGrabbed) {\n\t\t\tlistener_overlay.claimPointerDown(pointerId); // Remove the pointer down so other scripts don't use it\n\t\t\tinitSinglePointerDrag(pointerId);\n\t\t} else if (pointer2Id === undefined) {\n\t\t\tlistener_overlay.claimPointerDown(pointerId); // Remove the pointer down so other scripts don't use it\n\t\t\tinitDoublePointerDrag(pointerId);\n\t\t}\n\t}\n}\n\n/** Checks if the board needs to SINGLE-GRABBED by any new pointers pressed down this frame. */\nfunction checkIfBoardSingleGrabbed(): void {\n\tif (perspective.getEnabled() || Transition.areTransitioning() || guipromotion.isUIOpen())\n\t\treturn;\n\n\tif (boardIsGrabbed) return; // Already grabbed\n\n\t// All pointers down that are either left mouse button, or a touch\n\tconst allPointersDown = getBoardDraggablePointersDown();\n\tif (allPointersDown.length === 0) return; // No pointers down\n\n\tlistener_overlay.claimPointerDown(allPointersDown[0]!); // Remove the pointer down so other scripts don't use it\n\tinitSinglePointerDrag(allPointersDown[0]!); // If multiple pointers down, just use the first one.\n}\n\n/**\n * If the given pointer has been claimed by something else (piece dragging, arrow/ray drawing, etc),\n * this will STEAL it from them, so that it can be used for board pinching, which takes priority.\n * Essentially this just tells them to stop using it.\n */\nfunction stealPointer(pointerId: string): void {\n\tselection.stealPointer(pointerId);\n\tdrawarrows.stealPointer(pointerId);\n\tdrawrays.stealPointer(pointerId);\n\tetoolmanager.stealPointer(pointerId);\n}\n\n/** Grabs board with the given pointer. */\nfunction initSinglePointerDrag(pointerId: string): void {\n\t// console.log('Board grabbed with pointer', pointerId);\n\n\tpointer1Id = pointerId;\n\tpointer1BoardPosGrabbed = mouse.getTilePointerOver_Float(pointer1Id!)!;\n\t// console.log('pointer1BoardPosGrabbed', pointer1BoardPosGrabbed);\n\tboardIsGrabbed = true;\n\tboardpos.setPanVel([0, 0]); // Erase all momentum\n\n\taddCurrentPositionToHistory();\n}\n\n/** Pinches board with given 2nd pointer. */\nfunction initDoublePointerDrag(pointerId: string): void {\n\t// Cannot pinch with the same pointer.\n\t// This can happen in board editor if you drag board with right mouse,\n\t// drag offscreen, let go, then right click to drag board again.\n\tif (pointer1Id === pointerId) return;\n\n\t// Pixel distance\n\tconst p1Pos = listener_overlay.getPointerPos(pointer1Id!)!;\n\tconst p2Pos = listener_overlay.getPointerPos(pointerId)!;\n\tconst dist = vectors.euclideanDistanceDoubles(p1Pos, p2Pos);\n\tif (dist === 0) {\n\t\t// Error gracefully. Allows a rare bug where some users mouse\n\t\t// makes two identical pointer down events in the same frame.\n\t\tconsole.error('Finger pixel dist is 0. Skipping pinch.');\n\t\treturn;\n\t}\n\n\t// console.log('Board pinched with pointer', pointerId);\n\n\tpointer2Id = pointerId;\n\tpointer2BoardPosGrabbed = mouse.getTilePointerOver_Float(pointer2Id!)!;\n\n\tfingerPixelDist_WhenBoardPinched = dist;\n\n\t// Scale\n\tscale_WhenBoardPinched = boardpos.getBoardScale();\n}\n\n/**\n * Checks if any of the pointers that are currenlty dragging the board\n * have been released, or no longer exist. If so, throw the board and cancel the drag.\n */\nfunction checkIfBoardDropped(): void {\n\tif (!boardIsGrabbed) return; // Not grabbed\n\n\tconst now = Date.now();\n\n\t// All existing pointers that are either left mouse button, or a touch\n\tconst allPointers = getBoardDraggablePointers();\n\n\tconst pointer1Released = !allPointers.includes(pointer1Id!);\n\n\tif (pointer2Id === undefined) {\n\t\t// 1 finger drag\n\t\tif (pointer1Released) {\n\t\t\t// Finger has been released\n\t\t\tthrowBoard(now);\n\t\t\tcancelBoardDrag();\n\t\t} // else still one finger holding the board\n\t} else {\n\t\t// 2 finger drag\n\t\tconst pointer2Released = !allPointers.includes(pointer2Id);\n\n\t\tif (!pointer1Released && !pointer2Released) return; // Both fingers are still holding the board\n\n\t\tthrowScale(now);\n\n\t\tif (pointer1Released && pointer2Released) {\n\t\t\t// Both fingers have been released\n\t\t\tthrowBoard(now);\n\t\t\tcancelBoardDrag();\n\t\t} else {\n\t\t\t// Only one finger has been released\n\t\t\tif (pointer2Released) {\n\t\t\t\t// Only Pointer 2 released\n\t\t\t\tpointer2Id = undefined;\n\t\t\t\tpointer2BoardPosGrabbed = undefined;\n\t\t\t\t// Recalculate pointer 1's grab position\n\t\t\t\tpointer1BoardPosGrabbed = mouse.getTilePointerOver_Float(pointer1Id!)!;\n\t\t\t} else if (pointer1Released) {\n\t\t\t\t// Only Pointer 1 released\n\t\t\t\t// Make pointer2 pointer1\n\t\t\t\tpointer1Id = pointer2Id;\n\t\t\t\t// Recalculate pointer 2's grab position\n\t\t\t\tpointer1BoardPosGrabbed = mouse.getTilePointerOver_Float(pointer1Id!)!;\n\t\t\t\tpointer2Id = undefined;\n\t\t\t\tpointer2BoardPosGrabbed = undefined;\n\t\t\t} else throw Error('Umm how did we get here?');\n\n\t\t\tscale_WhenBoardPinched = undefined;\n\t\t\tfingerPixelDist_WhenBoardPinched = undefined;\n\t\t}\n\t}\n}\n\n/** Forcefully terminates a board drag WITHOUT throwing the board. */\nfunction cancelBoardDrag(): void {\n\tboardIsGrabbed = false;\n\tpointer1Id = undefined;\n\tpointer2Id = undefined;\n\tpointer1BoardPosGrabbed = undefined;\n\tpointer2BoardPosGrabbed = undefined;\n\tscale_WhenBoardPinched = undefined;\n\tfingerPixelDist_WhenBoardPinched = undefined;\n\t/** Clears the list of past positions. Call this to prevent teleportation giving momentum.*/\n\tpositionHistory.length = 0;\n}\n\n/** Called after letting go of the board. Applies velocity to the board according to how fast the mouse was moving */\nfunction throwBoard(time: number): void {\n\tremoveOldPositions(time);\n\tif (positionHistory.length < 2) return;\n\tconst firstBoardState = positionHistory[0]!;\n\tconst lastBoardState = positionHistory[positionHistory.length - 1]!;\n\tconst deltaX = bd.subtract(lastBoardState.boardPos[0], firstBoardState.boardPos[0]);\n\tconst deltaY = bd.subtract(lastBoardState.boardPos[1], firstBoardState.boardPos[1]);\n\tconst deltaT = bd.fromNumber((lastBoardState.time - firstBoardState.time) / 1000);\n\tif (bd.isZero(deltaT)) return; // Prevent division by zero\n\tconst boardScale = lastBoardState.boardScale;\n\tconst newPanVel: DoubleCoords = [\n\t\tbd.toNumber(bd.multiply(bd.divide(deltaX, deltaT), boardScale)),\n\t\tbd.toNumber(bd.multiply(bd.divide(deltaY, deltaT), boardScale)),\n\t];\n\t// console.log('Throwing board with velocity', newPanVel);\n\tboardpos.setPanVel(newPanVel);\n}\n\n/**\n * Called after letting go of the board with a second finger. Applies scale\n * velocity to the board according to how fast the fingers were pinching\n */\nfunction throwScale(time: number): void {\n\tremoveOldPositions(time);\n\tif (positionHistory.length < 2) return;\n\tconst firstBoardState = positionHistory[0]!;\n\tconst lastBoardState = positionHistory[positionHistory.length - 1]!;\n\tconst ratio = bd.toNumber(\n\t\tbd.divideFloating(lastBoardState.boardScale, firstBoardState.boardScale),\n\t);\n\tconst deltaTime = (lastBoardState.time - firstBoardState.time) / 1000;\n\tif (deltaTime === 0) return; // Prevent division by zero\n\tboardpos.setScaleVel((ratio - 1) / deltaTime);\n}\n\n/** Called if the board is being dragged, calculates the new board position. */\nfunction dragBoard(): void {\n\tif (!boardIsGrabbed) return;\n\n\t// Calculate new board position...\n\n\tif (pointer2Id === undefined) {\n\t\t// 1 Finger drag\n\n\t\tconst mouseWorld = bdcoords.FromDoubleCoords(mouse.getPointerWorld(pointer1Id!)!);\n\t\t// console.log('Mouse world', mousePos);\n\n\t\t/**\n\t\t * worldCoordsX / boardScale + boardPosX = mouseCoordsX\n\t\t * worldCoordsY / boardScale + boardPosY = mouseCoordsY\n\t\t *\n\t\t * Solve for boardPosX & boardPosY:\n\t\t *\n\t\t * boardPosX = mouseCoordsX - worldCoordsX / boardScale\n\t\t * boardPosY = mouseCoordsY - worldCoordsY / boardScale\n\t\t */\n\n\t\tconst boardScale = boardpos.getBoardScale();\n\t\tconst newBoardPos: BDCoords = [\n\t\t\t// negate and add pointer1BoardPosGrabbed instead of flipped, because we don't need high precision here.\n\t\t\tbd.add(bd.negate(bd.divide(mouseWorld[0], boardScale)), pointer1BoardPosGrabbed![0]),\n\t\t\tbd.add(bd.negate(bd.divide(mouseWorld[1], boardScale)), pointer1BoardPosGrabbed![1]),\n\t\t];\n\t\tboardpos.setBoardPos(newBoardPos);\n\t} else {\n\t\t// 2 Fingers grab/pinch   (center the board position, & calculate scale)\n\n\t\tconst pointer1Pos = listener_overlay.getPointerPos(pointer1Id!)!;\n\t\tconst pointer2Pos = listener_overlay.getPointerPos(pointer2Id!)!;\n\t\tconst pointer1World = mouse.convertMousePositionToWorldSpace(\n\t\t\tpointer1Pos,\n\t\t\tlistener_overlay.element,\n\t\t);\n\t\tconst pointer2World = mouse.convertMousePositionToWorldSpace(\n\t\t\tpointer2Pos,\n\t\t\tlistener_overlay.element,\n\t\t);\n\n\t\t// Calculate the new scale by comparing the touches current distance in pixels to their distance when they first started pinching\n\t\tconst thisPixelDist = vectors.euclideanDistanceDoubles(pointer1Pos, pointer2Pos);\n\t\tconst ratio = bd.fromNumber(thisPixelDist / fingerPixelDist_WhenBoardPinched!);\n\n\t\tconst newScale = bd.multiplyFloating(scale_WhenBoardPinched!, ratio);\n\t\tboardpos.setBoardScale(newScale);\n\n\t\t/**\n\t\t * For calculating the new board position, treat the two fingers\n\t\t * as one finger dragging from the midpoint between them.\n\t\t */\n\n\t\tconst midCoords: BDCoords = coordutil.lerpCoords(\n\t\t\tpointer1BoardPosGrabbed!,\n\t\t\tpointer2BoardPosGrabbed!,\n\t\t\t0.5,\n\t\t);\n\n\t\tconst midPosWorld: BDCoords = bdcoords.FromDoubleCoords(\n\t\t\tcoordutil.lerpCoordsDouble(pointer1World, pointer2World, 0.5),\n\t\t);\n\n\t\tconst newBoardPos: BDCoords = [\n\t\t\t// negate and add midCoords instead of flipped, because we don't need high precision here.\n\t\t\tbd.add(bd.negate(bd.divide(midPosWorld[0], newScale)), midCoords[0]),\n\t\t\tbd.add(bd.negate(bd.divide(midPosWorld[1], newScale)), midCoords[1]),\n\t\t];\n\n\t\tboardpos.setBoardPos(newBoardPos);\n\t}\n\n\taddCurrentPositionToHistory();\n}\n\n/**\n * Adds the board's current position and scale to its history.\n * Used for calculating the velocity of the board after letting go.\n *\n * History is only kept track of while dragging.\n */\nfunction addCurrentPositionToHistory(): void {\n\tconst now = Date.now();\n\tremoveOldPositions(now);\n\tpositionHistory.push({\n\t\ttime: now,\n\t\tboardPos: boardpos.getBoardPos(),\n\t\tboardScale: boardpos.getBoardScale(),\n\t});\n}\n\n/**\n * Removes all positions from the history that are older than the\n * positionHistoryWindowMillis.\n */\nfunction removeOldPositions(now: number): void {\n\tconst earliestTime = now - positionHistoryWindowMillis;\n\twhile (positionHistory.length > 0 && positionHistory[0]!.time < earliestTime)\n\t\tpositionHistory.shift();\n}\n\n// Exports ------------------------------------------------------------\n\nexport default {\n\tisBoardDragging,\n\tcheckIfBoardPinched,\n\tcheckIfBoardSingleGrabbed,\n\tdragBoard,\n\tcheckIfBoardDropped,\n\tcancelBoardDrag,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/boardpos.ts",
    "content": "// src/client/scripts/esm/game/rendering/boardpos.ts\n\n/**\n * This script stores the board position and scale,\n * and updates them according to their velocity.\n */\n\nimport type { BDCoords, DoubleCoords } from '../../../../../shared/chess/util/coordutil.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport jsutil from '../../../../../shared/util/jsutil.js';\nimport bdcoords from '../../../../../shared/chess/util/bdcoords.js';\nimport coordutil from '../../../../../shared/chess/util/coordutil.js';\n\nimport camera from './camera.js';\nimport guipause from '../gui/guipause.js';\nimport Transition from './transitions/Transition.js';\nimport perspective from './perspective.js';\nimport loadbalancer from '../misc/loadbalancer.js';\nimport frametracker from './frametracker.js';\n\n// BigDecimal Constants ---------------------------------------------------\n\nconst ZERO = bd.fromBigInt(0n);\nconst ONE = bd.fromBigInt(1n);\n\n// Variables -------------------------------------------------------------\n\n/**\n * The position of the board in front of the camera.\n * The camera never moves, only the board beneath it.\n * A positon of [0,0] places the [0,0] square in the center of the screen.\n */\nlet boardPos: BDCoords = bdcoords.FromCoords([0n, 0n]); // Coordinates\n/** The current board panning velocity. */\nlet panVel: DoubleCoords = [0, 0];\n/**\n * The current board scale (zoom).\n * Higher => zoomed IN\n * Lower => zoomed OUT\n */\nlet boardScale: BigDecimal = bd.fromBigInt(1n); // Default: 1\n/** The current board scale (zoom) velocity. */\nlet scaleVel: number = 0;\n\n/** The hypotenuse of the x & y pan velocities cannot exceed this value in 2D mode. */\nconst panVelCap2D = 22.0; // Default: 22\n/** The hypotenuse of the x & y pan velocities cannot exceed this value in 3D mode. */\nconst panVelCap3D = 16.0; // Default: 16\n\n/** The furthest we can be zoomed IN. */\nconst maximumScale = bd.fromBigInt(5n); // Default: 5.0\nconst limitToDampScale = 0.000_01; // We need to soft limit the scale so the game doesn't break\n\n// Getters -------------------------------------------------------\n\nfunction getBoardPos(): BDCoords {\n\treturn coordutil.copyBDCoords(boardPos);\n}\n\nfunction getBoardScale(): BigDecimal {\n\treturn bd.clone(boardScale);\n}\n\n/**\n * Call when you are CONFIDENT we are zoomed in enough that our scale\n * can be represented as a javascript number without overflowing to\n * Infinity or underflowing to 0.\n *\n * Typically used for graphics calculations, as the arithmetic\n * is faster than using BigDecimals.\n */\nfunction getBoardScaleAsNumber(): number {\n\treturn bd.toNumber(boardScale);\n}\n\nfunction getPanVel(): DoubleCoords {\n\treturn [...panVel]; // Copies\n}\n\nfunction getRelativePanVelCap(): number {\n\treturn perspective.getEnabled() ? panVelCap3D : panVelCap2D;\n}\n\nfunction getScaleVel(): number {\n\treturn scaleVel;\n}\n\nfunction glimitToDampScale(): number {\n\treturn limitToDampScale;\n}\n\n// Setters ----------------------------------------------------------------------------------------\n\nfunction setBoardPos(newPos: BDCoords): void {\n\t// Enforce fixed point model. Catches bugs during development.\n\tif (!bd.hasDefaultPrecision(newPos[0]))\n\t\tthrow Error(\n\t\t\t`Cannot set board position X to [${newPos[0].divex}] ${bd.toApproximateString(newPos[0])}. Does not have default precision.`,\n\t\t);\n\tif (!bd.hasDefaultPrecision(newPos[1]))\n\t\tthrow Error(\n\t\t\t`Cannot set board position Y to [${newPos[1].divex}] ${bd.toApproximateString(newPos[1])}. Does not have default precision.`,\n\t\t);\n\n\t// console.log(`New board position [${(boardPos[0].divex)},${boardPos[1].divex}]`, coordutil.stringifyBDCoords(boardPos));\n\tboardPos = jsutil.deepCopyObject(newPos); // Copy\n\tframetracker.onVisualChange();\n}\n\nfunction setBoardScale(newScale: BigDecimal): void {\n\tif (bd.compare(newScale, ZERO) <= 0)\n\t\treturn console.error(`Cannot set scale to a negative: ${bd.toApproximateString(newScale)}`);\n\t// console.error(\"New scale:\", bd.toApproximateString(newScale));\n\n\t// Cap the scale\n\tif (bd.compare(newScale, maximumScale) > 0) {\n\t\tnewScale = maximumScale;\n\t\tscaleVel = 0; // Cut the scale momentum immediately\n\t}\n\n\tboardScale = newScale;\n\tframetracker.onVisualChange();\n}\n\nfunction setPanVel(newPanVel: DoubleCoords): void {\n\tif (isNaN(newPanVel[0]) || isNaN(newPanVel[1]))\n\t\treturn console.error(`Cannot set panVel to ${newPanVel}!`);\n\n\t// Can't enforce a cap, as otherwise we wouldn't\n\t// be able to throw the board as fast as possible.\n\n\tpanVel = [...newPanVel];\n}\n\nfunction setScaleVel(newScaleVel: number): void {\n\tif (isNaN(newScaleVel)) return console.error(`Cannot set scaleVel to ${newScaleVel}!`);\n\tif (Math.abs(newScaleVel) >= 100) console.warn(`Very large scaleVel: (${newScaleVel})`);\n\n\tscaleVel = newScaleVel;\n}\n\n// Other Utility --------------------------------------------------------\n\n/** Erases all board pan & scale velocity. */\nfunction eraseMomentum(): void {\n\tpanVel = [0, 0];\n\tscaleVel = 0;\n}\n\nfunction boardHasMomentum(): boolean {\n\treturn panVel[0] !== 0 || panVel[1] !== 0;\n}\n\n/**\n * We are considered \"zoomed out\" if every tile is smaller than one virtual pixel.\n * If so, the game has very different behavior, such as:\n * * Legal moves highlights and Ray annotations rendering as highlight lines.\n * * Pieces rendering as mini-images.\n * * Annotations rendered at a fixed size on screen.\n */\nfunction areZoomedOut(): boolean {\n\treturn bd.compare(boardScale, camera.getScaleWhenZoomedOut()) < 0;\n}\n\n/**\n * This is true when your device is physically incapable\n * of reprenting single tiles with a single of your monitor's pixels.\n * On retina displays you have to zoom out even more to reach this.\n */\nfunction isScaleSmallForInvisibleTiles(): boolean {\n\treturn bd.compare(boardScale, camera.getScaleWhenTilesInvisible()) < 0;\n}\n\n// Updating -------------------------------------------------------------------\n\n// Called from game.updateBoard()\nfunction update(): void {\n\tif (guipause.areWePaused()) return; // Exit if paused\n\tif (Transition.areTransitioning()) return; // Exit if we are teleporting\n\tif (loadbalancer.areWeAFK()) return; // Exit if we're AFK. Save our CPU!\n\n\tpanBoard();\n\trecalcScale();\n}\n\n/** Shifts the board position by its velocity. */\nfunction panBoard(): void {\n\tif (panVel[0] === 0 && panVel[1] === 0) return; // Exit if we're not moving\n\n\tconst panVelBD: BDCoords = bdcoords.FromDoubleCoords(panVel);\n\n\t// What the change would be if all frames were the exact same time length.\n\tconst baseXChange = bd.divide(panVelBD[0], boardScale);\n\tconst baseYChange = bd.divide(panVelBD[1], boardScale);\n\n\t// Account for delta time\n\tconst deltaTimeBD: BigDecimal = bd.fromNumber(loadbalancer.getDeltaTime());\n\tconst actualXChange = bd.multiply(baseXChange, deltaTimeBD);\n\tconst actualYChange = bd.multiply(baseYChange, deltaTimeBD);\n\n\tconst newPos: BDCoords = [\n\t\tbd.add(boardPos[0], actualXChange),\n\t\tbd.add(boardPos[1], actualYChange),\n\t];\n\tsetBoardPos(newPos);\n}\n\n/** Shifts the board scale by its scale velocity. */\nfunction recalcScale(): void {\n\tif (scaleVel === 0) return; // Exit if we're not zooming\n\n\tconst scaleVelBD: BigDecimal = bd.fromNumber(scaleVel);\n\tconst deltaTimeBD: BigDecimal = bd.fromNumber(loadbalancer.getDeltaTime());\n\n\tlet product = bd.multiply(scaleVelBD, deltaTimeBD); // scaleVel * deltaTime\n\tproduct = bd.clamp(product, bd.fromNumber(-0.5), bd.fromNumber(0.5)); // Prevent extreme zoom changes from low lps\n\tconst factor2 = bd.add(product, ONE); // scaleVel * deltaTime + 1\n\n\tconst newScale = bd.multiplyFloating(boardScale, factor2); // boardScale * (scaleVel * deltaTime + 1)\n\tsetBoardScale(newScale);\n}\n\n// Exports -------------------------------------------------------------------\n\nexport default {\n\t// Getters\n\tgetBoardPos,\n\tgetBoardScale,\n\tgetBoardScaleAsNumber,\n\tgetPanVel,\n\tgetRelativePanVelCap,\n\tgetScaleVel,\n\tglimitToDampScale,\n\t// Setters\n\tsetBoardPos,\n\tsetBoardScale,\n\tsetPanVel,\n\tsetScaleVel,\n\t// Other Utility\n\teraseMomentum,\n\tboardHasMomentum,\n\tareZoomedOut,\n\tisScaleSmallForInvisibleTiles,\n\t// Updating\n\tupdate,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/boardtiles.ts",
    "content": "// src/client/scripts/esm/game/rendering/boardtiles.ts\n\n/**\n * This script renders the board, and changes it's color.\n * We also keep track of what tile the mouse is currently hovering over.\n */\n\nimport type { Color } from '../../../../../shared/util/math/math.js';\nimport type { BDCoords, DoubleCoords } from '../../../../../shared/chess/util/coordutil.js';\nimport type { BoundingBox, BoundingBoxBD } from '../../../../../shared/util/math/bounds.js';\nimport type { AttributeInfo, Renderable, TextureInfo } from '../../webgl/Renderable.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport math from '../../../../../shared/util/math/math.js';\nimport jsutil from '../../../../../shared/util/jsutil.js';\nimport gamefileutility from '../../../../../shared/chess/util/gamefileutility.js';\n\nimport style from '../gui/style.js';\nimport camera from './camera.js';\nimport gameslot from '../chess/gameslot.js';\nimport boardpos from './boardpos.js';\nimport imagecache from '../../chess/rendering/imagecache.js';\nimport primitives from './primitives.js';\nimport preferences from '../../components/header/preferences.js';\nimport piecemodels from './piecemodels.js';\nimport perspective from './perspective.js';\nimport { GameBus } from '../GameBus.js';\nimport frametracker from './frametracker.js';\nimport guipromotion from '../gui/guipromotion.js';\nimport texturecache from '../../chess/rendering/texturecache.js';\nimport TextureLoader from '../../webgl/TextureLoader.js';\nimport webgl, { gl } from './webgl.js';\nimport checkerboardgenerator from '../../chess/rendering/checkerboardgenerator.js';\nimport { createRenderable, createRenderable_GivenInfo } from '../../webgl/Renderable.js';\n\n// Types ---------------------------------------------------------------------------\n\n/**\n * Optional noise textures to bind during rendering,\n * for the uber shader to apply board Zone effects.\n */\ntype NoiseTextures = { perlinNoise?: WebGLTexture; whiteNoise?: WebGLTexture };\n\n// Constants ---------------------------------------------------------------------------\n\n/** Without this, the center of tiles would be their bottom-left corner. Range: 0-1 */\nconst squareCenter: number = 0.5;\n\n/** Z level for perspective mode rendering of the board tiles. */\nconst perspectiveMode_z = -0.01;\n\n// BigDecimal constants\nconst ONE = bd.fromNumber(1.0);\nconst TWO = bd.fromNumber(2.0);\nconst TEN = bd.fromNumber(10);\n\n// Variables ---------------------------------------------------------------------------\n\n/** 2x2 Opaque, no mipmaps. Used in perspective mode. Medium moire, medium blur, no antialiasing. */\nlet tilesTexture_2: WebGLTexture | undefined; // Opaque, no mipmaps\n/** 256x256 Opaque, yes mipmaps. Used in 2D mode. Zero moire, yes antialiasing. */\nlet tilesTexture_256mips: WebGLTexture | undefined;\n\n/**\n * A mask texture for the tiles, used to apply Zone effects to selective light/dark tiles.\n * White pixels represent light tile pixels, black pixels represent dark tile pixels.\n * Independent of theme.\n */\nlet tilesMask: WebGLTexture | undefined;\n\n/**\n * The *exact* bounding box of the board currently visible on the canvas.\n * This differs from the camera's bounding box because this is effected by the camera's scale (zoom).\n */\nlet boundingBoxFloat: BoundingBoxBD;\n/**\n * The bounding box of the board currently visible on the canvas,\n * rounded away from the center of the canvas to encapsulate the whole of any partially visible squares.\n * This differs from the camera's bounding box because this is effected by the camera's scale (zoom).\n * CONTAINS INTEGER SQUARE VALUES. No floating points!\n */\nlet boundingBox: BoundingBox;\n/**\n * The bounding box of the board currently visible on the canvas when the CAMERA IS IN DEBUG MODE,\n * rounded away from the center of the canvas to encapsulate the whole of any partially visible squares.\n * This differs from the camera's bounding box because this is effected by the camera's scale (zoom).\n */\nlet boundingBox_debugMode: BoundingBox;\n\n/** Color [r,g,b,a] of the light tiles. */\nlet lightTiles: Color;\n/** Color [r,g,b,a] of the dark tiles. */\nlet darkTiles: Color;\n\n// Initialization --------------------------------------------------------------------------------\n\n// Add event listener for theme changes\ndocument.addEventListener('theme-change', (_event) => {\n\t// Custom Event listener.\n\tconsole.log(`Theme change event detected: ${preferences.getTheme()}`);\n\tupdateTheme();\n\tconst gamefile = gameslot.getGamefile();\n\tif (!gamefile) return;\n\timagecache.deleteImageCache();\n\t// texturecache.deleteTextureCache(gl);\n\timagecache.initImagesForGame(gamefile.boardsim).then(() => {\n\t\t// Regenerate piece textures with the new tinted images\n\t\ttexturecache.initTexturesForGame(gl, gamefile.boardsim);\n\t\tpiecemodels.regenAll(gamefile.boardsim, gameslot.getMesh());\n\t});\n\t// Reinit the promotion UI\n\tguipromotion.resetUI();\n\tguipromotion.initUI(gamefile.basegame.gameRules.promotionsAllowed);\n});\n\nGameBus.addEventListener('game-concluded', () => {\n\tdarkenColor();\n});\nGameBus.addEventListener('game-unloaded', () => {\n\t// Resets the board color (the color changes when checkmate happens)\n\tupdateTheme();\n});\n\n/** Loads the tiles texture. */\nfunction init(): void {\n\t// Generate the tiles mask texture\n\t// Using 256x256 instead of 2x2 avoids creating an ring of higher moire around the camera in perspective mode.\n\tcheckerboardgenerator.createCheckerboardIMG('white', 'black', 256).then((tilesMask_IMG) => {\n\t\ttilesMask = TextureLoader.loadTexture(gl, tilesMask_IMG, { mipmaps: false });\n\t});\n\n\t// Initial generation of tile textures\n\tupdateTheme();\n\n\trecalcVariables(); // Variables dependant on the board position & scale\n}\n\nasync function initTextures(): Promise<void> {\n\tconst lightTilesCssColor = style.arrayToCssColor(lightTiles);\n\tconst darkTilesCssColor = style.arrayToCssColor(darkTiles);\n\n\t// Generate both images in parallel\n\tconst [tilesTexture_2_IMG, tilesTexture_256mips_IMG] = await Promise.all([\n\t\tcheckerboardgenerator.createCheckerboardIMG(lightTilesCssColor, darkTilesCssColor, 2),\n\t\tcheckerboardgenerator.createCheckerboardIMG(lightTilesCssColor, darkTilesCssColor, 256),\n\t]);\n\n\ttilesTexture_2 = TextureLoader.loadTexture(gl, tilesTexture_2_IMG, { mipmaps: false });\n\ttilesTexture_256mips = TextureLoader.loadTexture(gl, tilesTexture_256mips_IMG, {\n\t\tmipmaps: true,\n\t});\n\n\tframetracker.onVisualChange();\n}\n\n// Updating --------------------------------------------------------------------------------\n\n// Recalculate board velicity, scale, and other common variables.\nfunction recalcVariables(): void {\n\trecalcBoundingBox();\n}\n\nfunction recalcBoundingBox(): void {\n\tboundingBoxFloat = getBoundingBoxOfBoard(\n\t\tboardpos.getBoardPos(),\n\t\tboardpos.getBoardScale(),\n\t\tfalse,\n\t);\n\tboundingBox = roundAwayBoundingBox(boundingBoxFloat);\n\n\tconst boundingBoxFloat_debugMode = getBoundingBoxOfBoard(\n\t\tboardpos.getBoardPos(),\n\t\tboardpos.getBoardScale(),\n\t\ttrue,\n\t);\n\tboundingBox_debugMode = roundAwayBoundingBox(boundingBoxFloat_debugMode);\n}\n\n// Public API ---------------------------------------------------------------------------------\n\nfunction getSquareCenter(): BigDecimal {\n\treturn bd.fromNumber(squareCenter);\n}\n\nfunction getSquareCenterAsNumber(): number {\n\treturn squareCenter;\n}\n\nfunction gtileWidth_Pixels(debugMode = camera.getDebug()): BigDecimal {\n\t// If we're in developer mode, our screenBoundingBox is different\n\tconst screenBoundingBox = camera.getScreenBoundingBox(debugMode);\n\tconst factor1: BigDecimal = bd.fromNumber((camera.canvas.height * 0.5) / screenBoundingBox.top);\n\tconst tileWidthPixels_Physical = bd.multiplyFloating(factor1, boardpos.getBoardScale()); // Greater for retina displays\n\n\tconst divisor = bd.fromNumber(window.devicePixelRatio);\n\tconst tileWidthPixels_Virtual = bd.divideFloating(tileWidthPixels_Physical, divisor);\n\n\treturn tileWidthPixels_Virtual;\n}\n\n/**\n * Returns a copy of the board bounding box, rounded away from the center\n * of the canvas to encapsulate the whole of any partially visible squares.\n * CONTAINS INTEGER SQUARE VALUES. No floating points!\n * @returns The board bounding box\n */\nfunction gboundingBox(debugMode = camera.getDebug()): BoundingBox {\n\treturn debugMode\n\t\t? jsutil.deepCopyObject(boundingBox_debugMode)\n\t\t: jsutil.deepCopyObject(boundingBox);\n}\n\n/**\n * Returns a copy of the *exact* board bounding box.\n * @returns The board bounding box\n */\nfunction gboundingBoxFloat(): BoundingBoxBD {\n\treturn jsutil.deepCopyObject(boundingBoxFloat);\n}\n\n/**\n * Calculates the bounding box of the board visible on screen,\n * when the camera is at the specified position, up to a certain precision level.\n *\n * This is different from the bounding box of the canvas, because\n * this is effected by the camera's scale (zoom) property.\n *\n * Returns in float form. To round away from the origin to encapsulate\n * the whole of all tiles at least partially visible, further use {@link roundAwayBoundingBox}\n * @param [position] The position of the camera.\n * @param [scale] The scale (zoom) of the camera.\n * @param debugMode - Whether developer mode is enabled.\n * @returns The bounding box\n */\nfunction getBoundingBoxOfBoard(\n\tposition: BDCoords = boardpos.getBoardPos(),\n\tscale: BigDecimal = boardpos.getBoardScale(),\n\tdebugMode?: boolean,\n): BoundingBoxBD {\n\tconst screenBoundingBox = camera.getScreenBoundingBox(debugMode);\n\n\tfunction getAxisEdges(position: BigDecimal, screenEnd: number): [BigDecimal, BigDecimal] {\n\t\tconst screenEndBD = bd.fromNumber(screenEnd);\n\t\tconst distToEdgeInSquares: BigDecimal = bd.divideFloating(screenEndBD, scale);\n\t\tconst start = bd.subtract(position, distToEdgeInSquares);\n\t\tconst end = bd.add(position, distToEdgeInSquares);\n\t\treturn [start, end];\n\t}\n\n\tconst [left, right] = getAxisEdges(position[0], screenBoundingBox.right);\n\tconst [bottom, top] = getAxisEdges(position[1], screenBoundingBox.top);\n\n\treturn { left, right, bottom, top };\n}\n\n/**\n * Returns the expected render range bounding box when we're in perspective mode.\n * @param {number} rangeOfView - The distance in tiles (when scale is 1) to render the legal move fields in perspective mode.\n * @returns {BoundingBox} The perspective mode render range bounding box\n */\nfunction generatePerspectiveBoundingBox(rangeOfView: number): BoundingBoxBD {\n\t// ~18\n\tconst position = boardpos.getBoardPos();\n\tconst scale = boardpos.getBoardScale();\n\tconst rangeOfViewBD = bd.fromNumber(rangeOfView);\n\tconst renderDistInSquares = bd.divideFloating(rangeOfViewBD, scale);\n\n\treturn {\n\t\tleft: bd.subtract(position[0], renderDistInSquares),\n\t\tright: bd.add(position[0], renderDistInSquares),\n\t\tbottom: bd.subtract(position[1], renderDistInSquares),\n\t\ttop: bd.add(position[1], renderDistInSquares),\n\t};\n}\n\n/**\n * Returns a new board bounding box, with its edges rounded away from the\n * center of the canvas to encapsulate the whole of any squares partially included.\n * STILL IS AN INTEGER BOUNDING BOX,\n * @param src - The source board bounding box\n * @returns The rounded bounding box\n */\nfunction roundAwayBoundingBox(src: BoundingBoxBD): BoundingBox {\n\tconst squareCenter = getSquareCenter();\n\tconst squareCenterMinusOne = bd.subtract(squareCenter, ONE);\n\n\tconst left = bd.toBigInt(bd.floor(bd.add(src.left, squareCenter))); // floor(left + squareCenter)\n\tconst right = bd.toBigInt(bd.ceil(bd.add(src.right, squareCenterMinusOne))); // ceil(right + squareCenter - 1)\n\tconst bottom = bd.toBigInt(bd.floor(bd.add(src.bottom, squareCenter))); // floor(bottom + squareCenter)\n\tconst top = bd.toBigInt(bd.ceil(bd.add(src.top, squareCenterMinusOne))); // ceil(top + squareCenter - 1)\n\n\treturn { left, right, bottom, top };\n}\n\n/** Resets the board color, sky, and navigation bars (the color changes when checkmate happens). */\nfunction updateTheme(): void {\n\tconst gamefile = gameslot.getGamefile();\n\tif (gamefile && gamefileutility.isGameOver(gamefile.basegame))\n\t\tdarkenColor(); // Reset to slightly darkened board\n\telse resetColor(); // Reset to defaults\n\tupdateSkyColor();\n\tupdateNavColor();\n}\n\nfunction resetColor(\n\tnewLightTiles = preferences.getColorOfLightTiles(),\n\tnewDarkTiles = preferences.getColorOfDarkTiles(),\n): void {\n\tlightTiles = newLightTiles; // true for white\n\tdarkTiles = newDarkTiles; // false for dark\n\tinitTextures();\n\tframetracker.onVisualChange();\n}\n\n// Updates sky color based on current board color\nfunction updateSkyColor(): void {\n\tconst avgR = (lightTiles[0] + darkTiles[0]) / 2;\n\tconst avgG = (lightTiles[1] + darkTiles[1]) / 2;\n\tconst avgB = (lightTiles[2] + darkTiles[2]) / 2;\n\n\t// BEFORE STAR FIELD ANIMATION\n\t// const dimAmount = 0.27; // Default: 0.27\n\t// const skyR = avgR - dimAmount;\n\t// const skyG = avgG - dimAmount;\n\t// const skyB = avgB - dimAmount;\n\n\t// AFTER STAR FIELD ANIMATION\n\tconst baseDim = 0.27;\n\tconst multiplierDim = 0.6;\n\tconst skyR = (avgR - baseDim) * multiplierDim;\n\tconst skyG = (avgG - baseDim) * multiplierDim;\n\tconst skyB = (avgB - baseDim) * multiplierDim;\n\n\twebgl.setClearColor([skyR, skyG, skyB]);\n\t// webgl.setClearColor([0,0,0]); // Solid Black\n}\n\nfunction updateNavColor(): void {\n\t// Determine the new \"white\" color\n\n\tconst avgR = (lightTiles[0] + darkTiles[0]) / 2;\n\tconst avgG = (lightTiles[1] + darkTiles[1]) / 2;\n\tconst avgB = (lightTiles[2] + darkTiles[2]) / 2;\n\n\t// With the default theme, these should be max\n\tlet navR = 255;\n\tlet navG = 255;\n\tlet navB = 255;\n\n\tif (preferences.getTheme() !== 'white') {\n\t\tconst brightAmount = 0.6; // 50% closer to white\n\t\tnavR = (1 - (1 - avgR) * (1 - brightAmount)) * 255;\n\t\tnavG = (1 - (1 - avgG) * (1 - brightAmount)) * 255;\n\t\tnavB = (1 - (1 - avgB) * (1 - brightAmount)) * 255;\n\t}\n\n\tstyle.setNavStyle(`\n\n        .navigation {\n            background: linear-gradient(to top, rgba(${navR}, ${navG}, ${navB}, 0.104), rgba(${navR}, ${navG}, ${navB}, 0.552), rgba(${navR}, ${navG}, ${navB}, 0.216));\n        }\n\n        .footer {\n            background: linear-gradient(to bottom, rgba(${navR}, ${navG}, ${navB}, 0.307), rgba(${navR}, ${navG}, ${navB}, 1), rgba(${navR}, ${navG}, ${navB}, 0.84));\n        }\n    `);\n}\n\nfunction darkenColor(): void {\n\tconst defaultLightTiles = preferences.getColorOfLightTiles();\n\tconst defaultDarkTiles = preferences.getColorOfDarkTiles();\n\n\tconst darkenBy = 0.09;\n\tconst darkWR = Math.max(defaultLightTiles[0] - darkenBy, 0);\n\tconst darkWG = Math.max(defaultLightTiles[1] - darkenBy, 0);\n\tconst darkWB = Math.max(defaultLightTiles[2] - darkenBy, 0);\n\tconst darkDR = Math.max(defaultDarkTiles[0] - darkenBy, 0);\n\tconst darkDG = Math.max(defaultDarkTiles[1] - darkenBy, 0);\n\tconst darkDB = Math.max(defaultDarkTiles[2] - darkenBy, 0);\n\n\tresetColor([darkWR, darkWG, darkWB, 1], [darkDR, darkDG, darkDB, 1]);\n}\n\n// Rendering -------------------------------------------------------------------------\n\n// Renders board tiles\nfunction render(noiseTextures?: NoiseTextures, uniforms?: Record<string, any>): void {\n\t// This prevents tearing when rendering in the same z-level and in perspective.\n\twebgl.executeWithDepthFunc_ALWAYS(() => {\n\t\trenderSolidCover(); // This is needed even outside of perspective, so when we zoom out, the rendered fractal transprent boards look correct.\n\t\t// renderMainBoard(noiseTextures, uniforms);\n\t\trenderFractalBoards(noiseTextures, uniforms);\n\t});\n}\n\n// Renders an upside down grey cone centered around the camera, and level with the horizon.\nfunction renderSolidCover(): void {\n\t// const dist = perspective.distToRenderBoard;\n\tconst dist = camera.getZFar() / Math.SQRT2;\n\tconst z = getRelativeZ();\n\tconst cameraZ = camera.getPosition(true)[2];\n\n\tconst r = (lightTiles[0] + darkTiles[0]) / 2;\n\tconst g = (lightTiles[1] + darkTiles[1]) / 2;\n\tconst b = (lightTiles[2] + darkTiles[2]) / 2;\n\tconst a = (lightTiles[3] + darkTiles[3]) / 2;\n\n\tconst data = primitives.BoxTunnel(-dist, -dist, cameraZ, dist, dist, z, r, g, b, a);\n\tdata.push(...primitives.Quad_Color3D(-dist, -dist, dist, dist, z, [r, g, b, a])); // Floor of the box\n\n\tconst model = createRenderable(data, 3, 'TRIANGLES', 'color', true);\n\n\tmodel.render();\n}\n\nfunction renderFractalBoards(noiseTextures?: NoiseTextures, uniforms?: Record<string, any>): void {\n\tconst z = getRelativeZ();\n\n\t// Determine at what \"e\" the main boards tiles are 1 virtual pixel wide.\n\tconst scaleWhen1TileIs1VirtualPixel = camera.getScaleWhenZoomedOut();\n\tconst eWhen1TileIs1VirtualPixel = bd.log10(scaleWhen1TileIs1VirtualPixel);\n\n\tconst currentE = bd.log10(boardpos.getBoardScale());\n\t// console.log(\"currentE:\", currentE);\n\n\t// Board 1 (most zoomed in, always rendered, but may be fading out)\n\tconst board1_E =\n\t\tMath.floor((currentE - eWhen1TileIs1VirtualPixel) / 3) * 3 + eWhen1TileIs1VirtualPixel;\n\t// console.log(\"board1_E:\", board1_E);\n\n\t/**\n\t * How many orders of magnitude of the scale to transition\n\t * board 1's opacity from 1.0 to 0.0. Larger = slower fade.\n\t */\n\tconst E_FADE_DIST = 0.9;\n\tconst board1_Opacity = Math.min(-(board1_E - currentE) / E_FADE_DIST, 1.0);\n\tconst board1_Opacity_Eased = math.easeOut(board1_Opacity);\n\t// console.log(\"nextZoomedInOpacity:\", board1_Opacity_Eased);\n\n\t// Board 2 (more zoomed out, always 1.0 opacity, but ONLY rendered when board 1 is fading out)\n\tconst board2_E = board1_E - 3;\n\t// console.log(\"board2_E:\", board2_E);\n\n\t// ONLY render board2 if the first board has started fading.\n\t// It's always rendered on bottom at 1.0 opacity.\n\tif (board1_Opacity_Eased < 1.0) {\n\t\t// console.log(\"Rendering 2nd board\");\n\t\tconst power = -Math.round(board2_E - eWhen1TileIs1VirtualPixel); // Rounding is ONLY necessary due to correct tiny floating point inaccuracies. This MUST be an integer.\n\t\tconst zoom = bd.pow(TEN, power);\n\t\tgenerateBoardModel(noiseTextures, zoom, 1.0)?.render([0, 0, z], undefined, uniforms);\n\t}\n\n\t// ALWAYS render board 1 (most zoomed in).\n\t// This is rendered on top, and may be fading out.\n\tconst power = -Math.round(board1_E - eWhen1TileIs1VirtualPixel); // Rounding is ONLY necessary due to correct tiny floating point inaccuracies. This MUST be an integer.\n\tconst zoom = bd.pow(TEN, power);\n\tgenerateBoardModel(noiseTextures, zoom, board1_Opacity_Eased)?.render(\n\t\t[0, 0, z],\n\t\tundefined,\n\t\tuniforms,\n\t);\n}\n\n/** Returns what Z level the board tiles should be rendered at this frame. */\nfunction getRelativeZ(): number {\n\treturn perspective.getEnabled() ? perspectiveMode_z : 0;\n}\n\n/**\n * Generates the buffer model of the light tiles.\n * The dark tiles are rendered separately and underneath.\n * @param noise - Noise textures for zone effects, if they are loaded.\n * @param zoom - The zoom level to generate the board model at. Main board: 1.0\n */\nfunction generateBoardModel(\n\t{ perlinNoise, whiteNoise }: NoiseTextures = {},\n\tzoom: BigDecimal,\n\topacity: number = 1.0,\n): Renderable | undefined {\n\tif (!tilesMask) return; // Mask texture not loaded yet\n\n\tconst boardScale = boardpos.getBoardScale();\n\n\t/** Whether this is NOT the main board (zoom level 1.0) */\n\tconst isFractal = !bd.areEqual(zoom, ONE);\n\t// Fractal boards get the texture with no antialiasing, but some moire.\n\tconst boardTexture =\n\t\tisFractal || perspective.getEnabled() ? tilesTexture_2 : tilesTexture_256mips;\n\tif (!boardTexture) return; // Texture not loaded yet\n\n\t/** The scale of the RENDERED board. Final result should always be within a small, visible range. */\n\tconst zoomTimesScale = bd.toNumber(bd.multiplyFloating(boardScale, zoom));\n\tconst zoomTimesScaleTwo = zoomTimesScale * 2;\n\n\tconst { left, right, bottom, top } = camera.getRespectiveScreenBox();\n\n\tconst boardPos = boardpos.getBoardPos();\n\n\t/** Calculates the texture coords for one axis (X/Y) of the tiles model. */\n\tfunction getAxisTexCoords(boardPos: BigDecimal, start: number, end: number): DoubleCoords {\n\t\tconst squareCenter = getSquareCenter();\n\n\t\tconst boardPosAdjusted: BigDecimal = bd.add(boardPos, squareCenter);\n\t\tconst addend1: BigDecimal = bd.divide(boardPosAdjusted, zoom);\n\t\tconst addend2: BigDecimal = bd.fromNumber(start / zoomTimesScale);\n\n\t\tconst sum: BigDecimal = bd.add(addend1, addend2);\n\t\tconst mod2: number = bd.toNumber(bd.mod(sum, TWO));\n\t\tconst texstart: number = mod2 / 2;\n\n\t\tconst diff = end - start;\n\t\tconst texdiff = diff / zoomTimesScaleTwo;\n\t\tconst texend = texstart + texdiff;\n\t\treturn [texstart, texend];\n\t}\n\n\tconst [texstartX, texendX] = getAxisTexCoords(boardPos[0], left, right);\n\tconst [texstartY, texendY] = getAxisTexCoords(boardPos[1], bottom, top);\n\n\t// prettier-ignore\n\tconst data = primitives.Quad_ColorTexture(left, bottom, right, top, texstartX, texstartY, texendX, texendY, 1, 1, 1, opacity);\n\n\tconst attributeInfo: AttributeInfo = [\n\t\t{ name: 'a_position', numComponents: 2 },\n\t\t{ name: 'a_texturecoord', numComponents: 2 },\n\t\t{ name: 'a_color', numComponents: 4 },\n\t];\n\tconst textures: TextureInfo[] = [\n\t\t{ texture: boardTexture, uniformName: 'u_colorTexture' },\n\t\t{ texture: tilesMask, uniformName: 'u_maskTexture' },\n\t];\n\tif (perlinNoise) textures.push({ texture: perlinNoise, uniformName: 'u_perlinNoiseTexture' });\n\tif (whiteNoise) textures.push({ texture: whiteNoise, uniformName: 'u_whiteNoiseTexture' });\n\n\treturn createRenderable_GivenInfo(\n\t\tdata,\n\t\tattributeInfo,\n\t\t'TRIANGLES',\n\t\t'board_uber_shader',\n\t\ttextures,\n\t);\n}\n\n// Exports -------------------------------------------------------------------------\n\nexport default {\n\t// Initialization\n\tinit,\n\t// Updating\n\trecalcVariables,\n\t// Public API\n\tgetSquareCenter,\n\tgetSquareCenterAsNumber,\n\tgtileWidth_Pixels,\n\tgboundingBox,\n\tgboundingBoxFloat,\n\tgetBoundingBoxOfBoard,\n\tgeneratePerspectiveBoundingBox,\n\troundAwayBoundingBox,\n\tresetColor,\n\tdarkenColor,\n\t// Rendering\n\trender,\n\trenderSolidCover,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/border.ts",
    "content": "// src/client/scripts/esm/game/rendering/border.ts\n\n/**\n * This script renders the border, and star field\n * animation of games with a world border.\n */\n\nimport bounds, {\n\tBoundingBox,\n\tDoubleBoundingBox,\n\tUnboundedRectangle,\n} from '../../../../../shared/util/math/bounds.js';\n\nimport meshes from './meshes.js';\nimport camera from './camera.js';\nimport primitives from './primitives.js';\nimport boardtiles from './boardtiles.js';\nimport perspective from './perspective.js';\nimport { createRenderable } from '../../webgl/Renderable.js';\n\n/**\n * Draws a square on screen containing the entire\n * playable area, just inside the world border.\n */\nfunction drawPlayableRegionMask(worldBorder: UnboundedRectangle | undefined): void {\n\t// No border, and in perspective mode => This is the best mask we can get!\n\t// This is crucial for making as if the board goes infinitely into the horizon.\n\t// Otherwise without this the solid cover isn't visible.\n\tif (!worldBorder && perspective.getEnabled()) return boardtiles.renderSolidCover();\n\n\tconst screenBox = camera.getRespectiveScreenBox();\n\n\tlet worldBox: DoubleBoundingBox;\n\tif (worldBorder) {\n\t\t// 0n works because, below, if the sides are at infinity anyway, they get capped to the screen box. The intermediate worldBox makes no difference to the final result for those sides.\n\t\tconst worldBorderNotNull: BoundingBox = {\n\t\t\tleft: worldBorder.left ?? 0n,\n\t\t\tright: worldBorder.right ?? 0n,\n\t\t\tbottom: worldBorder.bottom ?? 0n,\n\t\t\ttop: worldBorder.top ?? 0n,\n\t\t};\n\t\tconst boundingBoxBD =\n\t\t\tmeshes.expandTileBoundingBoxToEncompassWholeSquare(worldBorderNotNull);\n\t\tworldBox = meshes.applyWorldTransformationsToBoundingBox(boundingBoxBD);\n\n\t\t// Cap the world box to the screen box.\n\t\t// Fixes graphical glitches when the vertex data is beyond float32 range.\n\t\t// Null sides of worldBorder represent infinity, so we treat them as ±Infinity\n\t\t// so that clampDoubleBoundingBox clamps those sides to the screen edge.\n\t\tworldBox = bounds.clampDoubleBoundingBox(\n\t\t\t{\n\t\t\t\tleft: worldBorder.left === null ? -Infinity : worldBox.left,\n\t\t\t\tright: worldBorder.right === null ? Infinity : worldBox.right,\n\t\t\t\tbottom: worldBorder.bottom === null ? -Infinity : worldBox.bottom,\n\t\t\t\ttop: worldBorder.top === null ? Infinity : worldBox.top,\n\t\t\t},\n\t\t\tscreenBox,\n\t\t);\n\n\t\tif (bounds.areBoxesDisjoint(worldBox, screenBox)) return; // No need to draw if playable area not on screen\n\t} else {\n\t\t// No world border, just use the screen box\n\t\tworldBox = screenBox;\n\t}\n\n\tconst { left, right, bottom, top } = worldBox;\n\tconst vertexData = primitives.Quad_Color(left, bottom, right, top, [0, 0, 0, 1]); // Color doesn't matter since it's a mask\n\n\tcreateRenderable(vertexData, 2, 'TRIANGLES', 'color', true).render();\n}\n\n// Exports -------------------------------------\n\nexport default {\n\tdrawPlayableRegionMask,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/camera.ts",
    "content": "// src/client/scripts/esm/game/rendering/camera.ts\n\n/**\n * This script handles and stores the matrixes of our shader programs, which\n * store the location of the camera, and contains data about our canvas and window.\n * Note that our camera is going to be at a FIXED location no matter what our board\n * location is or our scale is, the camera remains still while the board moves beneath us.\n *\n * viewMatrix  is the camera location and rotation.\n * projMatrix  needed for perspective mode rendering (is even enabled in 2D view).\n * modelMatrix  is custom for each rendered object, translating it how desired.\n */\n\nimport type { Vec3 } from '../../../../../shared/util/math/vectors.js';\nimport type { DoubleBoundingBox } from '../../../../../shared/util/math/bounds.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport jsutil from '../../../../../shared/util/jsutil.js';\n\nimport mat4 from './gl-matrix.js';\nimport toast from '../gui/toast.js';\nimport stats from '../gui/stats.js';\nimport { gl } from './webgl.js';\nimport perspective from './perspective.js';\nimport preferences from '../../components/header/preferences.js';\nimport guigameinfo from '../gui/guigameinfo.js';\nimport screenshake from './screenshake.js';\nimport guidrawoffer from '../gui/guidrawoffer.js';\nimport frametracker from './frametracker.js';\n\n/** A 4x4 matrix, represented as a 16-element Float32Array */\ntype Mat4 = Float32Array;\n\n/** If true, the camera is stationed farther back. */\nlet DEBUG: boolean = false;\n\n// This will NEVER change! The camera stays while the board position is what moves!\n// What CAN change is the rotation of the view matrix!\nconst position: Vec3 = [0, 0, 12]; // [x, y, z]\nconst position_devMode: Vec3 = [0, 0, 18]; // Default: 18\n\n/** Field of view, in radians */\nlet fieldOfView: number;\n// The closer near & far limits are in terms of orders of magnitude, the more accurate\n// and less often things appear out of order. Should be within 5-6 magnitude orders.\nconst zNear: number = 1;\nconst zFar: number = 1500 * Math.SQRT2; // Default 1500. Has to at least be  perspective.distToRenderBoard * sqrt(2)\n\n/** The canvas document element that WebGL renders the game onto. */\nconst canvas: HTMLCanvasElement = document.getElementById('game') as HTMLCanvasElement;\nlet canvasWidthVirtualPixels: number;\nlet canvasHeightVirtualPixels: number;\nlet aspect: number; // Aspect ratio of the canvas width to height.\n\n/**\n * The location in world-space of the edges of the screen.\n * SMALL NUMBERS. Not affected by position or scale (zoom).\n * So we don't need to use BigDecimals.\n */\nlet screenBoundingBox: DoubleBoundingBox;\n/**\n * The location in world-space of the edges of the screen, when in developer mode.\n * SMALL NUMBERS. Not affected by position or scale (zoom).\n * So we don't need to use BigDecimals.\n */\nlet screenBoundingBox_devMode: DoubleBoundingBox;\n\n/** Contains the matrix for transforming our camera to look like it's in perspective.\n * This ONLY needs to update on the gpu whenever the screen size changes. */\nlet projMatrix: Mat4; // Same for every shader program\n\n/** Contains the camera's position and rotation, updated once per frame on the gpu.\n *\n * When compared to the world matrix, that uniform is updated with every draw call,\n * because it specifies the translation and rotation of the bound mesh. */\nlet viewMatrix: Mat4;\n\n// Returns devMode-sensitive camera position.\nfunction getPosition(ignoreDevmode?: boolean): Vec3 {\n\treturn jsutil.deepCopyObject(!ignoreDevmode && DEBUG ? position_devMode : position);\n}\n\nfunction getZFar(): number {\n\treturn zFar;\n}\n\nfunction getCanvasWidthVirtualPixels(): number {\n\treturn canvasWidthVirtualPixels;\n}\n\nfunction getCanvasHeightVirtualPixels(): number {\n\treturn canvasHeightVirtualPixels;\n}\n\nfunction toggleDebug(): void {\n\tDEBUG = !DEBUG;\n\tframetracker.onVisualChange(); // Visual change, render the screen this frame\n\tonPositionChange();\n\tperspective.initCrosshairModel();\n\ttoast.show(`Toggled camera debug: ${DEBUG}`);\n}\n\nfunction getDebug(): boolean {\n\treturn DEBUG;\n}\n\n/**\n * Returns a copy of the current screen bounding box,\n * or the world-space coordinates of the edges of the canvas.\n * @param [debugMode] Whether developer mode is enabled. If omitted, the current debug status is used.\n * @param [pad] Whether to add a small padding to the box to account for screen shakes.\n * @returns The bounding box of the screen\n */\nfunction getScreenBoundingBox(debugMode: boolean = DEBUG, pad: boolean = false): DoubleBoundingBox {\n\tconst box = jsutil.deepCopyObject(debugMode ? screenBoundingBox_devMode : screenBoundingBox);\n\tif (pad) {\n\t\tconst width = box.right - box.left;\n\t\tconst height = box.top - box.bottom;\n\t\tconst longestSide = Math.max(width, height);\n\t\tlet PAD_AMOUNT = 0.04; // 4% of longest side\n\t\tPAD_AMOUNT = Math.max(PAD_AMOUNT, (PAD_AMOUNT * width) / height); // Increase that if the screen is wider than taller\n\t\t// Add a small padding to the box so that things right at the edge don't get cut off\n\t\tconst paddingAmountX = longestSide * PAD_AMOUNT;\n\t\tconst paddingAmountY = longestSide * PAD_AMOUNT;\n\t\tbox.left -= paddingAmountX;\n\t\tbox.right += paddingAmountX;\n\t\tbox.bottom -= paddingAmountY;\n\t\tbox.top += paddingAmountY;\n\t}\n\treturn box;\n}\n\n/**\n * Returns the respective world-space bounding box containing the whole screen,\n * depending on whether we're in perspective mode or not.\n * Intended for knowing how far out to render items to the edge.\n *\n * In perspective, the range of visibility is much greater.\n *\n * Ignorant of debug mode.\n */\nfunction getRespectiveScreenBox(): DoubleBoundingBox {\n\tif (perspective.getEnabled()) return getPerspectiveScreenBox();\n\telse return getScreenBoundingBox(false, true);\n}\n\n/** Returns the world bounding box of the visible range when in perspective mode. */\nfunction getPerspectiveScreenBox(): DoubleBoundingBox {\n\tconst dist = perspective.distToRenderBoard;\n\treturn { left: -dist, right: dist, bottom: -dist, top: dist };\n}\n\n/**\n * Returns the length from the bottom of the screen to the top, in tiles when at a zoom of 1.\n * This is the same as the height of {@link getScreenBoundingBox}.\n * @param [debugMode] Whether developer mode is enabled. If omitted, the current debug status is used.\n * @returns The height of the screen in squares\n */\nfunction getScreenHeightWorld(debugMode: boolean = DEBUG): number {\n\tconst boundingBox = getScreenBoundingBox(debugMode);\n\treturn boundingBox.top - boundingBox.bottom;\n}\n\n/**\n * Returns a copy of the current view matrix.\n * @returns The view matrix\n */\nfunction getViewMatrix(): Mat4 {\n\treturn jsutil.copyFloat32Array(viewMatrix);\n}\n\n/**\n * Returns a copy of both the projMatrix and viewMatrix\n */\nfunction getProjAndViewMatrixes(): { projMatrix: Mat4; viewMatrix: Mat4 } {\n\treturn {\n\t\tprojMatrix: jsutil.copyFloat32Array(projMatrix),\n\t\tviewMatrix: jsutil.copyFloat32Array(viewMatrix),\n\t};\n}\n\n// Initiates the matrixes (uniforms) of our shader programs: viewMatrix (Camera), projMatrix (Projection), modelMatrix (world translation)\nfunction init(): void {\n\tinitFOV();\n\tinitMatrixes();\n\tdocument.addEventListener('fov-change', () => onFOVChange());\n\twindow.addEventListener('resize', () => onScreenResize());\n}\n\n// Inits the matrix uniforms: viewMatrix (camera) & projMatrix\nfunction initMatrixes(): void {\n\tprojMatrix = mat4.create(); // Same for every shader program\n\n\tupdateCanvasDimensions();\n\tinitPerspective(); // Initiates perspective, including the projection matrix\n\n\tinitViewMatrix(); // Camera\n\n\t// World matrix only needs to be initiated when rendering objects\n}\n\n// Call this when window resized. Also updates the projection matrix.\nfunction initPerspective(): void {\n\tinitProjMatrix();\n}\n\n// Also updates viewport, and updates canvas-dependant variables\nfunction updateCanvasDimensions(): void {\n\t// Get the canvas element's bounding rectangle\n\tconst rect = canvas.getBoundingClientRect();\n\tcanvasWidthVirtualPixels = rect.width;\n\tcanvasHeightVirtualPixels = rect.height;\n\n\t// Size of entire window in physical pixels, not virtual. Retina displays have a greater width.\n\tcanvas.width = canvasWidthVirtualPixels * window.devicePixelRatio;\n\tcanvas.height = canvasHeightVirtualPixels * window.devicePixelRatio;\n\n\tgl.viewport(0, 0, canvas.width, canvas.height);\n\n\trecalcCanvasVariables(); // Recalculate canvas-dependant variables\n\n\t// Dispatch event to notify other application code of the new canvas dimensions\n\tconst detail = { width: canvas.width, height: canvas.height };\n\tdocument.dispatchEvent(new CustomEvent('canvas_resize', { detail }));\n}\n\nfunction recalcCanvasVariables(): void {\n\taspect =\n\t\t(gl.canvas as HTMLCanvasElement).clientWidth /\n\t\t(gl.canvas as HTMLCanvasElement).clientHeight;\n\tinitScreenBoundingBox();\n}\n\n// Set view matrix\nfunction setViewMatrix(newMatrix: Mat4): void {\n\tviewMatrix = newMatrix;\n}\n\n// Initiates the camera matrix. View matrix.\nfunction initViewMatrix(ignoreRotations?: boolean): void {\n\tconst newViewMatrix: Mat4 = mat4.create();\n\n\tconst cameraPos = getPosition(); // devMode-sensitive\n\n\t// Translates the view (camera) matrix to be looking at point..\n\t//             Camera,     Position, Looking-at, Up-direction\n\tmat4.lookAt(newViewMatrix, cameraPos, [0, 0, 0], [0, 1, 0]);\n\n\t// Screen Shake Integration\n\tconst shakeMatrix = screenshake.getShakeMatrix();\n\t// Apply to our view matrix to shake the camera\n\tmat4.multiply(newViewMatrix, newViewMatrix, shakeMatrix);\n\n\tif (!ignoreRotations) perspective.applyRotations(newViewMatrix);\n\n\tviewMatrix = newViewMatrix;\n\n\t// We NO LONGER send the updated matrix to the shaders as a uniform anymore,\n\t// because the combined transformMatrix is recalculated on every draw call.\n}\n\n/** Inits the projection matrix uniform and sends that over to the gpu for each of our shader programs. */\nfunction initProjMatrix(): void {\n\tmat4.perspective(projMatrix, fieldOfView, aspect, zNear, zFar);\n\t// We NO LONGER send the updated matrix to the shaders as a uniform anymore,\n\t// because the combined transformMatrix is recalculated on every draw call.\n\tframetracker.onVisualChange();\n}\n\n// Return the world-space x & y positions of the screen edges. Not affected by scale or board position.\nfunction initScreenBoundingBox(): void {\n\t// Camera dist\n\tlet dist = position[2];\n\t// const dist = 7;\n\tconst thetaY = fieldOfView / 2; // Radians\n\n\t// Length of missing side:\n\t// tan(theta) = x / dist\n\t// x = tan(theta) * dist\n\tlet distToVertEdge = Math.tan(thetaY) * dist;\n\tlet distToHorzEdge = distToVertEdge * aspect;\n\n\tscreenBoundingBox = {\n\t\tleft: -distToHorzEdge,\n\t\tright: distToHorzEdge,\n\t\tbottom: -distToVertEdge,\n\t\ttop: distToVertEdge,\n\t};\n\n\t// Now init the developer-mode screen bounding box\n\n\tdist = position_devMode[2];\n\n\tdistToVertEdge = Math.tan(thetaY) * dist;\n\tdistToHorzEdge = distToVertEdge * aspect;\n\n\tscreenBoundingBox_devMode = {\n\t\tleft: -distToHorzEdge,\n\t\tright: distToHorzEdge,\n\t\tbottom: -distToVertEdge,\n\t\ttop: distToVertEdge,\n\t};\n}\n\nfunction onScreenResize(): void {\n\tupdateCanvasDimensions(); // Also updates viewport\n\tstats.updateStatsCSS();\n\tinitPerspective(); // The projection matrix needs to be recalculated every screen resize\n\tperspective.initCrosshairModel();\n\tframetracker.onVisualChange(); // Visual change. Render the screen this frame.\n\tguidrawoffer.updateVisibilityOfNamesAndClocksWithDrawOffer(); // Hide the names and clocks depending on if the draw offer UI is cramped\n\tguigameinfo.updateAlignmentUsernames();\n\t// console.log('Resized window.')\n}\n\n// Converts to radians\nfunction initFOV(): void {\n\tfieldOfView = (preferences.getPerspectiveFOV() * Math.PI) / 180;\n}\n\nfunction onFOVChange(): void {\n\t// console.log(\"Detected field of view change custom event!\");\n\tinitFOV();\n\tinitProjMatrix();\n\trecalcCanvasVariables(); // The only thing inside here we don't actually need to change is the aspect variable, but it doesn't matter.\n\tperspective.initCrosshairModel();\n}\n\n// Call both when camera moves or rotates\nfunction onPositionChange(): void {\n\tinitViewMatrix();\n}\n\n/**\n * Returns the scale at which 1 physical pixel on the screen equals 1 tile.\n */\nfunction getScaleWhenTilesInvisible(): BigDecimal {\n\t// We can cast this to a BigDecimal last because we know the resulting scale isn't arbitrarily small.\n\treturn bd.fromNumber((screenBoundingBox.right * 2) / canvas.width);\n}\n\n/**\n * Returns the scale at which the game is considered *zoomed out*.\n * Each tile equals 1 virtual pixel on the screen.\n */\nfunction getScaleWhenZoomedOut(): BigDecimal {\n\tconst WDPR_BD = bd.fromNumber(window.devicePixelRatio);\n\treturn bd.multiply(getScaleWhenTilesInvisible(), WDPR_BD);\n}\n\nexport type { Mat4 };\n\nexport default {\n\tgetPosition,\n\tcanvas,\n\tgetCanvasWidthVirtualPixels,\n\tgetCanvasHeightVirtualPixels,\n\ttoggleDebug,\n\tgetDebug,\n\tgetScreenBoundingBox,\n\tgetRespectiveScreenBox,\n\tgetPerspectiveScreenBox,\n\tgetScreenHeightWorld,\n\tgetViewMatrix,\n\tsetViewMatrix,\n\tgetProjAndViewMatrixes,\n\tinit,\n\tonPositionChange,\n\tinitViewMatrix,\n\tgetZFar,\n\tgetScaleWhenTilesInvisible,\n\tgetScaleWhenZoomedOut,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/coordinates.ts",
    "content": "// src/client/scripts/esm/game/rendering/coordinates.ts\n\n/**\n * Board Coordinates\n *\n * Renders coordinate labels (file numbers along the bottom, rank numbers along\n * the left side) in a style similar to classical chess board notation.\n *\n * Labels are fixed-size in screen pixels regardless of zoom level.\n * When zoomed out, labels are skipped to prevent overlap, using the step\n * sequence [1, 2, 5, 10, 20, 50, 100, ...]. The step is determined by the\n * widest file label in the current view (the more restrictive axis), which\n * automatically ensures rank labels also won't overlap.\n */\n\nimport type { Color } from '../../../../../shared/util/math/math.js';\nimport type { DoubleCoords } from '../../../../../shared/chess/util/coordutil.js';\nimport type { DoubleBoundingBox } from '../../../../../shared/util/math/bounds.js';\n\nimport bd, { BigDecimal, toNumber } from '@naviary/bigdecimal';\n\nimport bounds from '../../../../../shared/util/math/bounds.js';\nimport bdcoords from '../../../../../shared/chess/util/bdcoords.js';\n\nimport space from '../misc/space.js';\nimport camera from './camera.js';\nimport arrows from './arrows/arrows.js';\nimport boardpos from './boardpos.js';\nimport boardtiles from './boardtiles.js';\nimport primitives from './primitives.js';\nimport guigameinfo from '../gui/guigameinfo.js';\nimport perspective from './perspective.js';\nimport preferences from '../../components/header/preferences.js';\nimport textrenderer from './text/textrenderer.js';\nimport arrowscalculator from './arrows/arrowscalculator.js';\nimport { createRenderable } from '../../webgl/Renderable.js';\nimport { ATLAS_DESCENDER_FRACTION } from './text/glyphatlas.js';\n\n// Constants -------------------------------------------------------------------------\n\n/** Virtual-pixel height of each coordinate label at full size. Zoom-independent. */\nconst LABEL_SIZE_PX = 24;\n/**\n * Controls how labels shrink on small screens.\n * The smaller canvas dimension (min of width and height) is used as the screen-size metric.\n */\nconst LABEL_SHRINK = {\n\t/**\n\t * Virtual-pixel threshold for the smaller canvas dimension.\n\t * Above this value labels are always {@link LABEL_SIZE_PX} tall; below it they start shrinking.\n\t */\n\tthreshold: 1000,\n\t/**\n\t * How aggressively labels shrink below the threshold.\n\t * At `1.0` labels scale fully to zero as the screen shrinks to zero.\n\t * At `0.5` they only ever shrink to half of {@link LABEL_SIZE_PX} no matter how small the screen gets.\n\t * Valid range: [0, 1].\n\t */\n\trate: 0.6,\n} as const;\n/** Virtual-pixel gap between the screen edge and the near edge of each label. */\nconst LABEL_PADDING_PX = 5;\n/** RGBA color applied to all coordinate labels. */\nconst LABEL_COLOR: Color = [0, 0, 0, 0.65];\n/** Labels with more characters than this threshold switch to the abbreviated \"...XX\" format. */\nconst MAX_FULL_DISPLAY_LENGTH = 7;\n/** Gap between adjacent labels as a multiple of the label height. */\nconst LABEL_GAP_SIZE = 0.4;\n/**\n * Extra padding, in virual-pixels, added to each side\n * of a label's hitbox when testing against arrow indicator hitboxes.\n */\nconst LABEL_ARROW_PADDING_PX = 6;\n\n/** Whether to render a wireframe outline of each label's bounding box for debugging. */\nconst DEBUG_RENDER_LABEL_BOUNDS = false;\n\n// Functions -------------------------------------------------------------------------\n\n/** Returns the label size in virtual pixels for the current frame. */\nfunction calcLabelSizePx(): number {\n\tconst minDim = Math.min(\n\t\tcamera.getCanvasWidthVirtualPixels(),\n\t\tcamera.getCanvasHeightVirtualPixels(),\n\t);\n\tif (minDim >= LABEL_SHRINK.threshold) return LABEL_SIZE_PX;\n\tconst ratio = minDim / LABEL_SHRINK.threshold;\n\treturn LABEL_SIZE_PX * (1 - LABEL_SHRINK.rate * (1 - ratio));\n}\n\n/**\n * Returns the display string for a coordinate label, abbreviating large\n * values to \"...XX\" (or \"-...XX\" for negatives) using the last two digits.\n */\nfunction formatCoord(coord: bigint): string {\n\tconst full = coord.toString();\n\tif (full.length <= MAX_FULL_DISPLAY_LENGTH) return full;\n\tconst prefix = coord < 0n ? '-...' : '...';\n\treturn prefix + full.slice(-2);\n}\n\n/**\n * Returns the smallest value from the sequence [1, 2, 5, 10, 20, 50, 100, 200, 500, ...]\n * such that `step * scale >= threshold`.\n */\nfunction computeStep(threshold: number, scale: BigDecimal): bigint {\n\tconst magnitudes = [1n, 2n, 5n];\n\tlet power = 1n;\n\twhile (true) {\n\t\tfor (const m of magnitudes) {\n\t\t\tconst step = m * power;\n\t\t\t// Multiplies rather than divides so that an arbitrarily small `scale` (BigDecimal)\n\t\t\t// never causes float overflow or a division-by-zero. `toNumber` is safe here because\n\t\t\t// values too large to represent become Infinity (still >= threshold) and values too\n\t\t\t// small become 0 (still < threshold).\n\t\t\tif (toNumber(bd.multiply(bd.fromBigInt(step), scale)) >= threshold) return step;\n\t\t}\n\t\tpower *= 10n;\n\t}\n}\n\n/** Returns the smallest multiple of `multiple` that is >= `n`. */\nfunction ceilToMultiple(n: bigint, multiple: bigint): bigint {\n\tconst mod = ((n % multiple) + multiple) % multiple;\n\treturn mod === 0n ? n : n + multiple - mod;\n}\n\n// API -------------------------------------------------------------------------\n\n/** Renders the file (x-axis) and rank (y-axis) coordinate labels for the current frame. */\nfunction render(): void {\n\tif (!preferences.getCoordinatesEnabled()) return; // Not enabled in the setting dropdown\n\tif (perspective.getEnabled()) return;\n\n\tconst scale = boardpos.getBoardScale();\n\tconst labelSizePx = calcLabelSizePx();\n\tconst sizeWorld = space.convertPixelsToWorldSpace_Virtual(labelSizePx);\n\tconst paddingWorld = space.convertPixelsToWorldSpace_Virtual(LABEL_PADDING_PX);\n\tconst screenBox = camera.getScreenBoundingBox(false);\n\tconst tileBox = boardtiles.gboundingBox(false);\n\t// Shrink the bounding box by 1 on each side to skip cut off edge tiles.\n\ttileBox.left += 1n;\n\ttileBox.right -= 1n;\n\ttileBox.bottom += 1n;\n\ttileBox.top -= 1n;\n\n\tif (tileBox.left > tileBox.right || tileBox.bottom > tileBox.top) return;\n\n\t// The step is driven by the widest visible file label (width-based overlap).\n\t// File labels overlap sooner than rank labels because characters are wider than\n\t// they are tall, so a step sufficient for files is automatically sufficient for ranks.\n\n\t// If both endpoints are abbreviated but the visible range spans the non-abbreviated zone,\n\t// the endpoints would underestimate the widest label. Guard against that by also\n\t// measuring the widest possible non-abbreviated label when the zone is in range.\n\tconst unabbrevMax = 10n ** BigInt(MAX_FULL_DISPLAY_LENGTH) - 1n; // e.g. 9999999n\n\tconst unabbrevMin = -(10n ** BigInt(MAX_FULL_DISPLAY_LENGTH - 1) - 1n); // e.g. -999999n\n\t// Only needed when at least one endpoint is abbreviated (outside the non-abbreviated zone)\n\t// but the range still spans into it, meaning interior labels will be wider than the endpoints.\n\tconst hasUnabbrevInRange =\n\t\t(tileBox.left < unabbrevMin || tileBox.right > unabbrevMax) &&\n\t\ttileBox.left <= unabbrevMax &&\n\t\ttileBox.right >= unabbrevMin;\n\n\tconst widestFileLabelWidth = Math.max(\n\t\ttextrenderer.getTextWidth(formatCoord(tileBox.left), sizeWorld),\n\t\ttextrenderer.getTextWidth(formatCoord(tileBox.right), sizeWorld),\n\t\thasUnabbrevInRange\n\t\t\t? textrenderer.getTextWidth('9'.repeat(MAX_FULL_DISPLAY_LENGTH), sizeWorld)\n\t\t\t: 0,\n\t);\n\tconst threshold = widestFileLabelWidth + sizeWorld * LABEL_GAP_SIZE;\n\tconst stepBig = computeStep(threshold, scale);\n\n\t// Pre-compute arrow indicator hitboxes for this frame to skip overlapping labels.\n\tconst arrowHalfWidth =\n\t\tarrowscalculator.getArrowIndicatorHalfWidth() +\n\t\tspace.convertPixelsToWorldSpace_Virtual(LABEL_ARROW_PADDING_PX);\n\tconst arrowLocations = arrows.getAllArrowWorldLocations();\n\n\tconst isBlackPerspective = perspective.getIsViewingBlackPerspective();\n\t// Arrow hitbox locations in black's perspective need to be negated so overlap detection remains accurate.\n\tconst effectiveArrowLocations: DoubleCoords[] = isBlackPerspective\n\t\t? arrowLocations.map((loc) => [-loc[0], -loc[1]])\n\t\t: arrowLocations;\n\n\t// X-axis: file labels centered on each file column, fixed at the bottom of the screen.\n\t// Shifted down by ATLAS_DESCENDER_FRACTION so the invisible descender space goes below\n\t// the screen edge rather than adding unwanted gap above the visible characters.\n\t// Shifted up by the game info bar height so labels aren't covered when it's visible.\n\tconst gameInfoBarOffsetWorld = space.convertPixelsToWorldSpace_Virtual(\n\t\tguigameinfo.getHeightOfGameInfoBar(),\n\t);\n\tconst fileWorldY =\n\t\tscreenBox.bottom +\n\t\tgameInfoBarOffsetWorld +\n\t\tpaddingWorld +\n\t\tsizeWorld * (0.5 - ATLAS_DESCENDER_FRACTION);\n\tconst firstFile = ceilToMultiple(tileBox.left, stepBig);\n\n\t// Y-axis: rank labels left-aligned from the left edge of the screen, at each rank row.\n\tconst rankWorldX = screenBox.left + paddingWorld;\n\tconst firstRank = ceilToMultiple(tileBox.bottom, stepBig);\n\n\t// Render without any rotation so glyphs always appear upright.\n\t// In black's perspective the view matrix carries a 180° Z-rotation that would otherwise flip the text.\n\tperspective.renderWithoutPerspectiveRotations(() => {\n\t\tfor (let file = firstFile; file <= tileBox.right; file += stepBig) {\n\t\t\tlet worldX = space.convertCoordToWorldSpace(bdcoords.FromCoords([file, 0n]))[0];\n\t\t\tif (isBlackPerspective) worldX = -worldX; // Invert world coords\n\t\t\t// prettier-ignore\n\t\t\trenderLabel(formatCoord(file), [worldX, fileWorldY], sizeWorld, 'center', arrowHalfWidth, effectiveArrowLocations);\n\t\t}\n\t\tfor (let rank = firstRank; rank <= tileBox.top; rank += stepBig) {\n\t\t\tlet worldY = space.convertCoordToWorldSpace(bdcoords.FromCoords([0n, rank]))[1];\n\t\t\tif (isBlackPerspective) worldY = -worldY; // Invert world coords\n\t\t\t// prettier-ignore\n\t\t\trenderLabel(formatCoord(rank), [rankWorldX, worldY], sizeWorld, 'left', arrowHalfWidth, effectiveArrowLocations);\n\t\t}\n\t});\n}\n\n/**\n * Renders a single coordinate label at the given position, unless its hitbox\n * intersects an arrow indicator hitbox (expanded by the current arrow padding).\n */\nfunction renderLabel(\n\tlabel: string,\n\tcoords: DoubleCoords,\n\tsizeWorld: number,\n\talign: 'left' | 'center' | 'right',\n\tarrowHalfWidth: number,\n\tarrowLocations: DoubleCoords[],\n): void {\n\tconst labelBounds = textrenderer.getTextBounds(label, coords, sizeWorld, align);\n\tfor (const loc of arrowLocations) {\n\t\tif (\n\t\t\t!bounds.areBoxesDisjoint(labelBounds, {\n\t\t\t\tleft: loc[0] - arrowHalfWidth,\n\t\t\t\tright: loc[0] + arrowHalfWidth,\n\t\t\t\tbottom: loc[1] - arrowHalfWidth,\n\t\t\t\ttop: loc[1] + arrowHalfWidth,\n\t\t\t})\n\t\t)\n\t\t\treturn; // Skip, it overlaps an arrow indicator.\n\t}\n\t// Proceed to render\n\ttextrenderer.render(label, coords, sizeWorld, LABEL_COLOR, align);\n\tif (DEBUG_RENDER_LABEL_BOUNDS) renderLabelBoundsOutline(labelBounds);\n}\n\n/**\n * [DEBUG] Renders a wireframe outline of a label's bounding box.\n * Only called when {@link DEBUG_RENDER_LABEL_BOUNDS} is `true`.\n */\nfunction renderLabelBoundsOutline(labelBounds: DoubleBoundingBox): void {\n\tconst DEBUG_BOUNDS_COLOR: Color = [1, 0, 0, 1]; // Red\n\tconst data = primitives.Rect(\n\t\tlabelBounds.left,\n\t\tlabelBounds.bottom,\n\t\tlabelBounds.right,\n\t\tlabelBounds.top,\n\t\tDEBUG_BOUNDS_COLOR,\n\t);\n\tcreateRenderable(data, 2, 'LINE_LOOP', 'color', true).render();\n}\n\n// Exports -------------------------------------------------------------------------\n\nexport default { render };\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/dragging/draganimation.ts",
    "content": "// src/client/scripts/esm/game/rendering/dragging/draganimation.ts\n\n/**\n * This script hides the original piece and renders a copy at the pointer location.\n * It also highlights the square that the piece would be dropped on (to do)\n * and plays the sound when the piece is dropped.\n */\n\nimport type { Color } from '../../../../../../shared/util/math/math.js';\nimport type { Piece } from '../../../../../../shared/chess/util/boardutil.js';\nimport type { Coords, DoubleCoords } from '../../../../../../shared/chess/util/coordutil.js';\n\nimport bd from '@naviary/bigdecimal';\n\nimport typeutil from '../../../../../../shared/chess/util/typeutil.js';\nimport bdcoords from '../../../../../../shared/chess/util/bdcoords.js';\nimport coordutil from '../../../../../../shared/chess/util/coordutil.js';\n\nimport space from '../../misc/space.js';\nimport mouse from '../../../util/mouse.js';\nimport meshes from '../meshes.js';\nimport camera from '../camera.js';\nimport boardpos from '../boardpos.js';\nimport keybinds from '../../misc/keybinds.js';\nimport selection from '../../chess/selection.js';\nimport animation from '../animation.js';\nimport { Mouse } from '../../input.js';\nimport droparrows from './droparrows.js';\nimport boardtiles from '../boardtiles.js';\nimport primitives from '../primitives.js';\nimport preferences from '../../../components/header/preferences.js';\nimport perspective from '../perspective.js';\nimport { GameBus } from '../../GameBus.js';\nimport texturecache from '../../../chess/rendering/texturecache.js';\nimport frametracker from '../frametracker.js';\nimport legalmovemodel from '../highlights/legalmovemodel.js';\nimport instancedshapes from '../instancedshapes.js';\nimport { listener_overlay } from '../../chess/game.js';\nimport { createRenderable, createRenderable_Instanced } from '../../../webgl/Renderable.js';\n\n// Variables --------------------------------------------------------------------------------------\n\nconst z: number = 0.01;\n\n/**\n * The minimum size of the rendered dragged piece on screen, in virtual pixels.\n * When zoomed out, this prevents it becoming tiny relative to the other pieces.\n */\nconst dragMinSizeVirtualPixels = {\n\t/** 2D desktop mode */\n\tmouse: 50, // Only applicable in 2D mode, not perspective\n\t/** Mobile/touchscreen mode */\n\ttouch: 50,\n} as const;\n\n/**\n * The width of the box/rank/file outline used to emphasize the hovered square.\n */\nconst outlineWidth = {\n\t/** 2D desktop mode */\n\tmouse: 0.08,\n\t// Since on touchscreen the rank/column outlines are ALWAYS enabled,\n\t// make them a little less noticeable/distracting.\n\t/** Mobile/touchscreen mode */\n\ttouch: 0.065,\n} as const;\n\n/** When using a touchscreen, the piece is shifted upward by this amount to prevent it being covered by fingers. */\nconst touchscreenOffset: number = 1.6; // Default: 2\n/** When each square becomes smaller than this in virtual pixels, we render rank/column outlines instead of the outline box. */\nconst minSizeToDrawOutline: number = 40;\n\n/** Adjustments for the dragged piece while in perspective mode. */\nconst perspectiveConfigs: { z: number; shadowColor: Color } = {\n\t/** The height the piece is rendered above the board when in perspective mode. */\n\tz: 0.6,\n\t/** The color of the shadow of the dragged piece. */\n\tshadowColor: [0.1, 0.1, 0.1, 0.5],\n} as const;\n\n/** If true, `pieceSelected` is currently being held. */\nlet areDragging = false;\n/**\n * When true, the rank/file outline is always rendered during dragging,\n * regardless of zoom level. Set by dragarrows.ts during slide zone mode.\n */\nlet forceRankFileOutline: boolean = false;\n/**\n * When true, the next time a piece is dropped on its own square, it will NOT be unselected.\n * But if this is false, it WOULD be unselected.\n * Pieces are unselected every second time dropped.\n */\nlet parity: boolean = true;\n\n/** The ID of the pointer that is dragging the piece. */\nlet pointerId: string | undefined;\n\n/** The coordinates of the piece before it was dragged. */\nlet startCoords: Coords | undefined;\n/** The world location the piece has been dragged to. */\nlet worldLocation: DoubleCoords | undefined;\n/** The square that the piece would be moved to if dropped now. It will be outlined. */\nlet hoveredCoords: Coords | undefined;\n/** The type of piece being dragged. */\nlet pieceType: number | undefined;\n\n// Functions --------------------------------------------------------------------------------------\n\nfunction areDraggingPiece(): boolean {\n\treturn areDragging;\n}\n\n/** Forces the rank/file outline to always render during dragging. Used by dragarrows.ts in slide zone mode. */\nfunction setForceRankFileOutline(value: boolean): void {\n\tforceRankFileOutline = value;\n}\n\n/** If true, the last pick-up action newly selected that piece, vs picking up an already-selected piece. */\nfunction getDragParity(): boolean {\n\treturn parity;\n}\n\n/**\n * Start dragging a piece.\n * @param type - The type of piece being dragged\n * @param pieceCoords - the square the piece was on\n */\nfunction pickUpPiece(piece: Piece, resetParity: boolean): void {\n\tif (!keybinds.getEffectiveDragEnabled()) return; // Dragging is disabled\n\tareDragging = true;\n\tif (resetParity) parity = true;\n\n\tconst respectiveListener = mouse.getRelevantListener();\n\tpointerId = respectiveListener.getMouseId(Mouse.LEFT);\n\n\tstartCoords = piece.coords;\n\tpieceType = piece.type;\n\t// If any one animation's end coords is currently being animated towards the coords of the picked up piece, clear the animation.\n\tif (\n\t\tanimation.animations.some((a) =>\n\t\t\tcoordutil.areCoordsEqual(piece.coords, a.path[a.path.length - 1]!),\n\t\t)\n\t)\n\t\tanimation.clearAnimations(true);\n}\n\n/**\n * Call AFTER selection.update()\n */\nfunction updateDragLocation(): void {\n\tif (!areDragging) return;\n\n\t/**\n\t * If the promotion UI is open, change the world location of\n\t * the dragged piece to the promotion square\n\t */\n\tconst squarePawnPromotingOn = selection.getSquarePawnIsCurrentlyPromotingOn();\n\tif (squarePawnPromotingOn !== undefined) {\n\t\tconst worldCoords = space.convertCoordToWorldSpace(\n\t\t\tbdcoords.FromCoords(squarePawnPromotingOn),\n\t\t);\n\t\tworldLocation = worldCoords;\n\t\thoveredCoords = squarePawnPromotingOn;\n\t\treturn;\n\t} else {\n\t\t// Normal drag location\n\t\tworldLocation = mouse.getPointerWorld(pointerId!);\n\t\thoveredCoords = worldLocation\n\t\t\t? space.convertWorldSpaceToCoords_Rounded(worldLocation)\n\t\t\t: undefined;\n\t}\n}\n\n/** Call AFTER {@link updateDragLocation} and BEFORE {@link renderPiece} */\nfunction setDragLocationAndHoverSquare(worldLoc: DoubleCoords, hoverSquare: Coords): void {\n\tworldLocation = worldLoc;\n\thoveredCoords = hoverSquare;\n}\n\n/** Returns the id of the pointer currently dragging a piece. */\nfunction getPointerIdDraggingPiece(): string | undefined {\n\tif (!areDragging) throw Error('Unexpected!');\n\treturn pointerId;\n}\n\n/**\n * Returns the square the dragged piece is currently hovering over.\n * Set by updateDragLocation or setDragLocationAndHoverSquare\n * by the droparrows or dragarrows features.\n */\nfunction getHoveredCoords(): Coords | undefined {\n\treturn hoveredCoords;\n}\n\n/** Whether the pointer dragging the selected piece has released yet. */\nfunction hasPointerReleased(): boolean {\n\tif (!areDragging) throw Error(\"Don't call hasPointerReleased() when not dragging a piece\");\n\tconst respectiveListener = mouse.getRelevantListener();\n\treturn !respectiveListener.isPointerHeld(pointerId!);\n}\n\n// /** Returns the pointer id that is dragging the piece. */\n// function getPointerId(): string {\n// \tif (!areDragging) throw Error(\"Don't call getPointerId() when not dragging a piece\");\n// \treturn pointerId!;\n// }\n\n/**\n * Stop dragging the piece.\n */\nfunction dropPiece(): void {\n\t// console.error(\"Dropped piece\");\n\tif (!areDragging) return;\n\tareDragging = false;\n\tpieceType = undefined;\n\tstartCoords = undefined;\n\tworldLocation = undefined;\n\thoveredCoords = undefined;\n\tparity = false; // The next time this piece is dropped on its home square, it will be deselected\n\tdroparrows.onDragTermination();\n\tframetracker.onVisualChange();\n\t// Rapidly picking up and dropping a piece triggers a simulated click.\n\t// If we don't claim it here, annotations will read it to Collapse annotations.\n\tif (mouse.isMouseClicked(Mouse.LEFT)) mouse.claimMouseClick(Mouse.LEFT);\n}\n\nGameBus.addEventListener('piece-unselected', () => {\n\tcancelDragging();\n});\n\n/** Puts the dragged piece back. Doesn't make a move. */\nfunction cancelDragging(): void {\n\tdropPiece();\n\tparity = true;\n}\n\n// Rendering --------------------------------------------------------------------------------------------\n\n// Hides the original piece by rendering a transparent square model above it in the depth field.\nfunction renderTransparentSquare(): void {\n\tif (!startCoords) return;\n\n\tconst color: Color = [0, 0, 0, 0];\n\tconst data = meshes.QuadWorld_Color(startCoords, color); // Hide orginal piece\n\treturn createRenderable(data, 2, 'TRIANGLES', 'color', true).render([0, 0, z]);\n}\n\n// Renders the box outline, the dragged piece and its shadow\nfunction renderPiece(): void {\n\tif (!areDragging || perspective.isLookingUp() || !worldLocation) return;\n\n\trenderOutline();\n\trenderPieceModel();\n}\n\n/** Generates the model of the dragged piece and its shadow. */\nfunction renderPieceModel(): void {\n\tif (typeutil.SVGLESS_TYPES.has(typeutil.getRawType(pieceType!))) return; // No SVG/texture for this piece (void), can't render it.\n\n\tconst perspectiveEnabled = perspective.getEnabled();\n\tconst touchscreenUsed = listener_overlay.isPointerTouch(pointerId!);\n\tconst boardScale = boardpos.getBoardScaleAsNumber();\n\tconst rotation = perspective.getIsViewingBlackPerspective() ? -1 : 1;\n\tconst { texleft, texbottom, texright, textop } = meshes.getPieceTexCoords();\n\n\t// In perspective the piece is rendered above the surface of the board.\n\tconst height = perspectiveEnabled ? perspectiveConfigs.z * boardScale : z;\n\n\t// If touchscreen is being used the piece is rendered larger and offset upward to prevent\n\t// it being covered by the finger.\n\tlet size: number = boardScale;\n\tif (!selection.getSquarePawnIsCurrentlyPromotingOn() && !perspective.getEnabled()) {\n\t\t// Apply a minimum size only if we're not currently promoting a pawn (promote UI open) and not in perspective mode.\n\t\t// The minimum world space the dragged piece should be rendered\n\t\tconst minSizeWorldSpace = touchscreenUsed\n\t\t\t? space.convertPixelsToWorldSpace_Virtual(dragMinSizeVirtualPixels.touch) // Mobile/touchscreen mode\n\t\t\t: space.convertPixelsToWorldSpace_Virtual(dragMinSizeVirtualPixels.mouse); // 2D desktop mode\n\t\tsize = Math.max(size, minSizeWorldSpace); // Apply the minimum size\n\t}\n\n\tconst halfSize = size / 2;\n\tconst left = worldLocation![0] - halfSize;\n\tconst bottom =\n\t\tworldLocation![1] - halfSize + (touchscreenUsed ? touchscreenOffset * rotation : 0);\n\tconst right = worldLocation![0] + halfSize;\n\tconst top = worldLocation![1] + halfSize + (touchscreenUsed ? touchscreenOffset * rotation : 0);\n\n\tconst data: number[] = [];\n\t// prettier-ignore\n\tif (perspectiveEnabled) data.push(...primitives.Quad_ColorTexture3D(left, bottom, right, top, z, texleft, texbottom, texright, textop, ...perspectiveConfigs.shadowColor)); // Shadow\n\t// prettier-ignore\n\tdata.push(...primitives.Quad_ColorTexture3D(left, bottom, right, top, height, texleft, texbottom, texright, textop, 1, 1, 1, 1)); // Piece\n\tcreateRenderable(\n\t\tdata,\n\t\t3,\n\t\t'TRIANGLES',\n\t\t'colorTexture',\n\t\ttrue,\n\t\ttexturecache.getTexture(pieceType!),\n\t).render();\n}\n\n/**\n * Renders the outline emphasizing the hovered square.\n * If mouse is being used the square is outlined.\n * On touchscreen (or in slide zone mode) the entire rank and file are outlined.\n */\n// prettier-ignore\nfunction renderOutline(): void {\n\tconst pointerIsTouch = listener_overlay.isPointerTouch(pointerId!);\n\t// The coordinates of the edges of the square\n\tconst { left, right, bottom, top } = meshes.getCoordBoxWorld(hoveredCoords!);\n\tconst boardScale = boardpos.getBoardScaleAsNumber();\n\tconst width = (pointerIsTouch ? outlineWidth.touch : outlineWidth.mouse) * boardScale;\n\tconst color = preferences.getBoxOutlineColor();\n\n\t// Outline the entire rank & file when:\n\t// 1. We're not hovering over the start square.\n\t// 2. It is a touch screen, OR we are zoomed out enough.\n\tif (\n\t\t!coordutil.areCoordsEqual(hoveredCoords!, startCoords!) &&\n\t\t(forceRankFileOutline || pointerIsTouch || bd.toNumber(boardtiles.gtileWidth_Pixels()) < minSizeToDrawOutline)\n\t) {\n\t\t// Outline the entire rank and file\n\t\tconst screenBox = camera.getRespectiveScreenBox();\n\t\tconst data: number[] = [];\n\t\tdata.push(...primitives.Quad_Color(left, screenBox.bottom, left + width, screenBox.top, color)); // left\n\t\tdata.push(...primitives.Quad_Color(screenBox.left, bottom, screenBox.right, bottom + width, color)); // bottom\n\t\tdata.push(...primitives.Quad_Color(right - width, screenBox.bottom, right, screenBox.top, color)); // right\n\t\tdata.push(...primitives.Quad_Color(screenBox.left, top - width, screenBox.right, top, color)); // top\n\t\tcreateRenderable(data, 2, 'TRIANGLES', 'color', true).render();\n\t} else {\n\t\t// Outline the hovered square using an instanced box outline model\n\t\tconst vertexData = instancedshapes.getDataBoxOutline();\n\t\tconst offset = legalmovemodel.getOffset();\n\t\tconst offsetCoord = coordutil.subtractCoords(hoveredCoords!, offset);\n\t\tconst instanceData: number[] = [Number(offsetCoord[0]), Number(offsetCoord[1])];\n\t\tconst { position, scale } = meshes.getBoardRenderTransform(offset);\n\t\tcreateRenderable_Instanced(vertexData, instanceData, 'TRIANGLES', 'colorInstanced', true)\n\t\t\t.render(position, scale);\n\t}\n}\n\nexport default {\n\tareDraggingPiece,\n\tgetDragParity,\n\tpickUpPiece,\n\tupdateDragLocation,\n\tsetDragLocationAndHoverSquare,\n\tsetForceRankFileOutline,\n\tgetPointerIdDraggingPiece,\n\tgetHoveredCoords,\n\thasPointerReleased,\n\tdropPiece,\n\trenderTransparentSquare,\n\trenderPiece,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/dragging/dragarrows.ts",
    "content": "// src/client/scripts/esm/game/rendering/dragging/dragarrows.ts\n\n/**\n * This script handles clicking and dragging arrow indicators that point to your\n * own off-screen pieces, allowing you to drag and move that piece without\n * needing to pan or zoom to it.\n *\n * This is the companion feature to droparrows.ts (which handles dropping your\n * dragged piece onto arrows to capture off-screen opponent pieces).\n */\n\nimport type { Color } from '../../../../../../shared/util/math/math.js';\nimport type { Piece } from '../../../../../../shared/chess/util/boardutil.js';\nimport type { LegalMoves } from '../../../../../../shared/chess/logic/legalmoves.js';\nimport type { HoveredArrow } from '../arrows/arrows.js';\nimport type { Vec2, Vec2Key } from '../../../../../../shared/util/math/vectors.js';\nimport type {\n\tCoords,\n\tBDCoords,\n\tDoubleCoords,\n} from '../../../../../../shared/chess/util/coordutil.js';\n\nimport vectors from '../../../../../../shared/util/math/vectors.js';\nimport geometry from '../../../../../../shared/util/math/geometry.js';\nimport bdcoords from '../../../../../../shared/chess/util/bdcoords.js';\nimport boardutil from '../../../../../../shared/chess/util/boardutil.js';\nimport coordutil from '../../../../../../shared/chess/util/coordutil.js';\nimport legalmoves from '../../../../../../shared/chess/logic/legalmoves.js';\n\nimport space from '../../misc/space.js';\nimport mouse from '../../../util/mouse.js';\nimport camera from '../camera.js';\nimport meshes from '../meshes.js';\nimport arrows from '../arrows/arrows.js';\nimport gameslot from '../../chess/gameslot.js';\nimport keybinds from '../../misc/keybinds.js';\nimport selection from '../../chess/selection.js';\nimport { Mouse } from '../../input.js';\nimport maskedDraw from '../../../webgl/maskedDraw.js';\nimport primitives from '../primitives.js';\nimport droparrows from './droparrows.js';\nimport guigameinfo from '../../gui/guigameinfo.js';\nimport arrowshifts from '../arrows/arrowshifts.js';\nimport frametracker from '../frametracker.js';\nimport loadbalancer from '../../misc/loadbalancer.js';\nimport draganimation from './draganimation.js';\nimport guinavigation from '../../gui/guinavigation.js';\nimport legalmovemodel from '../highlights/legalmovemodel.js';\nimport arrowscalculator from '../arrows/arrowscalculator.js';\nimport { ARROW_SIZE_RATIO } from '../arrows/arrowsgraphics.js';\nimport { createRenderable } from '../../../webgl/Renderable.js';\n\n// Types ---------------------------------------------------------------------------------\n\n/**\n * State stored when the user presses the mouse on an own-piece arrow indicator.\n * Persists until the pointer is released or the drag is fully initiated.\n */\ninterface CandidateArrow {\n\t/** Integer board coordinates of the off-screen piece the arrow points to. */\n\tpieceCoords: Coords;\n\t/** The type of the off-screen piece the arrow points to. */\n\tpieceType: number;\n\t/** The direction vector of the arrow indicator. */\n\tdirection: Vec2;\n\t/** The input pointer ID holding the mouse button down. */\n\tpointerId: string;\n}\n\n// Constants -------------------------------------------------------------------------------\n\n/** Settings for the animated arrows shown beside the candidate arrow indicator. */\nconst CANDIDATE_ANIM = {\n\t/** Period of the oscillation, in milliseconds. */\n\tPERIOD_MS: 800,\n\t/** Amplitude of the oscillation, as a multiple of the arrow indicator half-width. */\n\tAMPLITUDE: 0.3,\n\t/** Initial phase offset as a fraction of the full period (0–1). */\n\tPHASE_INITIAL: 0.1,\n\t/** Color of the arrows [r, g, b, a]. */\n\tCOLOR: [0, 0, 0, 0.8] satisfies Color,\n} as const;\n\n/** The width of the slide zone, as a percentage of arrow indicator images. */\nconst SLIDE_ZONE_WIDTH = 1.7;\n/** Radial gradient rendered inside the slide zone. */\nconst SLIDE_ZONE_GRADIENT = {\n\tCOLORS: [\n\t\t[1, 1, 1, 0.2],\n\t\t[1, 1, 1, 0.6],\n\t] satisfies Color[],\n\t/** World units between each individual color ring. */\n\tSPACING: 5,\n\t/** World units per second the phase advances. */\n\tVELOCITY: 9,\n} as const;\n\n// State ---------------------------------------------------------------------------------\n\n/** The candidate arrow — set when mouse is pressed on an own-piece arrow, cleared when pointer releases. */\nlet candidate: CandidateArrow | undefined;\n/**\n * Whether the drag has been activated (mouse moved past the activation threshold).\n * Can only ever be true if candidate is also defined.\n */\nlet isDragActive: boolean = false;\n/** Whether the dragged piece is currently positioned inside the slide zone. */\nlet currentlyInSlideZone: boolean = false;\n\n/** Timestamp when the current candidate was set, used for the candidate animation. */\nlet candidateAnimStartTime: number = 0;\n/** Current phase offset for the slide zone radial gradient, in world units. */\nlet slideZonePhase: number = 0;\n\n// Main update ---------------------------------------------------------------------------\n\n/**\n * Main per-frame update.\n *\n * CALL AFTER droparrows.shiftArrows() and BEFORE arrows.executeArrowShifts().\n */\nfunction update(): void {\n\tif (!gameslot.getGamefile()) return;\n\tif (!arrows.areArrowsActiveThisFrame()) return;\n\n\tif (isDragActive) {\n\t\tupdateActiveDrag();\n\t} else if (candidate !== undefined) {\n\t\tupdateCandidate();\n\t} else {\n\t\tdetectCandidateArrow();\n\t}\n\n\tif (candidate !== undefined) {\n\t\t// Keep rendering while the candidate animation is active,\n\t\t// OR there's an active drag.\n\t\tframetracker.onVisualChange();\n\n\t\tif (isDragActive) {\n\t\t\t// Update the phase of the slide zone gradient to create a moving effect\n\t\t\tslideZonePhase =\n\t\t\t\t(slideZonePhase + SLIDE_ZONE_GRADIENT.VELOCITY * loadbalancer.getDeltaTime()) %\n\t\t\t\t(SLIDE_ZONE_GRADIENT.COLORS.length * SLIDE_ZONE_GRADIENT.SPACING);\n\t\t\tframetracker.onVisualChange(); // Render this frame (slide zone is being animated)\n\t\t}\n\t}\n}\n\n/** Branch A: drag is active. Manage slide zone positioning and arrow shifts. */\nfunction updateActiveDrag(): void {\n\tif (!draganimation.areDraggingPiece()) {\n\t\t// The drag was completed or cancelled by selection.ts (piece was dropped/moved).\n\t\treset();\n\t\treturn;\n\t}\n\n\tif (findCandidateHoveredArrow() !== undefined) {\n\t\t// Mouse moved back within threshold — deactivate the drag.\n\t\tdraganimation.setForceRankFileOutline(false);\n\t\tisDragActive = false;\n\t\t// console.log('Set isDragActive = false');\n\t\tselection.unselectPiece(); // Fires 'piece-unselected' → draganimation.cancelDragging()\n\t\treturn;\n\t}\n\n\tconst mouseWorld = mouse.getPointerWorld(candidate!.pointerId);\n\tif (!mouseWorld) return;\n\n\tmanageActiveDrag(mouseWorld);\n}\n\n/** Branch B: candidate exists but drag not yet active. Check threshold and initiate drag. */\nfunction updateCandidate(): void {\n\tconst respectiveListener = mouse.getRelevantListener();\n\tif (!respectiveListener.isPointerHeld(candidate!.pointerId)) {\n\t\t// Pointer released without crossing threshold — clear candidate, allow normal arrow click.\n\t\tcandidate = undefined;\n\t\t// console.log('Set candidate = undefined');\n\t\treturn;\n\t}\n\n\tif (findCandidateHoveredArrow() !== undefined) return; // Still within threshold — wait.\n\n\t// Threshold crossed — initiate drag of the off-screen piece.\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst piece: Piece | undefined = boardutil.getPieceFromCoords(\n\t\tgamefile.boardsim.pieces,\n\t\tcandidate!.pieceCoords,\n\t);\n\tif (!piece) {\n\t\t// Piece disappeared (shouldn't happen during candidate phase, but guard it).\n\t\tcandidate = undefined;\n\t\t// console.log('Set candidate = undefined');\n\t\treturn;\n\t}\n\n\tselection.selectPiece(gamefile, gameslot.getMesh(), piece, true);\n\tmouse.cancelMouseClick(Mouse.LEFT); // Prevent the eventual release from being treated as a click/teleport.\n\tisDragActive = true;\n\t// console.log('Set isDragActive = true');\n\tdraganimation.setForceRankFileOutline(true);\n\tframetracker.onVisualChange();\n}\n\n/** Branch C: no candidate. Check for a new mouse-down on an own-piece arrow. */\nfunction detectCandidateArrow(): void {\n\tif (!mouse.isMouseDown(Mouse.LEFT)) return;\n\n\tconst hoveredArrowsList = arrows.getHoveredArrows();\n\tif (hoveredArrowsList.length === 0) return;\n\n\t// Claim the mouse down for any arrow hover to prevent board drag.\n\t// Mouse down for move hint arrow indicators must be claimed separately.\n\tmouse.claimMouseDown(Mouse.LEFT);\n\n\t// Early exit on dragging disabled now, since the mouse down has been claimed.\n\tif (!keybinds.getEffectiveDragEnabled()) return;\n\n\tconst gamefile = gameslot.getGamefile()!;\n\n\tfor (const hoveredArrow of hoveredArrowsList) {\n\t\tif (hoveredArrow.piece.floating) continue; // Ignore animated arrows.\n\t\tif (!hoveredArrow.ownsSlide) continue; // Piece can't slide in this direction.\n\t\tconst pieceType = hoveredArrow.piece.type;\n\t\tif (selection.canSelectPieceType(gamefile.basegame, pieceType) !== 2) continue; // Not own draggable piece.\n\n\t\tconst pieceCoords = bdcoords.coordsToBigInt(hoveredArrow.piece.coords);\n\t\tconst pointerId = mouse.getRelevantListener().getMouseId(Mouse.LEFT)!;\n\n\t\tcandidate = {\n\t\t\tpieceCoords,\n\t\t\tpieceType,\n\t\t\tdirection: hoveredArrow.direction,\n\t\t\tpointerId,\n\t\t};\n\t\t// console.log('Set candidate');\n\n\t\tcandidateAnimStartTime = performance.now();\n\t\tbreak;\n\t}\n}\n\n// Active drag management ---------------------------------------------------------------\n\n/**\n * Returns the hovered arrow that matches the current candidate, or undefined if not found.\n * The arrow may move every frame (panning & zooming), so we have to re-check each frame.\n */\nfunction findCandidateHoveredArrow(): HoveredArrow | undefined {\n\tif (!candidate) return undefined;\n\treturn arrows.getHoveredArrows().find((h) => {\n\t\tif (h.piece.floating) return false;\n\t\tconst hCoords = bdcoords.coordsToBigInt(h.piece.coords);\n\t\treturn (\n\t\t\tcoordutil.areCoordsEqual(hCoords, candidate!.pieceCoords) &&\n\t\t\tcoordutil.areCoordsEqual(h.direction, candidate!.direction)\n\t\t);\n\t});\n}\n\n/**\n * Handles the per-frame logic when the drag is active and the mouse is past threshold.\n * Determines if the mouse is in the slide zone and updates drag position accordingly.\n */\nfunction manageActiveDrag(mouseWorld: DoubleCoords): void {\n\t// Slide zone depth in world space units\n\tconst slideZoneDepth = 2.0 * arrowscalculator.getArrowIndicatorHalfWidth() * SLIDE_ZONE_WIDTH;\n\t// Always use the 2D screen box for slide zone boundaries, even in perspective mode.\n\tconst screenBox = camera.getScreenBoundingBox(false);\n\tconst dir = candidate!.direction;\n\n\tconst topBarDepth = space.convertPixelsToWorldSpace_Virtual(guinavigation.getHeightOfNavBar());\n\tconst bottomBarDepth = space.convertPixelsToWorldSpace_Virtual(\n\t\tguigameinfo.getHeightOfGameInfoBar(),\n\t);\n\n\tconst inRight = dir[0] > 0n && mouseWorld[0] > screenBox.right - slideZoneDepth;\n\tconst inLeft = dir[0] < 0n && mouseWorld[0] < screenBox.left + slideZoneDepth;\n\tconst inTop = dir[1] > 0n && mouseWorld[1] > screenBox.top - slideZoneDepth - topBarDepth;\n\tconst inBottom =\n\t\tdir[1] < 0n && mouseWorld[1] < screenBox.bottom + slideZoneDepth + bottomBarDepth;\n\tcurrentlyInSlideZone = inRight || inLeft || inTop || inBottom;\n\n\tif (currentlyInSlideZone) {\n\t\tupdateSlideZoneDrag(mouseWorld);\n\t} else {\n\t\tupdateOnScreenDrag();\n\t}\n}\n\n/** Mouse is in the slide zone — compute intersection and keep piece off-screen. */\nfunction updateSlideZoneDrag(mouseWorld: DoubleCoords): void {\n\tdraganimation.setForceRankFileOutline(true);\n\t// droparrows has already snapped the drag position and queued a moveArrow shift for the\n\t// captured piece's location — don't overwrite it with an animateArrow shift.\n\tif (droparrows.getCaptureCoords() !== undefined) return;\n\n\tconst mouseBDCoords: BDCoords = space.convertWorldSpaceToCoords(mouseWorld);\n\tconst pieceBDCoords: BDCoords = bdcoords.FromCoords(candidate!.pieceCoords);\n\tconst arrowDir = candidate!.direction;\n\tconst perpDir = vectors.getPerpendicularVector(arrowDir);\n\n\t// Line 1: through mouse in arrow direction.\n\tconst line1 = vectors.getLineGeneralFormFromCoordsAndVecBD(mouseBDCoords, arrowDir);\n\t// Line 2: through piece, perpendicular to arrow direction.\n\tconst line2 = vectors.getLineGeneralFormFromCoordsAndVecBD(pieceBDCoords, perpDir);\n\n\t// Intersection gives the dragged piece's board position.\n\tconst intersectionBD: BDCoords | undefined = geometry.calcIntersectionPointOfLinesBD(\n\t\t...line1,\n\t\t...line2,\n\t);\n\tif (!intersectionBD) return; // Lines are parallel (shouldn't happen with perpendicular lines).\n\n\tconst intersectionWorld: DoubleCoords = space.convertCoordToWorldSpace(intersectionBD);\n\tconst hoveredCoords: Coords = space.roundCoords(intersectionBD);\n\n\tdraganimation.setDragLocationAndHoverSquare(intersectionWorld, hoveredCoords);\n\n\t// Queue arrow shifts — animateArrow handles deletion of the original arrow and places\n\t// animated arrows (for each applicable slide direction) at the intersection.\n\tarrowshifts.animateArrow(candidate!.pieceCoords, intersectionBD, candidate!.pieceType);\n}\n\n/** Mouse is outside the slide zone — piece follows mouse normally, original arrow removed. */\nfunction updateOnScreenDrag(): void {\n\tdraganimation.setForceRankFileOutline(false);\n\t// droparrows has already queued a moveArrow shift — don't overwrite it with a deleteArrow.\n\tif (droparrows.getCaptureCoords() !== undefined) return;\n\t// Delete the original arrow. Normal drag rendering takes over.\n\tarrowshifts.deleteArrow(candidate!.pieceCoords);\n}\n\n// Cleanup -----------------------------------------------------------------------------\n\n/** Resets all drag arrow state. Called when the drag naturally completes or is force-cleared. */\nfunction reset(): void {\n\t// console.error('Resetting state');\n\tcandidate = undefined;\n\tisDragActive = false;\n\tcurrentlyInSlideZone = false;\n\tcandidateAnimStartTime = 0;\n\tdraganimation.setForceRankFileOutline(false);\n}\n\n// Rendering ---------------------------------------------------------------------------\n\n/** Renders all dragarrows visuals: the slide zone gradient and the slide move highlights. */\nfunction render(): void {\n\tif (!arrows.areArrowsActiveThisFrame()) return;\n\trenderCandidateArrows();\n\trenderSlideZone();\n\trenderSlideMoveHighlights();\n}\n\n/**\n * Renders two animated arrowhead triangles on either side of the candidate arrow indicator,\n * perpendicular to the arrow direction, while awaiting drag activation.\n */\nfunction renderCandidateArrows(): void {\n\tif (!candidate || isDragActive) return;\n\n\tconst worldLocation = findCandidateHoveredArrow()?.worldLocation;\n\tif (!worldLocation) return;\n\n\tconst halfWidth = arrowscalculator.getArrowIndicatorHalfWidth();\n\tconst size = halfWidth * ARROW_SIZE_RATIO;\n\n\t// Determine the perpendicular axis from the indicator's screen position by measuring\n\t// the raw world-space distance to each edge pair. The indicator sits on whichever edge is closer.\n\tconst screenBox = camera.getScreenBoundingBox(false);\n\tconst cx = worldLocation[0];\n\tconst cy = worldLocation[1];\n\tconst topBarDepth = space.convertPixelsToWorldSpace_Virtual(guinavigation.getHeightOfNavBar());\n\tconst bottomBarDepth = space.convertPixelsToWorldSpace_Virtual(\n\t\tguigameinfo.getHeightOfGameInfoBar(),\n\t);\n\tconst distToHorizontalEdge = screenBox.right - Math.abs(cx);\n\tconst distToVerticalEdge = Math.min(\n\t\tscreenBox.top - topBarDepth - cy,\n\t\tcy - screenBox.bottom - bottomBarDepth,\n\t);\n\t// px/py is the unit vector along which the extra arrows oscillate\n\tlet px: number, py: number;\n\tif (distToHorizontalEdge < distToVerticalEdge) {\n\t\t// Indicator is on the left or right edge → extra arrows go above/below\n\t\tpx = 0;\n\t\tpy = 1;\n\t} else {\n\t\t// Indicator is on the top or bottom edge → extra arrows go left/right\n\t\tpx = 1;\n\t\tpy = 0;\n\t}\n\n\t// Sine-wave oscillation with a configurable initial phase offset.\n\tconst elapsed = performance.now() - candidateAnimStartTime;\n\tconst phase = 2 * Math.PI * (elapsed / CANDIDATE_ANIM.PERIOD_MS + CANDIDATE_ANIM.PHASE_INITIAL);\n\tconst sineOffset = halfWidth * CANDIDATE_ANIM.AMPLITUDE * 0.5 * (1 - Math.cos(phase));\n\n\tconst data: number[] = [];\n\tconst [r, g, b, a] = CANDIDATE_ANIM.COLOR;\n\n\t// Render an arrowhead triangle in each perpendicular direction (+/-)\n\tfor (const sign of [1, -1] as const) {\n\t\tconst spx = sign * px;\n\t\tconst spy = sign * py;\n\n\t\t// Center of the base of this arrowhead triangle\n\t\tconst bx = cx + spx * (halfWidth + sineOffset);\n\t\tconst by = cy + spy * (halfWidth + sineOffset);\n\n\t\t// Perpendicular-of-perpendicular, for the width of the triangle base\n\t\tconst qx = -spy;\n\t\tconst qy = spx;\n\n\t\t// Triangle: two base corners + tip\n\t\t// prettier-ignore\n\t\tdata.push(\n\t\t\tbx + qx * size,  by + qy * size,  r, g, b, a,\n\t\t\tbx - qx * size,  by - qy * size,  r, g, b, a,\n\t\t\tbx + spx * size, by + spy * size, r, g, b, a,\n\t\t);\n\t}\n\n\tcreateRenderable(data, 2, 'TRIANGLES', 'color', true).render();\n}\n\n/** Renders a radial gradient over the slide zone when active. */\nfunction renderSlideZone(): void {\n\tif (!isDragActive || !candidate) return;\n\n\tconst screenBox = camera.getScreenBoundingBox(false);\n\t// Slide zone depth in world space units\n\tconst depth = 2.0 * arrowscalculator.getArrowIndicatorHalfWidth() * SLIDE_ZONE_WIDTH;\n\tconst dir = candidate.direction;\n\n\t// Build mask geometry — color values are irrelevant, only the geometry is used for stenciling.\n\tconst maskData: number[] = [];\n\tconst dummyColor: Color = [0, 0, 0, 1];\n\tconst topBarDepth = space.convertPixelsToWorldSpace_Virtual(guinavigation.getHeightOfNavBar());\n\tconst bottomBarDepth = space.convertPixelsToWorldSpace_Virtual(\n\t\tguigameinfo.getHeightOfGameInfoBar(),\n\t);\n\t// prettier-ignore\n\tif (dir[0] > 0n) maskData.push(...primitives.Quad_Color(screenBox.right - depth, screenBox.bottom, screenBox.right, screenBox.top, dummyColor));\n\t// prettier-ignore\n\tif (dir[0] < 0n) maskData.push(...primitives.Quad_Color(screenBox.left, screenBox.bottom, screenBox.left + depth, screenBox.top, dummyColor));\n\t// prettier-ignore\n\tif (dir[1] > 0n) maskData.push(...primitives.Quad_Color(screenBox.left, screenBox.top - depth - topBarDepth, screenBox.right, screenBox.top, dummyColor));\n\t// prettier-ignore\n\tif (dir[1] < 0n) maskData.push(...primitives.Quad_Color(screenBox.left, screenBox.bottom, screenBox.right, screenBox.bottom + depth + bottomBarDepth, dummyColor));\n\n\tif (maskData.length === 0) return;\n\n\tconst maskRenderable = createRenderable(maskData, 2, 'TRIANGLES', 'color', true);\n\tmaskedDraw.execute(\n\t\t() => maskRenderable.render(),\n\t\tundefined,\n\t\t() =>\n\t\t\trenderRadialGradient(\n\t\t\t\tSLIDE_ZONE_GRADIENT.COLORS,\n\t\t\t\tSLIDE_ZONE_GRADIENT.SPACING,\n\t\t\t\tslideZonePhase,\n\t\t\t),\n\t\t'and',\n\t);\n}\n\n/**\n * Renders a full-screen radial gradient emanating from the screen center.\n * Colors repeat outward with the given spacing (world units) and phase offset.\n */\nfunction renderRadialGradient(colors: Color[], spacing: number, phase: number): void {\n\tconst screenBox = camera.getScreenBoundingBox(false);\n\tconst maxX = Math.max(Math.abs(screenBox.left), Math.abs(screenBox.right));\n\tconst maxY = Math.max(Math.abs(screenBox.top), Math.abs(screenBox.bottom));\n\tconst radius = Math.sqrt(maxX * maxX + maxY * maxY);\n\n\tconst data = primitives.RadialGradient(0, 0, radius, colors, spacing, phase, 360);\n\tif (data.length > 0) createRenderable(data, 2, 'TRIANGLES', 'color', true).render();\n}\n\n/**\n * When dragging an arrow indicator and the mouse is inside the slide zone,\n * renders white box outlines along the piece's sliding direction,\n * showing you what squares you can reach next by sliding the piece there.\n */\nfunction renderSlideMoveHighlights(): void {\n\tif (!candidate || !currentlyInSlideZone) return;\n\n\tconst hoveredCoords = draganimation.getHoveredCoords();\n\tif (!hoveredCoords) return;\n\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst pieceType = candidate.pieceType;\n\n\t// Get the piece's moveset\n\tconst moveset = legalmoves.getPieceMoveset(gamefile.boardsim, pieceType);\n\n\t// Find the canonical moveset sliding key (x-component is never negative in moveset keys)\n\tconst normalizedVec: Vec2 = vectors.absVector(candidate.direction);\n\tconst lineKey: Vec2Key = vectors.getKeyFromVec2(normalizedVec);\n\n\t// If the slide direction is orthogonal, skip. The entire orthogonal lines are already outlined in draganimation.ts\n\tif (normalizedVec[0] === 0n || normalizedVec[1] === 0n) return;\n\n\t// Only proceed if the piece actually slides in this direction\n\tif (!moveset.sliding?.[lineKey]) return;\n\n\t// For pieces that skip squares (e.g. knightriders), the hovered square may not be\n\t// a valid landing spot for the piece from its actual position. Skip in that case.\n\tconst draggedPiece = boardutil.getPieceFromCoords(\n\t\tgamefile.boardsim.pieces,\n\t\tcandidate.pieceCoords,\n\t)!;\n\tconst legalMoves: LegalMoves = legalmoves.getEmptyLegalMoves(moveset);\n\tlegalmoves.appendPotentialMoves(draggedPiece, moveset, legalMoves); // Appending potential is enough\n\tif (!legalmoves.doSlideRangesContainSquare(legalMoves, candidate.pieceCoords, hoveredCoords))\n\t\treturn;\n\n\t// Create a virtual piece at the hovered coords for move calculation\n\tconst piece: Piece = { type: pieceType, coords: hoveredCoords, index: -1 };\n\n\t// Build premove-style LegalMoves containing ONLY the arrow's sliding direction.\n\t// Premoves ignore friendly/enemy blocking (only voids and world border restrict).\n\tconst moves: LegalMoves = legalmoves.getEmptyLegalMoves(moveset);\n\tmoves.sliding[lineKey] = moveset.sliding[lineKey]!;\n\tlegalmoves.removeObstructedMoves(\n\t\tgamefile.boardsim,\n\t\tgamefile.basegame.gameRules.worldBorder,\n\t\tpiece,\n\t\tmoveset,\n\t\tmoves,\n\t\ttrue, // premove = true: only voids and world border restrict movement\n\t);\n\n\t// Render white box outlines for all reachable squares using the shared transform\n\tconst model = legalmovemodel.generateModelForSlideHighlightOutlines(hoveredCoords, moves);\n\tconst { position, scale } = meshes.getBoardRenderTransform(legalmovemodel.getOffset());\n\tmodel.render(position, scale);\n}\n\n// Exports ------------------------------------------------------------------------------\n\nexport default {\n\tupdate,\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/dragging/droparrows.ts",
    "content": "// src/client/scripts/esm/game/rendering/dragging/droparrows.ts\n\n/**\n * This script handles dropping the dragged piece onto\n * arrow indicators to capture the piece the arrow\n * is pointing to.\n */\n\nimport type { Piece } from '../../../../../../shared/chess/util/boardutil.js';\nimport type { Coords } from '../../../../../../shared/chess/util/coordutil.js';\n\nimport typeutil from '../../../../../../shared/chess/util/typeutil.js';\nimport bdcoords from '../../../../../../shared/chess/util/bdcoords.js';\nimport coordutil from '../../../../../../shared/chess/util/coordutil.js';\nimport legalmoves from '../../../../../../shared/chess/logic/legalmoves.js';\n\nimport space from '../../misc/space.js';\nimport arrows from '../arrows/arrows.js';\nimport gameslot from '../../chess/gameslot.js';\nimport selection from '../../chess/selection.js';\nimport arrowshifts from '../arrows/arrowshifts.js';\nimport frametracker from '../frametracker.js';\nimport draganimation from './draganimation.js';\n\n// Constants -------------------------------------------------------------------------------\n\n/** Settings for the opacity pulsation on legally capturable arrow indicators. */\nconst LEGAL_CAPTURE_PULSATE = {\n\t/** Period of the oscillation, in milliseconds. */\n\tPERIOD_MS: 700,\n\t/** Lower bound of the opacity oscillation. */\n\tMIN_OPACITY: 0.4,\n} as const;\n\n// State -----------------------------------------------------------------------------------\n\nlet capturedPieceThisFrame: Piece | undefined;\n/** Timestamp when the current drag started, used to anchor the pulsation phase to 0. */\nlet dragStartTime: number | undefined;\n\n// Functions -------------------------------------------------------------------------------\n\n/**\n * Update the piece that would be captured if we were to let\n * go of the dragged piece right now and return those coordinates if so.\n *\n * CALL BEFORE shiftArrows()\n */\nfunction updateCapturedPiece(): void {\n\tif (!draganimation.areDraggingPiece())\n\t\tthrow Error('Should not be updating droparrows when not dragging a piece!');\n\n\tcapturedPieceThisFrame = undefined;\n\n\tconst selectedPiece = selection.getPieceSelected()!;\n\tconst selectedPieceLegalMoves = selection.getLegalMovesOfSelectedPiece()!;\n\tconst selectedPieceColor = typeutil.getColorFromType(selectedPiece.type);\n\n\t// Test if the mouse is hovering over any arrow\n\n\tlet hoveredArrows = arrows.getHoveredArrows();\n\n\t// Filter out the selected piece, and floating point arrows (animated ones)\n\n\thoveredArrows = hoveredArrows.filter((arrow) => {\n\t\tif (arrow.piece.floating) return false; // Filter animated arrows\n\t\tconst integerCoords = bdcoords.coordsToBigInt(arrow.piece.coords);\n\t\treturn !coordutil.areCoordsEqual(integerCoords, selectedPiece.coords);\n\t});\n\n\t// For each of the hovered arrows, test if capturing is legal\n\n\tconst legalCaptureHoveredArrows = hoveredArrows.filter((arrow) => {\n\t\treturn legalmoves.checkIfMoveLegal(\n\t\t\tgameslot.getGamefile()!,\n\t\t\tselectedPieceLegalMoves,\n\t\t\tselectedPiece.coords,\n\t\t\tbdcoords.coordsToBigInt(arrow.piece.coords),\n\t\t\tselectedPieceColor,\n\t\t);\n\t});\n\n\tif (legalCaptureHoveredArrows.length === 0) return; // No arrow being hovered over is legal to capture by the dragged piece\n\n\tconst legalCapturePiece = legalCaptureHoveredArrows[0]!.piece;\n\n\t// console.log(JSON.stringify(legalCaptureHoveredArrows));\n\n\tcapturedPieceThisFrame = {\n\t\ttype: legalCapturePiece.type,\n\t\tcoords: bdcoords.coordsToBigInt(legalCapturePiece.coords),\n\t\tindex: legalCapturePiece.index,\n\t};\n}\n\nfunction getCaptureCoords(): Coords | undefined {\n\treturn capturedPieceThisFrame?.coords;\n}\n\n/**\n * Shifts an arrow indicator if we are hovering the dragged piece over a capturable arrow.\n *\n * DO AFTER selection.update(). Because making a move changes the board.\n */\nfunction shiftArrows(): void {\n\tif (!draganimation.areDraggingPiece()) return;\n\n\tconst selectedPiece = selection.getPieceSelected()!;\n\n\t// Modify the arrow indicators to reflect the potentialcapture\n\n\tlet newLocationOfSelectedPiece: Coords | undefined;\n\n\tif (capturedPieceThisFrame !== undefined) {\n\t\t// Reflect the dragged piece's new location in draganimation.ts\n\t\tconst worldCoords = space.convertCoordToWorldSpace(\n\t\t\tbdcoords.FromCoords(capturedPieceThisFrame.coords),\n\t\t);\n\t\tdraganimation.setDragLocationAndHoverSquare(worldCoords, capturedPieceThisFrame.coords);\n\t\t// Delete the captured piece arrow\n\t\tarrowshifts.deleteArrow(capturedPieceThisFrame.coords);\n\t\t// Place the selected piece's arrow location on it\n\t\tnewLocationOfSelectedPiece = capturedPieceThisFrame.coords;\n\t}\n\n\t// Shift the arrow of the selected piece\n\tif (newLocationOfSelectedPiece)\n\t\tarrowshifts.moveArrow(selectedPiece.coords, newLocationOfSelectedPiece);\n\t// Or just delete if there's no new integer destination\n\telse arrowshifts.deleteArrow(selectedPiece.coords);\n}\n\n/**\n * Every frame while dragging, iterates all visible arrow indicators and pulsates\n * the opacity of any that the dragged piece could legally capture.\n */\nfunction updateLegalCaptureArrows(): void {\n\tif (!draganimation.areDraggingPiece()) return;\n\n\tconst gamefile = gameslot.getGamefile()!;\n\n\tconst selectedPiece = selection.getPieceSelected()!;\n\tconst selectedPieceLegalMoves = selection.getLegalMovesOfSelectedPiece()!;\n\tconst selectedPieceColor = typeutil.getColorFromType(selectedPiece.type);\n\n\tif (dragStartTime === undefined) dragStartTime = performance.now();\n\tconst phase =\n\t\t(2 * Math.PI * (performance.now() - dragStartTime)) / LEGAL_CAPTURE_PULSATE.PERIOD_MS;\n\tconst alpha =\n\t\tLEGAL_CAPTURE_PULSATE.MIN_OPACITY +\n\t\t((1 - LEGAL_CAPTURE_PULSATE.MIN_OPACITY) * (Math.cos(phase) + 1)) / 2;\n\n\tlet hasCapturable = false;\n\tfor (const arrow of arrows.getAllArrows()) {\n\t\tif (arrow.piece.floating) continue;\n\t\tconst intCoords = bdcoords.coordsToBigInt(arrow.piece.coords);\n\t\tif (coordutil.areCoordsEqual(intCoords, selectedPiece.coords)) continue;\n\t\t// When a capture is pending, the arrows at the capture destination have been replaced\n\t\t// by the dragged piece's own arrows — skip them so they don't pulsate.\n\t\tif (\n\t\t\tcapturedPieceThisFrame !== undefined &&\n\t\t\tcoordutil.areCoordsEqual(intCoords, capturedPieceThisFrame.coords)\n\t\t)\n\t\t\tcontinue;\n\t\tif (\n\t\t\tlegalmoves.checkIfMoveLegal(\n\t\t\t\tgamefile,\n\t\t\t\tselectedPieceLegalMoves,\n\t\t\t\tselectedPiece.coords,\n\t\t\t\tintCoords,\n\t\t\t\tselectedPieceColor,\n\t\t\t)\n\t\t) {\n\t\t\tarrow.opacity = alpha;\n\t\t\thasCapturable = true;\n\t\t}\n\t}\n\n\tif (hasCapturable) frametracker.onVisualChange();\n}\n\nfunction onDragTermination(): void {\n\tcapturedPieceThisFrame = undefined;\n\tdragStartTime = undefined;\n}\n\nexport default {\n\tupdateCapturedPiece,\n\tgetCaptureCoords,\n\tshiftArrows,\n\tupdateLegalCaptureArrows,\n\tonDragTermination,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/effect_zone/EffectZoneManager.ts",
    "content": "// src/client/scripts/esm/game/rendering/effect_zone/EffectZoneManager.ts\n\nimport boardtiles from '../boardtiles';\nimport ImageLoader from '../../../util/ImageLoader';\nimport preferences from '../../../components/header/preferences';\nimport frametracker from '../frametracker';\nimport TextureLoader from '../../../webgl/TextureLoader';\nimport { OceanZone } from './zones/OceanZone';\nimport { StaticZone } from './zones/StaticZone';\nimport { EchoRiftZone } from './zones/EchoRiftZone';\nimport { ProgramManager } from '../../../webgl/ProgramManager';\nimport { EmberVergeZone } from './zones/EmberVergeZone';\nimport { DustyWastesZone } from './zones/DustyWastesZone';\nimport { PostProcessPass } from '../../../webgl/post_processing/PostProcessingPipeline';\nimport { IridescenceZone } from './zones/IridescenceZone';\nimport { AshfallVocsZone } from './zones/AshfallVocsZone';\nimport { TheBeginningZone } from './zones/TheBeginningZone';\nimport { UndercurrentZone } from './zones/UndercurrentZone';\nimport { SpectralEdgeZone } from './zones/SpectralEdgeZone';\nimport { ContortionFieldZone } from './zones/ContortionFieldZone';\n\n/**\n * Defines a zone in space that applies a specific visual effect to the board.\n */\ninterface EffectZone {\n\t/** A unique name for the zone, for debugging. */\n\treadonly name: string;\n\t/** The closest tile that this zone effect starts at. */\n\treadonly start: bigint;\n\t/**\n\t * Whether this zone uses advanced visual effects. If true, then\n\t * the Advanced Effects settings toggle may disable the zone.\n\t */\n\treadonly advancedEffect?: boolean;\n}\n\n/** Union of all Zone names. */\ntype ZoneName = (typeof EffectZoneManager.ZONES)[number]['name'];\n\n/**\n * A constructed Zone, with methods for updating, obtaining\n * relevant uniforms, and obtaining post-process passes.\n */\nexport interface Zone {\n\t/** The unique integer id this effect zone gets. */\n\treadonly effectType: number;\n\t/** Dynamically updates the zone effect. */\n\treadonly update: () => void;\n\t/** Returns the uniforms needed to send to the gpu. */\n\treadonly getUniforms: () => Record<string, any>;\n\t/** Returns the current post processing pass effects for this zone. */\n\treadonly getPasses: () => PostProcessPass[];\n\t/** Fades in the ambience. */\n\treadonly fadeInAmbience: (_transitionDurationMillis: number) => void;\n\t/** Fades out the ambience, then stops the track playing. */\n\treadonly fadeOutAmbience: (_transitionDurationMillis: number) => void;\n}\n\n/**\n * Manages which visual effect is applied to the board based on distance from the origin,\n * and handles smooth, timed transitions between effect zones.\n */\nexport class EffectZoneManager {\n\t// prettier-ignore\n\tstatic readonly ZONES = [\n\t\t// Define zones in ascending order of their start distance.\n\t\t{ name: 'The Beginning', start: 0n, advancedEffect: false }, // 0\n\t\t// [PRODUCTION] Default distances:\n\t\t{ name: 'Undercurrent',     start: 10n ** 3n, advancedEffect: false }, // 1\n\t\t{ name: 'Contortion Field', start: 10n ** 40n, advancedEffect: true }, // 3\n\t\t{ name: 'Ocean',            start: 10n ** 80n, advancedEffect: true }, // 10\n\t\t{ name: 'Spectral Edge',    start: 10n ** 120n, advancedEffect: true }, // 4\n\t\t{ name: 'Iridescence',      start: 10n ** 180n, advancedEffect: true }, // 5\n\t\t{ name: 'Ember Verge',      start: 10n ** 330n, advancedEffect: true }, // 11\n\t\t{ name: 'Ashfall Vocs',     start: 10n ** 390n, advancedEffect: true }, // 9\n\t\t{ name: 'Dusty Wastes',     start: 10n ** 540n, advancedEffect: true }, // 6\n\t\t{ name: 'Static',           start: 10n ** 690n, advancedEffect: true }, // 7\n\t\t{ name: 'Echo Rift',        start: 10n ** 940n, advancedEffect: true }, // 8\n\t\t// [TESTING] Much shorter distances:\n\t\t// { name: 'Undercurrent',     start: 20n, advancedEffect: false }, // 1\n\t\t// { name: 'Contortion Field', start: 40n, advancedEffect: true }, // 3\n\t\t// { name: 'Ocean',            start: 60n, advancedEffect: true }, // 10\n\t\t// { name: 'Spectral Edge',    start: 80n, advancedEffect: true }, // 4\n\t\t// { name: 'Iridescence',      start: 100n, advancedEffect: true }, // 5\n\t\t// { name: 'Ember Verge',      start: 120n, advancedEffect: true }, // 11\n\t\t// { name: 'Ashfall Vocs',     start: 140n, advancedEffect: true }, // 9\n\t\t// { name: 'Dusty Wastes',     start: 160n, advancedEffect: true }, // 6\n\t\t// { name: 'Static',           start: 180n, advancedEffect: true }, // 7\n\t\t// { name: 'Echo Rift',        start: 200n, advancedEffect: true }, // 8\n\t] as const satisfies Readonly<EffectZone>[];\n\n\t/** A reference to the WebGL rendering context. */\n\tprivate gl: WebGL2RenderingContext;\n\n\t/** The constructed Zones. */\n\tprivate zones: Record<ZoneName, Zone>;\n\n\t/** The perlin noise texture used for cloudy effects. */\n\tprivate perlinNoiseTexture: WebGLTexture | undefined;\n\t/** The white noise texture used for static effects. */\n\tprivate whiteNoiseTexture: WebGLTexture | undefined;\n\n\t// --- Transition State ---\n\n\t/** How long a transition between zones should take, in milliseconds. */\n\tprivate transitionDuration: number = 1500;\n\t/** The timestamp when the current transition started, or null if no transition is happening. */\n\tprivate transitionStartTime: number | null = null;\n\n\t/** The current zone we are in, or transitioning out of. */\n\tprivate currentZone: Zone;\n\t/** The zone we are transitioning into, or null if no transition is happening. */\n\tprivate transitionTargetZone: Zone | null = null;\n\n\t/** 0.0 = fully currentZone, 1.0 = fully targetZone */\n\tprivate transitionProgress: number = 0.0;\n\n\tconstructor(gl: WebGL2RenderingContext, programManager: ProgramManager) {\n\t\tthis.gl = gl;\n\n\t\t// Load perlin noise texture\n\t\tconst noiseTexture: Promise<WebGLTexture> = ImageLoader.loadImage(\n\t\t\t'img/noise_texture/perlin_noise.webp',\n\t\t).then((image) => {\n\t\t\tconst texture = TextureLoader.loadTexture(gl, image);\n\t\t\tthis.perlinNoiseTexture = texture;\n\t\t\treturn texture;\n\t\t});\n\n\t\t// Load white noise texture\n\t\tImageLoader.loadImage('img/noise_texture/white_noise.webp').then((image) => {\n\t\t\t// Ensure texture filtering is set to NEAREST for a sharp, pixelated look\n\t\t\tconst texture = TextureLoader.loadTexture(gl, image, { mipmaps: false });\n\t\t\tthis.whiteNoiseTexture = texture;\n\t\t});\n\n\t\t// Construct Zones\n\t\t// prettier-ignore\n\t\tthis.zones = {\n\t\t\t'The Beginning': new TheBeginningZone(),\n\t\t\t'Undercurrent': new UndercurrentZone(),\n\t\t\t'Contortion Field': new ContortionFieldZone(programManager),\n\t\t\t'Ocean': new OceanZone(programManager),\n\t\t\t'Spectral Edge': new SpectralEdgeZone(),\n\t\t\t'Iridescence': new IridescenceZone(),\n\t\t\t'Ember Verge': new EmberVergeZone(),\n\t\t\t'Ashfall Vocs': new AshfallVocsZone(programManager, noiseTexture),\n\t\t\t'Dusty Wastes': new DustyWastesZone(programManager),\n\t\t\t'Static': new StaticZone(programManager),\n\t\t\t'Echo Rift': new EchoRiftZone(programManager),\n\t\t};\n\n\t\tthis.currentZone = this.zones['The Beginning'];\n\n\t\t// Set up a listener for the ambience-enabled preference changing.\n\t\tdocument.addEventListener('ambience-toggle', (event) => {\n\t\t\t// Turn on/off the ambience of the current zone (and transition target zone, if applicable).\n\t\t\tconst enabled: boolean = event.detail;\n\t\t\tif (!enabled) {\n\t\t\t\t// Fade out any currently playing ambience.\n\t\t\t\tthis.currentZone.fadeOutAmbience(this.transitionDuration);\n\t\t\t\tthis.transitionTargetZone?.fadeOutAmbience(this.transitionDuration);\n\t\t\t} else {\n\t\t\t\t// If we're mid-transition, fade in the target zone's ambience.\n\t\t\t\tif (this.transitionTargetZone)\n\t\t\t\t\tthis.transitionTargetZone.fadeInAmbience(this.transitionDuration);\n\t\t\t\t// Otherwise, fade in the current zone's ambience.\n\t\t\t\telse this.currentZone.fadeInAmbience(this.transitionDuration);\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * Finds the active zone for a given distance from the origin.\n\t */\n\tprivate findZoneForDistance(distance: bigint): Zone {\n\t\tconst advancedEnabled = preferences.getAdvancedEffectsMode();\n\n\t\tlet furthestZone: Zone | undefined;\n\t\t// Iterate through all proceeding zones in reverse to find\n\t\t// the furthest one that starts before our current distance.\n\t\tfor (let i = EffectZoneManager.ZONES.length - 1; i >= 0; i--) {\n\t\t\tconst zone = EffectZoneManager.ZONES[i]!;\n\t\t\tif (!advancedEnabled && zone.advancedEffect) continue; // Skip zones requiring advanced effects if they're disabled\n\t\t\tif (distance >= zone.start) {\n\t\t\t\tfurthestZone = this.zones[zone.name];\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif (!furthestZone) throw new Error(`No effect zones for distance ${distance}`);\n\t\treturn furthestZone;\n\t}\n\n\t/**\n\t * Detects if we should transition to a new zone,\n\t * updates transitionProgress, and updates zone states.\n\t */\n\tpublic update(distanceFromOrigin: bigint): void {\n\t\t// --- 1. UPDATE TRANSITION STATE ---\n\t\tif (this.transitionStartTime !== null && this.transitionTargetZone) {\n\t\t\tconst elapsedTime = Date.now() - this.transitionStartTime;\n\t\t\tif (elapsedTime >= this.transitionDuration) {\n\t\t\t\tthis.currentZone = this.transitionTargetZone;\n\t\t\t\tthis.transitionTargetZone = null;\n\t\t\t\tthis.transitionStartTime = null;\n\t\t\t}\n\t\t}\n\n\t\t// --- 2. DETECT NEW ZONE CROSSINGS ---\n\t\tconst targetZoneForDistance = this.findZoneForDistance(distanceFromOrigin);\n\n\t\tif (\n\t\t\tthis.transitionStartTime === null && // Only start a NEW transition if one isn't already active\n\t\t\ttargetZoneForDistance !== this.currentZone\n\t\t) {\n\t\t\t// A new transition needs to start.\n\t\t\t// console.log('Starting transition to new zone.');\n\t\t\tthis.transitionTargetZone = targetZoneForDistance;\n\t\t\tthis.transitionStartTime = Date.now();\n\t\t\t// Fade out the current zone's ambience and fade in the transitionTargetZone's\n\t\t\tif (preferences.getAmbienceEnabled()) {\n\t\t\t\tthis.currentZone.fadeOutAmbience(this.transitionDuration);\n\t\t\t\tthis.transitionTargetZone.fadeInAmbience(this.transitionDuration);\n\t\t\t}\n\t\t} else if (\n\t\t\tthis.transitionTargetZone && // A transition is active\n\t\t\ttargetZoneForDistance === this.currentZone && // And we've moved back into the 'from' zone's area\n\t\t\tthis.transitionTargetZone !== this.currentZone // And we're not already reversing\n\t\t) {\n\t\t\t// The user has changed their mind and is moving back. We need to reverse the transition.\n\t\t\t// console.log(`Reversing transition. Now going from ${this.transitionTargetZone.name} to ${this.currentZone.name}`);\n\n\t\t\t// 1. The 'from' and 'to' zones are swapped.\n\t\t\tconst oldTarget = this.transitionTargetZone;\n\t\t\tthis.transitionTargetZone = this.currentZone;\n\t\t\tthis.currentZone = oldTarget;\n\n\t\t\t// 2. The timer is reversed.\n\t\t\tconst elapsedTime = Date.now() - this.transitionStartTime!;\n\t\t\tconst remainingTime = this.transitionDuration - elapsedTime;\n\t\t\tthis.transitionStartTime = Date.now() - remainingTime;\n\n\t\t\t// Fade out the current zone's ambience and fade in the transitionTargetZone's\n\t\t\tif (preferences.getAmbienceEnabled()) {\n\t\t\t\tthis.currentZone.fadeOutAmbience(elapsedTime);\n\t\t\t\tthis.transitionTargetZone.fadeInAmbience(elapsedTime);\n\t\t\t}\n\t\t}\n\n\t\t// --- 3. UPDATE TRANSITION PROGRESS OF ACTIVE EFFECTS ---\n\t\t// Recalculate alpha for this frame's render pass.\n\t\tthis.transitionProgress =\n\t\t\tthis.transitionStartTime && this.transitionTargetZone\n\t\t\t\t? Math.min((Date.now() - this.transitionStartTime) / this.transitionDuration, 1.0)\n\t\t\t\t: 0.0;\n\n\t\t// Debugging\n\t\t// console.log(\n\t\t// \t`Current: ${fromZone.name}, `,\n\t\t// \t`Target: ${toZone.name}, `,\n\t\t// \t`Alpha: ${transitionAlpha.toFixed(2)}`\n\t\t// );\n\n\t\t// Update individual zone states\n\t\tthis.currentZone.update();\n\t\tif (this.transitionTargetZone) this.transitionTargetZone.update();\n\n\t\t// Only all for an animation frame if the current zone isn't the origin, or if we're mid-transition.\n\t\t// This ensures cpu usage isn't spiked from Zone Effects when near origin.\n\t\tif (\n\t\t\t(this.currentZone !== this.zones['The Beginning'] &&\n\t\t\t\tthis.currentZone !== this.zones['Undercurrent']) ||\n\t\t\tthis.transitionTargetZone\n\t\t)\n\t\t\tframetracker.onVisualChange();\n\t}\n\n\t/**\n\t * Renders the board tiles with all active Zones effects applied.\n\t */\n\tpublic renderBoard(): void {\n\t\tconst fromZone = this.currentZone;\n\t\tconst toZone = this.transitionTargetZone || this.currentZone;\n\n\t\t// Construct the uniform object for the Uber-Shader\n\n\t\tconst uniforms: Record<string, any> = {\n\t\t\t// Global uniforms\n\t\t\tu_transitionProgress: this.transitionProgress,\n\t\t\tu_resolution: [this.gl.canvas.width, this.gl.canvas.height],\n\t\t\tu_pixelDensity: window.devicePixelRatio,\n\t\t\t// Zone uniforms\n\t\t\tu_effectTypeA: fromZone.effectType,\n\t\t\tu_effectTypeB: toZone.effectType,\n\t\t\t...fromZone.getUniforms(),\n\t\t\t...toZone.getUniforms(),\n\t\t};\n\n\t\t// Render board tiles\n\t\tboardtiles.render(\n\t\t\t{\n\t\t\t\tperlinNoise: this.perlinNoiseTexture,\n\t\t\t\twhiteNoise: this.whiteNoiseTexture,\n\t\t\t},\n\t\t\tuniforms,\n\t\t);\n\t}\n\n\t/**\n\t * Returns an array of all post-process effects that should be active\n\t * this frame, according to the distance we are from the origin,\n\t * with their masterStrength properties set appropriately.\n\t */\n\tpublic getActivePostProcessPasses(): PostProcessPass[] {\n\t\tconst activePasses: PostProcessPass[] = [];\n\n\t\tconst fromZonePasses = this.currentZone.getPasses();\n\t\tfromZonePasses.forEach((pass) => (pass.masterStrength = 1.0 - this.transitionProgress));\n\t\tactivePasses.push(...fromZonePasses);\n\n\t\tif (this.transitionTargetZone) {\n\t\t\tconst toZonePasses = this.transitionTargetZone.getPasses();\n\t\t\ttoZonePasses.forEach((pass) => (pass.masterStrength = this.transitionProgress));\n\t\t\tactivePasses.push(...toZonePasses);\n\t\t}\n\n\t\treturn activePasses;\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/effect_zone/soundscapes/IridescenceSoundscape.ts",
    "content": "// src/client/scripts/esm/game/rendering/effect_zone/soundscapes/IridescenceSoundscape.ts\n\nimport type { LayerConfig } from '../../../../audio/SoundLayer';\n\n/** The first two layers of the Iridescence soundscape (lower pitch). */\nconst layers12: LayerConfig[] = [\n\t{\n\t\tvolume: {\n\t\t\tbase: 0.015,\n\t\t},\n\t\tsource: {\n\t\t\ttype: 'noise',\n\t\t},\n\t\tfilters: [\n\t\t\t{\n\t\t\t\ttype: 'bandpass',\n\t\t\t\tfrequency: {\n\t\t\t\t\tbase: 418,\n\t\t\t\t},\n\t\t\t\tQ: {\n\t\t\t\t\tbase: 29.9901,\n\t\t\t\t},\n\t\t\t\tgain: {\n\t\t\t\t\tbase: 0,\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\ttype: 'lowpass',\n\t\t\t\tfrequency: {\n\t\t\t\t\tbase: 418,\n\t\t\t\t},\n\t\t\t\tQ: {\n\t\t\t\t\tbase: 29.9901,\n\t\t\t\t},\n\t\t\t\tgain: {\n\t\t\t\t\tbase: 0,\n\t\t\t\t},\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\tvolume: {\n\t\t\tbase: 0.12,\n\t\t},\n\t\tsource: {\n\t\t\ttype: 'noise',\n\t\t},\n\t\tfilters: [\n\t\t\t{\n\t\t\t\ttype: 'bandpass',\n\t\t\t\tfrequency: {\n\t\t\t\t\tbase: 631,\n\t\t\t\t},\n\t\t\t\tQ: {\n\t\t\t\t\tbase: 29.9901,\n\t\t\t\t},\n\t\t\t\tgain: {\n\t\t\t\t\tbase: 0,\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\ttype: 'bandpass',\n\t\t\t\tfrequency: {\n\t\t\t\t\tbase: 631,\n\t\t\t\t},\n\t\t\t\tQ: {\n\t\t\t\t\tbase: 29.9901,\n\t\t\t\t},\n\t\t\t\tgain: {\n\t\t\t\t\tbase: 0,\n\t\t\t\t},\n\t\t\t},\n\t\t],\n\t},\n];\n\n/** The third and fourth layers of the Iridescence soundscape (higher pitch). */\nconst layers34: LayerConfig[] = [\n\t{\n\t\tvolume: {\n\t\t\tbase: 0.2,\n\t\t},\n\t\tsource: {\n\t\t\ttype: 'noise',\n\t\t},\n\t\tfilters: [\n\t\t\t{\n\t\t\t\ttype: 'bandpass',\n\t\t\t\tfrequency: {\n\t\t\t\t\tbase: 851,\n\t\t\t\t},\n\t\t\t\tQ: {\n\t\t\t\t\tbase: 29.9901,\n\t\t\t\t},\n\t\t\t\tgain: {\n\t\t\t\t\tbase: 0,\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\ttype: 'bandpass',\n\t\t\t\tfrequency: {\n\t\t\t\t\tbase: 850,\n\t\t\t\t},\n\t\t\t\tQ: {\n\t\t\t\t\tbase: 29.9901,\n\t\t\t\t},\n\t\t\t\tgain: {\n\t\t\t\t\tbase: 0,\n\t\t\t\t},\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\tvolume: {\n\t\t\tbase: 0.02,\n\t\t},\n\t\tsource: {\n\t\t\ttype: 'noise',\n\t\t},\n\t\tfilters: [\n\t\t\t{\n\t\t\t\ttype: 'bandpass',\n\t\t\t\tfrequency: {\n\t\t\t\t\tbase: 1714,\n\t\t\t\t},\n\t\t\t\tQ: {\n\t\t\t\t\tbase: 29.9901,\n\t\t\t\t},\n\t\t\t\tgain: {\n\t\t\t\t\tbase: 0,\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\ttype: 'bandpass',\n\t\t\t\tfrequency: {\n\t\t\t\t\tbase: 1715,\n\t\t\t\t},\n\t\t\t\tQ: {\n\t\t\t\t\tbase: 29.9901,\n\t\t\t\t},\n\t\t\t\tgain: {\n\t\t\t\t\tbase: 0,\n\t\t\t\t},\n\t\t\t},\n\t\t],\n\t},\n];\n\nexport default {\n\tlayers12,\n\tlayers34,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/effect_zone/soundscapes/UndercurrentSoundscape.ts",
    "content": "// src/client/scripts/esm/game/rendering/effect_zone/soundscapes/UndercurrentSoundscape.ts\n\nimport { LayerConfig } from '../../../../audio/SoundLayer';\nimport { SoundscapeConfig } from '../../../../audio/SoundscapePlayer';\n\n/** The source of the Undercurrent soundscape layer is white noise. */\nconst source: LayerConfig['source'] = {\n\ttype: 'noise',\n};\n\n/** The filters of the Undercurrent soundscape layer. */\nconst filters: LayerConfig['filters'] = [\n\t{\n\t\ttype: 'lowpass',\n\t\tfrequency: {\n\t\t\tbase: 136,\n\t\t},\n\t\tQ: {\n\t\t\tbase: 1,\n\t\t},\n\t\tgain: {\n\t\t\tbase: 0,\n\t\t},\n\t},\n\t{\n\t\ttype: 'lowpass',\n\t\tfrequency: {\n\t\t\tbase: 138,\n\t\t},\n\t\tQ: {\n\t\t\tbase: 1,\n\t\t},\n\t\tgain: {\n\t\t\tbase: 0,\n\t\t},\n\t},\n];\n\n/** The complete configuration for the Undercurrent soundscape. */\nconst config: SoundscapeConfig = {\n\tmasterVolume: 0.36,\n\tlayers: [\n\t\t{\n\t\t\tvolume: {\n\t\t\t\tbase: 1,\n\t\t\t},\n\t\t\tsource,\n\t\t\tfilters,\n\t\t},\n\t],\n};\n\nexport default {\n\tsource,\n\tfilters,\n\tconfig,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/effect_zone/zones/AshfallVocsZone.ts",
    "content": "// src/client/scripts/esm/game/rendering/effect_zone/zones/AshfallVocsZone.ts\n\nimport type { Zone } from '../EffectZoneManager';\n\nimport { HeatWavePass } from '../../../../webgl/post_processing/passes/HeatWavePass';\nimport { VignettePass } from '../../../../webgl/post_processing/passes/VignettePass';\nimport { ProgramManager } from '../../../../webgl/ProgramManager';\nimport { ColorGradePass } from '../../../../webgl/post_processing/passes/ColorGradePass';\nimport { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline';\nimport UndercurrentSoundscape from '../soundscapes/UndercurrentSoundscape';\nimport { SoundscapeConfig, SoundscapePlayer } from '../../../../audio/SoundscapePlayer';\n\nexport class AshfallVocsZone implements Zone {\n\t/** The unique integer id this effect zone gets. */\n\treadonly effectType: number = 9;\n\n\tprivate colorGradePass: ColorGradePass;\n\tprivate vignettePass: VignettePass;\n\tprivate heatWavePass: HeatWavePass | undefined = undefined;\n\n\t/** The soundscape player for this zone. */\n\tprivate ambience: SoundscapePlayer;\n\n\t/** The speed of the moving heat waves. */\n\tprivate heatWaveSpeed: number = 2.0;\n\n\tconstructor(programManager: ProgramManager, noise: Promise<WebGLTexture>) {\n\t\tnoise.then((texture) => (this.heatWavePass = new HeatWavePass(programManager, texture)));\n\n\t\tthis.colorGradePass = new ColorGradePass(programManager);\n\t\tthis.colorGradePass.saturation = 2;\n\t\tthis.colorGradePass.contrast = 1.4;\n\t\tthis.colorGradePass.brightness = -0.35;\n\t\tthis.colorGradePass.tint = [1.0, 0.4, 0.4];\n\n\t\tthis.vignettePass = new VignettePass(programManager);\n\t\tthis.vignettePass.radius = 0.3;\n\t\tthis.vignettePass.softness = 0.5;\n\t\tthis.vignettePass.intensity = 0.7;\n\n\t\t// Load the ambience...\n\n\t\tconst noiseConfig: SoundscapeConfig = {\n\t\t\tmasterVolume: 0.36,\n\t\t\tlayers: [\n\t\t\t\t...UndercurrentSoundscape.config.layers,\n\t\t\t\t{\n\t\t\t\t\t// High pitched sizzling\n\t\t\t\t\tvolume: {\n\t\t\t\t\t\tbase: 0.005,\n\t\t\t\t\t\tlfo: {\n\t\t\t\t\t\t\twave: 'perlin',\n\t\t\t\t\t\t\trate: 0.22,\n\t\t\t\t\t\t\tdepth: 0.002,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tsource: {\n\t\t\t\t\t\ttype: 'noise',\n\t\t\t\t\t},\n\t\t\t\t\tfilters: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: 'bandpass',\n\t\t\t\t\t\t\tfrequency: {\n\t\t\t\t\t\t\t\tbase: 10000,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tQ: {\n\t\t\t\t\t\t\t\tbase: 0.9601,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tgain: {\n\t\t\t\t\t\t\t\tbase: 0,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t],\n\t\t};\n\n\t\t// Initialize the player with the config.\n\t\tthis.ambience = new SoundscapePlayer(noiseConfig);\n\t}\n\n\tpublic update(): void {\n\t\tif (this.heatWavePass)\n\t\t\tthis.heatWavePass.time = (performance.now() / 1000) * this.heatWaveSpeed;\n\t}\n\n\tpublic getUniforms(): Record<string, any> {\n\t\treturn {};\n\t}\n\n\tpublic getPasses(): PostProcessPass[] {\n\t\tconst passes: PostProcessPass[] = [this.colorGradePass, this.vignettePass];\n\t\tif (this.heatWavePass) passes.push(this.heatWavePass);\n\t\treturn passes;\n\t}\n\n\tpublic fadeInAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeIn(transitionDurationMillis);\n\t}\n\n\tpublic fadeOutAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeOut(transitionDurationMillis);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/effect_zone/zones/ContortionFieldZone.ts",
    "content": "// src/client/scripts/esm/game/rendering/effect_zone/zones/ContortionFieldZone.ts\n\nimport type { Zone } from '../EffectZoneManager';\n\nimport loadbalancer from '../../../misc/loadbalancer';\nimport { SineWavePass } from '../../../../webgl/post_processing/passes/SineWavePass';\nimport { ProgramManager } from '../../../../webgl/ProgramManager';\nimport { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline';\nimport { SoundscapePlayer } from '../../../../audio/SoundscapePlayer';\nimport UndercurrentSoundscape from '../soundscapes/UndercurrentSoundscape';\n\nexport class ContortionFieldZone implements Zone {\n\t/** The unique integer id this effect zone gets. */\n\treadonly effectType: number = 3;\n\n\t/** Post Processing Effect creating heat waves. */\n\tprivate sineWavePass: SineWavePass;\n\n\t/** The soundscape player for this zone. */\n\tprivate ambience: SoundscapePlayer;\n\n\t/** How fast the sine waves oscillate. */\n\tprivate oscillationSpeed: number = 1.0;\n\n\t/** How fast the sine waves rotates, in degrees per second. */\n\tprivate rotationSpeed: number = 3.0;\n\n\tconstructor(programManager: ProgramManager) {\n\t\tthis.sineWavePass = new SineWavePass(programManager);\n\n\t\t// Load the ambience...\n\n\t\t// Initialize the player with the config.\n\t\tthis.ambience = new SoundscapePlayer(UndercurrentSoundscape.config);\n\t}\n\n\tpublic update(): void {\n\t\tconst deltaTime = loadbalancer.getDeltaTime(); // Seconds\n\n\t\tthis.sineWavePass.time = (performance.now() / 1000) * this.oscillationSpeed;\n\t\tthis.sineWavePass.angle += this.rotationSpeed * deltaTime;\n\t}\n\n\tpublic getUniforms(): Record<string, any> {\n\t\treturn {};\n\t}\n\n\tpublic getPasses(): PostProcessPass[] {\n\t\treturn [this.sineWavePass];\n\t}\n\n\tpublic fadeInAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeIn(transitionDurationMillis);\n\t}\n\n\tpublic fadeOutAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeOut(transitionDurationMillis);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/effect_zone/zones/DustyWastesZone.ts",
    "content": "// src/client/scripts/esm/game/rendering/effect_zone/zones/DustyWastesZone.ts\n\nimport type { Zone } from '../EffectZoneManager';\n\nimport loadbalancer from '../../../misc/loadbalancer';\nimport { GlitchPass } from '../../../../webgl/post_processing/passes/GlitchPass';\nimport { ColorGradePass } from '../../../../webgl/post_processing/passes/ColorGradePass';\nimport { ProgramManager } from '../../../../webgl/ProgramManager';\nimport { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline';\nimport { SoundscapeConfig, SoundscapePlayer } from '../../../../audio/SoundscapePlayer';\n\nexport class DustyWastesZone implements Zone {\n\t/** The unique integer id this effect zone gets. */\n\treadonly effectType: number = 6;\n\n\tprivate colorGradePass: ColorGradePass;\n\tprivate glitchPass: GlitchPass;\n\n\t/** The soundscape player for this zone. */\n\tprivate ambience: SoundscapePlayer;\n\n\t// --- Wind Effect Properties ---\n\t/** The opacity of the wind effect. */\n\tprivate windOpacity: number = 0.4; // Default: 0.35\n\n\t/** How many times the noise texture should tile the screen. */\n\tprivate noiseTiling: number = 1.25;\n\n\t/** The average wind speed. */\n\tprivate windSpeed: number = 0.7;\n\n\t/** How much faster one scroll speed is greater than the other. */\n\tprivate windSpeedsOffset: number = 1.2;\n\n\t/** The vector offset in radians each scroll vector is from each other. */\n\tprivate windDirectionsOffset: number = 0.6;\n\n\t/** The direction the wind is rotating. Clockwise or counter-clockwise. */\n\tprivate windRotationParity: -1 | 1 = Math.random() < 0.5 ? -1 : 1;\n\n\t/** The speed at which the wind direction rotates, in radians per second. */\n\tprivate windRotationSpeed: number = 0.0025;\n\n\t// --- Glitch Effect Properties ---\n\t/** A multiplier for the chromatic aberration strength. */\n\tprivate aberrationStrengthMultiplier: number = 1.3;\n\t/** Minimum amount of trauma to add per glitch burst. */\n\tprivate minTraumaToAdd: number = 0.5;\n\t/** Maximum amount of trauma to add per glitch burst. */\n\tprivate maxTraumaToAdd: number = 1.5;\n\t/** Intensity decreases by this amount per second. */\n\tprivate decayRate: number = 2.0;\n\t/** Minimum seconds between glitch bursts. */\n\tprivate minInterval: number = 1.0;\n\t/** Maximum seconds between glitch bursts. */\n\tprivate maxInterval: number = 7.0;\n\t/** Maximum allowed glitch intensity (\"trauma\") before clamping. */\n\tprivate maxTrauma: number = 2.0;\n\n\t// ============ State ============\n\n\t// --- Wind Animation State ---\n\t/** The wind direction in radians. 0 is to the right. */\n\tprivate windDirection: number = Math.random() * Math.PI * 2;\n\n\t/** The accumulated UV offset for the first noise layer. Wrapped to [0,1]. */\n\tprivate uvOffset1: [number, number] = [0, 0];\n\n\t/** The accumulated UV offset for the second noise layer. Wrapped to [0,1]. */\n\tprivate uvOffset2: [number, number] = [0, 0];\n\n\t// --- Glitch \"Trauma\" Animation State ---\n\t/** Current \"trauma\" level, from 0.0 to 1.0+. */\n\tprivate glitchIntensity: number = 0.0;\n\t/** Countdown timer in seconds until the next glitch burst. */\n\tprivate timeUntilNextGlitch: number = 0.0;\n\n\tconstructor(programManager: ProgramManager) {\n\t\tthis.colorGradePass = new ColorGradePass(programManager);\n\t\tthis.colorGradePass.brightness = -0.2; // Default: 0.7\n\t\tthis.colorGradePass.tint = [1.0, 0.75, 0.7]; // Slight red tint\n\n\t\tthis.glitchPass = new GlitchPass(programManager);\n\n\t\t// Load the ambience...\n\n\t\tconst noiseConfig: SoundscapeConfig = {\n\t\t\tmasterVolume: 0.14,\n\t\t\tlayers: [\n\t\t\t\t{\n\t\t\t\t\tvolume: {\n\t\t\t\t\t\tbase: 1,\n\t\t\t\t\t\tlfo: {\n\t\t\t\t\t\t\twave: 'perlin',\n\t\t\t\t\t\t\trate: 0.76,\n\t\t\t\t\t\t\tdepth: 0.12,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tsource: {\n\t\t\t\t\t\ttype: 'noise',\n\t\t\t\t\t},\n\t\t\t\t\tfilters: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: 'lowpass',\n\t\t\t\t\t\t\tfrequency: {\n\t\t\t\t\t\t\t\tbase: 271,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tQ: {\n\t\t\t\t\t\t\t\tbase: 1.0001,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tgain: {\n\t\t\t\t\t\t\t\tbase: 0,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tvolume: {\n\t\t\t\t\t\tbase: 0.5,\n\t\t\t\t\t},\n\t\t\t\t\tsource: {\n\t\t\t\t\t\ttype: 'noise',\n\t\t\t\t\t},\n\t\t\t\t\tfilters: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: 'bandpass',\n\t\t\t\t\t\t\tfrequency: {\n\t\t\t\t\t\t\t\tbase: 909,\n\t\t\t\t\t\t\t\tlfo: {\n\t\t\t\t\t\t\t\t\twave: 'perlin',\n\t\t\t\t\t\t\t\t\trate: 0.47,\n\t\t\t\t\t\t\t\t\tdepth: 203,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tQ: {\n\t\t\t\t\t\t\t\tbase: 29.9901,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tgain: {\n\t\t\t\t\t\t\t\tbase: 0,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: 'bandpass',\n\t\t\t\t\t\t\tfrequency: {\n\t\t\t\t\t\t\t\tbase: 909,\n\t\t\t\t\t\t\t\tlfo: {\n\t\t\t\t\t\t\t\t\twave: 'perlin',\n\t\t\t\t\t\t\t\t\trate: 0.35,\n\t\t\t\t\t\t\t\t\tdepth: 201,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tQ: {\n\t\t\t\t\t\t\t\tbase: 10.7801,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tgain: {\n\t\t\t\t\t\t\t\tbase: 0,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t],\n\t\t};\n\n\t\t// Initialize the player with the config.\n\t\tthis.ambience = new SoundscapePlayer(noiseConfig);\n\t}\n\n\t/** Responsible for calculating the exact UV offsets of the noise texture layers each frame. */\n\tpublic update(): void {\n\t\tconst deltaTime = loadbalancer.getDeltaTime();\n\n\t\t// --- Wind update logic ---\n\n\t\t// Optional animation of other properties\n\n\t\t// this.windSpeed = math.getSineWaveVariation(Date.now() / 1000, 0, 0.9);\n\t\t// this.windDirectionsOffset = math.getSineWaveVariation(Date.now() / 1000, 0, 2.5);\n\t\t// this.windSpeedsOffset = math.getSineWaveVariation(Date.now() / 1000, 1, 2.0);\n\n\t\t// Animate the wind direction.\n\n\t\tthis.windDirection += this.windRotationSpeed * this.windRotationParity * deltaTime;\n\t\tif (this.windDirection > Math.PI * 2) this.windDirection -= Math.PI * 2;\n\t\telse if (this.windDirection < 0) this.windDirection += Math.PI * 2;\n\n\t\t// Calculate the instantaneous velocity vectors for this frame.\n\t\tconst angle1 = this.windDirection - this.windDirectionsOffset / 2;\n\t\tconst angle2 = this.windDirection + this.windDirectionsOffset / 2;\n\n\t\tconst velocity1 = [Math.cos(angle1) * this.windSpeed, Math.sin(angle1) * this.windSpeed];\n\t\tconst velocity2 = [\n\t\t\tMath.cos(angle2) * this.windSpeed * this.windSpeedsOffset,\n\t\t\tMath.sin(angle2) * this.windSpeed * this.windSpeedsOffset,\n\t\t];\n\n\t\t// 3. Integrate: Add the displacement for this frame (velocity * deltaTime) to the total offset.\n\t\tthis.uvOffset1[0] += (velocity1[0]! * deltaTime) % 1;\n\t\tthis.uvOffset1[1] += (velocity1[1]! * deltaTime) % 1;\n\t\tthis.uvOffset2[0] += (velocity2[0]! * deltaTime) % 1;\n\t\tthis.uvOffset2[1] += (velocity2[1]! * deltaTime) % 1;\n\n\t\t// --- Glitch update logic ---\n\n\t\t// 1. Always decay the current glitch intensity\n\t\tthis.glitchIntensity = Math.max(0, this.glitchIntensity - this.decayRate * deltaTime);\n\n\t\t// 2. Check if it's time to trigger a new glitch burst\n\t\tthis.timeUntilNextGlitch -= deltaTime;\n\t\tif (this.timeUntilNextGlitch <= 0) {\n\t\t\t// Add a random amount of \"trauma\"\n\t\t\tconst traumaToAdd =\n\t\t\t\tthis.minTraumaToAdd + Math.random() * (this.maxTraumaToAdd - this.minTraumaToAdd);\n\t\t\tthis.glitchIntensity = Math.min(this.glitchIntensity + traumaToAdd, this.maxTrauma); // Clamp at maxTrauma\n\n\t\t\tthis.randomizeNextGlitchTimer(); // Reset the timer for the next burst\n\t\t}\n\n\t\t// 3. Apply the current intensity to the shader pass properties\n\t\t// Use powers to make the visual effect more \"bursty\" and less linear\n\t\tconst intensity = this.glitchIntensity * this.glitchIntensity;\n\t\tthis.glitchPass.tearStrength = intensity;\n\t\tthis.glitchPass.aberrationStrength =\n\t\t\tthis.glitchIntensity * this.aberrationStrengthMultiplier;\n\n\t\t// 4. Keep the shader's internal time moving for tear pattern animation\n\t\tthis.glitchPass.time += deltaTime;\n\t}\n\n\t// Copied from GlitchZone for combined effect\n\tprivate randomizeNextGlitchTimer(): void {\n\t\tthis.timeUntilNextGlitch =\n\t\t\tthis.minInterval + Math.random() * (this.maxInterval - this.minInterval);\n\t}\n\n\tpublic getUniforms(): Record<string, any> {\n\t\t// Pass the final accumulated offsets directly to the shader.\n\t\treturn {\n\t\t\tu6_strength: this.windOpacity,\n\t\t\tu6_noiseTiling: this.noiseTiling,\n\t\t\tu6_uvOffset1: this.uvOffset1,\n\t\t\tu6_uvOffset2: this.uvOffset2,\n\t\t};\n\t}\n\n\tpublic getPasses(): PostProcessPass[] {\n\t\treturn [this.colorGradePass, this.glitchPass];\n\t}\n\n\tpublic fadeInAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeIn(transitionDurationMillis);\n\t}\n\n\tpublic fadeOutAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeOut(transitionDurationMillis);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/effect_zone/zones/EchoRiftZone.ts",
    "content": "// src/client/scripts/esm/game/rendering/effect_zone/zones/EchoRiftZone.ts\n\nimport type { Zone } from '../EffectZoneManager';\n\nimport gamesound from '../../../misc/gamesound';\nimport PerlinNoise from '../../../../util/PerlinNoise';\nimport preferences from '../../../../components/header/preferences';\nimport AudioManager from '../../../../audio/AudioManager';\nimport { ProgramManager } from '../../../../webgl/ProgramManager';\nimport { ColorGradePass } from '../../../../webgl/post_processing/passes/ColorGradePass';\nimport { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline';\nimport { VoronoiDistortionPass } from '../../../../webgl/post_processing/passes/VoronoiDistortionPass';\nimport { SoundscapeConfig, SoundscapePlayer } from '../../../../audio/SoundscapePlayer';\n\nexport class EchoRiftZone implements Zone {\n\t/** The unique integer id this effect zone gets. */\n\treadonly effectType: number = 8;\n\n\tprivate colorGradePass: ColorGradePass;\n\n\t/** Post Processing Effect bending light through a crystalline voronoi distortion pattern structure. */\n\tprivate voronoiDistortionPass: VoronoiDistortionPass;\n\n\t/** The soundscape player for this zone. */\n\tprivate ambience: SoundscapePlayer;\n\n\t/** A 1D Perlin noise generator for randomizing color grade properties. */\n\tprivate noiseGenerator: (_t: number) => number;\n\t/** How \"zoomed in\" the Perlin noise is. Higher values = smoother/slower noise. */\n\tprivate noiseZoom: number = 3000;\n\n\t/** The base brightness level around which the brightness will vary. */\n\tprivate baseBrightness: number = -0.39;\n\t/** How much the brightness will vary above and below the base brightness. */\n\tprivate brightnessVariation: number = 0.07;\n\n\t// ============ State ============\n\n\t/** The next timestamp the voronoi distortion pass will update the time value, revealing a different pattern. */\n\tprivate nextCrackTime: number = Date.now();\n\n\tprivate baseMillisBetweenCracks: number = 400;\n\tprivate maxMillisBetweenCracks: number = 4000;\n\n\tconstructor(programManager: ProgramManager) {\n\t\tthis.voronoiDistortionPass = new VoronoiDistortionPass(programManager);\n\n\t\tthis.colorGradePass = new ColorGradePass(programManager);\n\t\tthis.colorGradePass.saturation = 0;\n\t\tthis.colorGradePass.contrast = 0.3;\n\n\t\tthis.noiseGenerator = PerlinNoise.create1DNoiseGenerator(30);\n\n\t\t// Load the ambience...\n\n\t\tconst soundConfig: SoundscapeConfig = {\n\t\t\tmasterVolume: 1.0,\n\t\t\tlayers: [\n\t\t\t\t{\n\t\t\t\t\tvolume: {\n\t\t\t\t\t\tbase: 0.7,\n\t\t\t\t\t\tlfo: {\n\t\t\t\t\t\t\twave: 'perlin',\n\t\t\t\t\t\t\trate: 1.13,\n\t\t\t\t\t\t\tdepth: 0.5,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tsource: {\n\t\t\t\t\t\ttype: 'noise',\n\t\t\t\t\t},\n\t\t\t\t\tfilters: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: 'lowpass',\n\t\t\t\t\t\t\tfrequency: {\n\t\t\t\t\t\t\t\tbase: 136,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tQ: {\n\t\t\t\t\t\t\t\tbase: 1.0001,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tgain: {\n\t\t\t\t\t\t\t\tbase: 0,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: 'lowpass',\n\t\t\t\t\t\t\tfrequency: {\n\t\t\t\t\t\t\t\tbase: 139,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tQ: {\n\t\t\t\t\t\t\t\tbase: 1.0001,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tgain: {\n\t\t\t\t\t\t\t\tbase: 0,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t],\n\t\t};\n\n\t\t// Initialize the player with the config.\n\t\tthis.ambience = new SoundscapePlayer(soundConfig);\n\t}\n\n\tpublic update(): void {\n\t\t// Update cracking of the voronoi distortion effect.\n\n\t\t// voronoiDistortionPass.time = 632663;\n\t\t// voronoiDistortionPass.time = Date.now() / 1000;\n\t\t// voronoiDistortionPass.time = Math.floor(performance.now() / 400) * 10;\n\t\tif (Date.now() > this.nextCrackTime) {\n\t\t\tthis.voronoiDistortionPass.time = performance.now() / 10;\n\t\t\tthis.nextCrackTime =\n\t\t\t\tDate.now() +\n\t\t\t\tthis.baseMillisBetweenCracks +\n\t\t\t\tMath.random() * this.maxMillisBetweenCracks;\n\t\t\tif (preferences.getAmbienceEnabled()) gamesound.playGlassCrack();\n\t\t}\n\n\t\t// Randomize the brightness\n\t\tconst noiseValue = this.noiseGenerator(performance.now() / this.noiseZoom);\n\t\tthis.colorGradePass.brightness =\n\t\t\tthis.baseBrightness + noiseValue * this.brightnessVariation;\n\t}\n\n\tpublic getUniforms(): Record<string, any> {\n\t\treturn {};\n\t}\n\n\tpublic getPasses(): PostProcessPass[] {\n\t\treturn [this.voronoiDistortionPass, this.colorGradePass];\n\t\t// return [this.colorGradePass];\n\t}\n\n\tpublic fadeInAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeIn(transitionDurationMillis);\n\t\tAudioManager.fadeInDownsampler(transitionDurationMillis);\n\t}\n\n\tpublic fadeOutAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeOut(transitionDurationMillis);\n\t\tAudioManager.fadeOutDownsampler(transitionDurationMillis);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/effect_zone/zones/EmberVergeZone.ts",
    "content": "// src/client/scripts/esm/game/rendering/effect_zone/zones/EmberVergeZone.ts\n\nimport type { Zone } from '../EffectZoneManager';\n\nimport loadbalancer from '../../../misc/loadbalancer';\nimport { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline';\nimport { SoundscapePlayer } from '../../../../audio/SoundscapePlayer';\nimport UndercurrentSoundscape from '../soundscapes/UndercurrentSoundscape';\n\nexport class EmberVergeZone implements Zone {\n\t/** The unique integer id this effect zone gets. */\n\treadonly effectType: number = 11;\n\n\t/** The soundscape player for this zone. */\n\tprivate ambience: SoundscapePlayer;\n\n\t// --- Configurable Properties ---\n\n\t// prettier-ignore\n\tprivate readonly colors: [number, number, number][] = [\n\t\t[0.92, 0.82, 0.62], // Faded Gold\n\t\t[0.6, 0.8, 0.6],    // Muted Green\n\t\t[0.5, 0.7, 0.9],    // Muted Blue\n\t\t[0.8, 0.5, 0.8],    // Muted Purple\n\t\t[0.88, 0.22, 0.15], // Molten Orange-Red\n\t\t[0.78, 0.05, 0.05], // Ashfall Core Red\n\t];\n\n\t/** Determines how strongly the gradient colors are blended with the original board tile colors. */\n\tprivate strength: number = 0.5;\n\n\t/** The base speed at which the gradient texture scrolls across the screen. */\n\tprivate flowSpeed: number = 0.07;\n\n\t/** The speed at which the flow direction changes over time, in radians per second. */\n\tprivate flowRotationSpeed: number = 0.0025;\n\n\t/** How many times the full gradient repeats across the screen along the direction of flow. */\n\tprivate gradientRepeat: number = 0.7;\n\n\t/** The phase shift applied to the light tiles' gradient, as a percentage of the gradient's total length. */\n\tprivate maskOffset: number = 0.07;\n\n\t// --- State Properties ---\n\n\t/** The current direction of the flow, in radians. */\n\tprivate flowDirection: number = Math.random() * Math.PI * 2;\n\n\tconstructor() {\n\t\tthis.ambience = new SoundscapePlayer(UndercurrentSoundscape.config);\n\t}\n\n\tpublic update(): void {\n\t\tconst deltaTime = loadbalancer.getDeltaTime(); // In seconds\n\n\t\t// Rotate the flow direction over time.\n\t\tthis.flowDirection += this.flowRotationSpeed * deltaTime;\n\t\tif (this.flowDirection > Math.PI * 2) this.flowDirection -= Math.PI * 2;\n\t\telse if (this.flowDirection < 0) this.flowDirection += Math.PI * 2;\n\t}\n\n\tpublic getUniforms(): Record<string, any> {\n\t\t// Pre-calculate the direction vector\n\t\tconst flowDirectionVec: [number, number] = [\n\t\t\tMath.cos(this.flowDirection),\n\t\t\tMath.sin(this.flowDirection),\n\t\t];\n\n\t\tconst flowDistance = (performance.now() / 1000) * this.flowSpeed;\n\n\t\tconst uniforms: Record<string, any> = {\n\t\t\tu11_flowDistance: flowDistance,\n\t\t\tu11_flowDirectionVec: flowDirectionVec,\n\t\t\tu11_gradientRepeat: this.gradientRepeat,\n\t\t\tu11_maskOffset: this.maskOffset,\n\t\t\tu11_strength: this.strength,\n\t\t};\n\n\t\t// Add each color as a separate uniform.\n\t\tfor (let i = 0; i < this.colors.length; i++) {\n\t\t\t// Use the color if it exists, otherwise pad with black.\n\t\t\tconst color = this.colors[i] || [0, 0, 0];\n\t\t\tuniforms[`u11_color${i + 1}`] = color;\n\t\t}\n\n\t\treturn uniforms;\n\t}\n\n\tpublic getPasses(): PostProcessPass[] {\n\t\treturn [];\n\t}\n\n\tpublic fadeInAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeIn(transitionDurationMillis);\n\t}\n\n\tpublic fadeOutAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeOut(transitionDurationMillis);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/effect_zone/zones/IridescenceZone.ts",
    "content": "// src/client/scripts/esm/game/rendering/effect_zone/zones/IridescenceZone.ts\n\nimport type { Zone } from '../EffectZoneManager';\n\nimport loadbalancer from '../../../misc/loadbalancer';\nimport { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline';\nimport IridescenceSoundscape from '../soundscapes/IridescenceSoundscape';\nimport { SoundscapeConfig, SoundscapePlayer } from '../../../../audio/SoundscapePlayer';\n\nexport class IridescenceZone implements Zone {\n\t/** The unique integer id this effect zone gets. */\n\treadonly effectType: number = 5;\n\n\t/** The soundscape player for this zone. */\n\tprivate ambience: SoundscapePlayer;\n\n\t// --- Configurable Properties ---\n\n\t/** The array of RGB colors that defines the gradient. Passed to the shader. */\n\tprivate readonly colors: [number, number, number][] = [\n\t\t[1.0, 0.5, 0.5], // Soft Red\n\t\t[1.0, 1.0, 0.5], // Soft Yellow\n\t\t[0.5, 1.0, 0.5], // Soft Green\n\t\t[0.5, 1.0, 1.0], // Soft Cyan\n\t\t[0.5, 0.5, 1.0], // Soft Blue\n\t\t[1.0, 0.5, 1.0], // Soft Magenta\n\t];\n\n\t/** Determines how strongly the gradient colors are blended with the original board tile colors. */\n\tprivate strength: number = 1;\n\n\t/** The base speed at which the gradient texture scrolls across the screen. */\n\tprivate flowSpeed: number = 0.07; // Default: 0.07\n\n\t/** The speed at which the flow direction changes over time, in radians per second. */\n\tprivate flowRotationSpeed: number = 0.0025; // Default: 0.0025\n\n\t/** How many times the full gradient repeats across the screen along the direction of flow. */\n\tprivate gradientRepeat: number = 0.7; // Default: 1.2\n\n\t/** The phase shift applied to the light tiles' gradient, as a percentage of the gradient's total length. */\n\tprivate maskOffset: number = 0.06; // Default: 0.06\n\n\t// --- State Properties ---\n\n\t/** The current direction of the flow, in radians. */\n\tprivate flowDirection: number = Math.random() * Math.PI * 2;\n\n\tconstructor() {\n\t\t// Load the ambience...\n\n\t\tconst noiseConfig: SoundscapeConfig = {\n\t\t\tmasterVolume: 0.33,\n\t\t\tlayers: [...IridescenceSoundscape.layers12, ...IridescenceSoundscape.layers34],\n\t\t};\n\n\t\t// Initialize the player with the config.\n\t\tthis.ambience = new SoundscapePlayer(noiseConfig);\n\t}\n\n\tpublic update(): void {\n\t\tconst deltaTime = loadbalancer.getDeltaTime(); // In seconds\n\n\t\t// Rotate the flow direction over time.\n\t\tthis.flowDirection += this.flowRotationSpeed * deltaTime;\n\t\tif (this.flowDirection > Math.PI * 2) this.flowDirection -= Math.PI * 2;\n\t\telse if (this.flowDirection < 0) this.flowDirection += Math.PI * 2;\n\t}\n\n\tpublic getUniforms(): Record<string, any> {\n\t\t// Pre-calculate the direction vector.\n\t\tconst flowDirectionVec: [number, number] = [\n\t\t\tMath.cos(this.flowDirection),\n\t\t\tMath.sin(this.flowDirection),\n\t\t];\n\n\t\tconst flowDistance = (performance.now() / 1000) * this.flowSpeed;\n\n\t\tconst uniforms: Record<string, any> = {\n\t\t\tu5_flowDistance: flowDistance,\n\t\t\tu5_flowDirectionVec: flowDirectionVec,\n\t\t\tu5_gradientRepeat: this.gradientRepeat,\n\t\t\tu5_maskOffset: this.maskOffset,\n\t\t\tu5_strength: this.strength,\n\t\t};\n\n\t\t// Add each color as a separate uniform.\n\t\tfor (let i = 0; i < this.colors.length; i++) {\n\t\t\t// Use the color if it exists, otherwise pad with black.\n\t\t\tconst color = this.colors[i] || [0, 0, 0];\n\t\t\tuniforms[`u5_color${i + 1}`] = color;\n\t\t}\n\n\t\treturn uniforms;\n\t}\n\n\tpublic getPasses(): PostProcessPass[] {\n\t\treturn [];\n\t}\n\n\tpublic fadeInAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeIn(transitionDurationMillis);\n\t}\n\n\tpublic fadeOutAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeOut(transitionDurationMillis);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/effect_zone/zones/OceanZone.ts",
    "content": "// src/client/scripts/esm/game/rendering/effect_zone/zones/OceanZone.ts\n\nimport type { Zone } from '../EffectZoneManager';\n\nimport camera from '../../camera';\nimport loadbalancer from '../../../misc/loadbalancer';\nimport { ProgramManager } from '../../../../webgl/ProgramManager';\nimport { ColorGradePass } from '../../../../webgl/post_processing/passes/ColorGradePass';\nimport { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline';\nimport { SoundscapePlayer } from '../../../../audio/SoundscapePlayer';\nimport UndercurrentSoundscape from '../soundscapes/UndercurrentSoundscape';\nimport { RippleSource, WaterPass } from '../../../../webgl/post_processing/passes/WaterPass';\n\nexport class OceanZone implements Zone {\n\t/** The unique integer id this effect zone gets. */\n\treadonly effectType: number = 10;\n\n\tprivate colorGradePass: ColorGradePass;\n\n\t/** The post-processing pass that renders the water ripple effect from continuous sources. */\n\tprivate waterPass: WaterPass;\n\n\t/** The soundscape player for this zone. */\n\tprivate ambience: SoundscapePlayer;\n\n\t/** The distance from the center of the screen (in world units) to place the ripples. */\n\tprivate readonly RIPPLE_DISTANCE: number = 100;\n\n\t/** The speed at which the circle of ripples rotates, in radians per second. */\n\tprivate readonly ROTATION_SPEED: number = 0.02;\n\n\t// State ---------------------------------------------------\n\n\t/** The state of the three persistent ripple sources. */\n\tprivate readonly sources: RippleSource[];\n\n\t/** The direction the circle rotates. Set randomly on initialization. */\n\tprivate readonly rotationDirection: 1 | -1;\n\n\t/** The current rotation of the entire ripple circle, in radians. */\n\tprivate circleRotationAngle: number = 0;\n\n\tconstructor(programManager: ProgramManager) {\n\t\tthis.colorGradePass = new ColorGradePass(programManager);\n\t\tthis.colorGradePass.saturation = 0.6;\n\t\tthis.colorGradePass.tint = [0.9, 0.95, 1.0]; // Slight blue\n\n\t\t// Initialize the WaterPass with the current canvas dimensions.\n\t\tthis.waterPass = new WaterPass(programManager, camera.canvas.width, camera.canvas.height);\n\n\t\t// Initialize the three permanent ripple sources. Their location will be updated each frame.\n\t\tthis.sources = [{ center: [0, 0] }, { center: [0, 0] }, { center: [0, 0] }];\n\n\t\t// Determine the rotation direction randomly.\n\t\tthis.rotationDirection = Math.random() < 0.5 ? 1 : -1;\n\n\t\tthis.ambience = new SoundscapePlayer(UndercurrentSoundscape.config);\n\n\t\t// Create event listener for screen resize to update water pass resolution.\n\t\tdocument.addEventListener('canvas_resize', (event) => {\n\t\t\tconst { width, height } = event.detail;\n\t\t\tthis.waterPass.setResolution(width, height);\n\t\t});\n\t}\n\n\tpublic update(): void {\n\t\tconst deltaTime = loadbalancer.getDeltaTime(); // Time in seconds since last frame.\n\n\t\t// --- 1. Animate the rotation of the ripple circle ---\n\t\tthis.circleRotationAngle += this.ROTATION_SPEED * this.rotationDirection * deltaTime;\n\n\t\t// --- 2. Define the base ripple locations on the circle ---\n\t\t// prettier-ignore\n\t\tconst baseAngles = [\n\t\t\t0,                                      // 0 degrees\n\t\t\t40 * (Math.PI / 180),                   // 40 degrees in radians\n\t\t\t(40 + 80) * (Math.PI / 180),            // 120 degrees in radians\n\t\t];\n\n\t\t// Calculate the final world positions by applying the current circle rotation.\n\t\tconst worldPositions = baseAngles.map((angle) => ({\n\t\t\tx: Math.cos(angle + this.circleRotationAngle) * this.RIPPLE_DISTANCE,\n\t\t\ty: Math.sin(angle + this.circleRotationAngle) * this.RIPPLE_DISTANCE,\n\t\t}));\n\n\t\t// --- 3. Convert world space coordinates to screen UVs [0-1] ---\n\t\tconst screenBox = camera.getScreenBoundingBox(false);\n\t\tconst screenWidthWorld = screenBox.right - screenBox.left;\n\t\tconst screenHeightWorld = screenBox.top - screenBox.bottom;\n\n\t\t// Calculate the final UV for each ripple and update its source's center.\n\t\tfor (let i = 0; i < worldPositions.length; i++) {\n\t\t\tconst pos = worldPositions[i]!;\n\t\t\tconst source = this.sources[i]!;\n\n\t\t\tconst u = (pos.x - screenBox.left) / screenWidthWorld;\n\t\t\tconst v = (pos.y - screenBox.bottom) / screenHeightWorld;\n\n\t\t\tsource.center = [u, v];\n\t\t}\n\n\t\t// --- 4. Feed the updated ripple source locations to the pass ---\n\t\tthis.waterPass.updateSources(this.sources);\n\t\tthis.waterPass.time = performance.now();\n\t}\n\n\tpublic getUniforms(): Record<string, any> {\n\t\t// This zone's visual effect is purely from a post-processing pass,\n\t\t// so it does not need to send any uniforms to the main board shader.\n\t\treturn {};\n\t}\n\n\tpublic getPasses(): PostProcessPass[] {\n\t\t// Return the water pass to be rendered by the pipeline.\n\t\treturn [this.colorGradePass, this.waterPass];\n\t}\n\n\tpublic fadeInAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeIn(transitionDurationMillis);\n\t}\n\n\tpublic fadeOutAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeOut(transitionDurationMillis);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/effect_zone/zones/SpectralEdgeZone.ts",
    "content": "// src/client/scripts/esm/game/rendering/effect_zone/zones/SpectralEdgeZone.ts\n\nimport type { Zone } from '../EffectZoneManager';\n\nimport loadbalancer from '../../../misc/loadbalancer';\nimport { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline';\nimport IridescenceSoundscape from '../soundscapes/IridescenceSoundscape';\nimport UndercurrentSoundscape from '../soundscapes/UndercurrentSoundscape';\nimport { SoundscapeConfig, SoundscapePlayer } from '../../../../audio/SoundscapePlayer';\n\nexport class SpectralEdgeZone implements Zone {\n\t/** The unique integer id this effect zone gets. */\n\treadonly effectType: number = 4;\n\n\t/** The soundscape player for this zone. */\n\tprivate ambience: SoundscapePlayer;\n\n\t// --- Configurable Properties ---\n\n\t/** The array of RGB colors that defines the gradient. Passed to the shader. */\n\tprivate readonly colors: [number, number, number][] = [\n\t\t[1.0, 0.5, 0.5], // Soft Red\n\t\t[1.0, 1.0, 0.5], // Soft Yellow\n\t\t[0.5, 1.0, 0.5], // Soft Green\n\t\t[0.5, 1.0, 1.0], // Soft Cyan\n\t\t[0.5, 0.5, 1.0], // Soft Blue\n\t\t[1.0, 0.5, 1.0], // Soft Magenta\n\t];\n\n\t/** Determines how strongly the gradient colors are blended with the original board tile colors. */\n\tprivate strength: number = 0.3;\n\n\t/** The base speed at which the gradient texture scrolls across the screen. */\n\tprivate flowSpeed: number = 0.07; // Default: 0.07\n\n\t/** The speed at which the flow direction changes over time, in radians per second. */\n\tprivate flowRotationSpeed: number = 0.0025; // Default: 0.0025\n\n\t/** How many times the full gradient repeats across the screen along the direction of flow. */\n\tprivate gradientRepeat: number = 0.7; // Default: 1.2\n\n\t/** The phase shift applied to the light tiles' gradient, as a percentage of the gradient's total length. */\n\tprivate maskOffset: number = 0.07; // Default: 0.06\n\n\t// --- State Properties ---\n\n\t/** The current direction of the flow, in radians. */\n\tprivate flowDirection: number = Math.random() * Math.PI * 2;\n\n\tconstructor() {\n\t\t// Load the ambience...\n\n\t\tconst noiseConfig: SoundscapeConfig = {\n\t\t\tmasterVolume: 0.25,\n\t\t\tlayers: [\n\t\t\t\t// Undercurrent layer\n\t\t\t\t{\n\t\t\t\t\t// Custom volume\n\t\t\t\t\tvolume: {\n\t\t\t\t\t\tbase: 0.8,\n\t\t\t\t\t},\n\t\t\t\t\tsource: UndercurrentSoundscape.source,\n\t\t\t\t\tfilters: UndercurrentSoundscape.filters,\n\t\t\t\t},\n\t\t\t\t// Partial of Iridescence layers\n\t\t\t\t...IridescenceSoundscape.layers12,\n\t\t\t],\n\t\t};\n\n\t\t// Initialize the player with the config.\n\t\tthis.ambience = new SoundscapePlayer(noiseConfig);\n\t}\n\n\tpublic update(): void {\n\t\tconst deltaTime = loadbalancer.getDeltaTime(); // In seconds\n\n\t\t// Rotate the flow direction over time.\n\t\tthis.flowDirection += this.flowRotationSpeed * deltaTime;\n\t\tif (this.flowDirection > Math.PI * 2) this.flowDirection -= Math.PI * 2;\n\t\telse if (this.flowDirection < 0) this.flowDirection += Math.PI * 2;\n\t}\n\n\tpublic getUniforms(): Record<string, any> {\n\t\t// Pre-calculate the direction vector\n\t\tconst flowDirectionVec: [number, number] = [\n\t\t\tMath.cos(this.flowDirection),\n\t\t\tMath.sin(this.flowDirection),\n\t\t];\n\n\t\tconst flowDistance = (performance.now() / 1000) * this.flowSpeed;\n\n\t\tconst uniforms: Record<string, any> = {\n\t\t\tu4_flowDistance: flowDistance,\n\t\t\tu4_flowDirectionVec: flowDirectionVec,\n\t\t\tu4_gradientRepeat: this.gradientRepeat,\n\t\t\tu4_maskOffset: this.maskOffset,\n\t\t\tu4_strength: this.strength,\n\t\t};\n\n\t\t// Add each color as a separate uniform.\n\t\tfor (let i = 0; i < this.colors.length; i++) {\n\t\t\t// Use the color if it exists, otherwise pad with black.\n\t\t\tconst color = this.colors[i] || [0, 0, 0];\n\t\t\tuniforms[`u4_color${i + 1}`] = color;\n\t\t}\n\n\t\treturn uniforms;\n\t}\n\n\tpublic getPasses(): PostProcessPass[] {\n\t\treturn [];\n\t}\n\n\tpublic fadeInAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeIn(transitionDurationMillis);\n\t}\n\n\tpublic fadeOutAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeOut(transitionDurationMillis);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/effect_zone/zones/StaticZone.ts",
    "content": "// src/client/scripts/esm/game/rendering/effect_zone/zones/StaticZone.ts\n\nimport type { Zone } from '../EffectZoneManager';\n\nimport AudioManager from '../../../../audio/AudioManager';\nimport { ProgramManager } from '../../../../webgl/ProgramManager';\nimport { ColorGradePass } from '../../../../webgl/post_processing/passes/ColorGradePass';\nimport { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline';\nimport { SoundscapeConfig, SoundscapePlayer } from '../../../../audio/SoundscapePlayer';\n\nexport class StaticZone implements Zone {\n\t/** The unique integer id this effect zone gets. */\n\treadonly effectType: number = 7;\n\n\tprivate colorGradePass: ColorGradePass;\n\n\t/** The soundscape player for this zone. */\n\tprivate ambience: SoundscapePlayer;\n\n\t/** How many pixels wide the white noise texture is. */\n\tprivate readonly TEXTURE_WIDTH = 256;\n\n\t/** The strength of the effect. */\n\tprivate strength: number = 0.05;\n\t/** How large each \"pixel\" of the static should be, in screen pixels. */\n\tprivate readonly PIXEL_SIZE = 6;\n\t/** How often the static pattern should change, in milliseconds. */\n\tprivate readonly UPDATE_INTERVAL = 60;\n\t// private readonly UPDATE_INTERVAL = 1000; // For testing\n\n\t// --- STATE ---\n\n\t/** The last timestamp the pixels were randomized. */\n\tprivate lastUpdateTime: number = 0;\n\t/** The current UV offset. */\n\tprivate uvOffset: [number, number] = [0, 0];\n\n\tconstructor(programManager: ProgramManager) {\n\t\tthis.colorGradePass = new ColorGradePass(programManager);\n\t\tthis.colorGradePass.saturation = 0.35; // Default: 0.5\n\t\tthis.colorGradePass.brightness = -0.2; // Default: -0.15\n\n\t\t// Load the ambience...\n\n\t\tconst noiseConfig: SoundscapeConfig = {\n\t\t\tmasterVolume: 0.016,\n\t\t\tlayers: [\n\t\t\t\t{\n\t\t\t\t\tvolume: {\n\t\t\t\t\t\tbase: 1,\n\t\t\t\t\t},\n\t\t\t\t\tsource: {\n\t\t\t\t\t\ttype: 'noise',\n\t\t\t\t\t},\n\t\t\t\t\tfilters: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttype: 'highpass',\n\t\t\t\t\t\t\tfrequency: {\n\t\t\t\t\t\t\t\tbase: 900,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tQ: {\n\t\t\t\t\t\t\t\tbase: 1,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tgain: {\n\t\t\t\t\t\t\t\tbase: 0,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t],\n\t\t};\n\n\t\t// Initialize the player with the config.\n\t\tthis.ambience = new SoundscapePlayer(noiseConfig);\n\t}\n\n\tpublic update(): void {\n\t\t// Randomize the pixels every little bit.\n\t\tconst now = Date.now();\n\t\tif (now - this.lastUpdateTime > this.UPDATE_INTERVAL) {\n\t\t\tthis.lastUpdateTime = now;\n\t\t\t// Generate a random offset, but snap it to the pixel grid.\n\t\t\tthis.uvOffset = [\n\t\t\t\tMath.floor(Math.random() * this.TEXTURE_WIDTH) / this.TEXTURE_WIDTH,\n\t\t\t\tMath.floor(Math.random() * this.TEXTURE_WIDTH) / this.TEXTURE_WIDTH,\n\t\t\t];\n\t\t}\n\t}\n\n\tpublic getUniforms(): Record<string, any> {\n\t\treturn {\n\t\t\tu7_strength: this.strength,\n\t\t\tu7_uvOffset: this.uvOffset,\n\t\t\tu7_pixelWidth: this.TEXTURE_WIDTH,\n\t\t\tu7_pixelSize: this.PIXEL_SIZE,\n\t\t};\n\t}\n\n\tpublic getPasses(): PostProcessPass[] {\n\t\treturn [this.colorGradePass];\n\t}\n\n\tpublic fadeInAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeIn(transitionDurationMillis);\n\t\tAudioManager.fadeInDownsampler(transitionDurationMillis);\n\t}\n\n\tpublic fadeOutAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeOut(transitionDurationMillis);\n\t\tAudioManager.fadeOutDownsampler(transitionDurationMillis);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/effect_zone/zones/TheBeginningZone.ts",
    "content": "// src/client/scripts/esm/game/rendering/effect_zone/zones/TheBeginningZone.ts\n\nimport { Zone } from '../EffectZoneManager';\nimport { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline';\n\nexport class TheBeginningZone implements Zone {\n\t/** The unique integer id this effect zone gets. */\n\treadonly effectType: number = 0;\n\n\tpublic update(): void {\n\t\t// No dynamic state to update for a pass-through zone.\n\t}\n\n\tpublic getUniforms(): Record<string, any> {\n\t\treturn {};\n\t}\n\n\tpublic getPasses(): PostProcessPass[] {\n\t\treturn [];\n\t}\n\n\tpublic fadeInAmbience(_transitionDurationMillis: number): void {}\n\n\tpublic fadeOutAmbience(_transitionDurationMillis: number): void {}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/effect_zone/zones/UndercurrentZone.ts",
    "content": "// src/client/scripts/esm/game/rendering/effect_zone/zones/UndercurrentZone.ts\n\n/**\n * This is the 1st zone you encounter moving away from the origin.\n *\n * It has NO visual effect, but it does introduce the first ambience.\n */\n\nimport type { Zone } from '../EffectZoneManager';\n\nimport { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline';\nimport { SoundscapePlayer } from '../../../../audio/SoundscapePlayer';\nimport UndercurrentSoundscape from '../soundscapes/UndercurrentSoundscape';\n\nexport class UndercurrentZone implements Zone {\n\t/** The unique integer id this effect zone gets. */\n\treadonly effectType: number = 1;\n\n\t/** The soundscape player for this zone. */\n\tprivate ambience: SoundscapePlayer;\n\n\tconstructor() {\n\t\t// Load the ambience...\n\n\t\t// Initialize the player with the config.\n\t\tthis.ambience = new SoundscapePlayer(UndercurrentSoundscape.config);\n\t}\n\n\tpublic update(): void {\n\t\t// No dynamic state to update for a pass-through zone.\n\t}\n\n\tpublic getUniforms(): Record<string, any> {\n\t\treturn {};\n\t}\n\n\tpublic getPasses(): PostProcessPass[] {\n\t\treturn [];\n\t}\n\n\tpublic fadeInAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeIn(transitionDurationMillis);\n\t}\n\n\tpublic fadeOutAmbience(transitionDurationMillis: number): void {\n\t\tthis.ambience.fadeOut(transitionDurationMillis);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/frameratelimiter.ts",
    "content": "// src/client/scripts/esm/game/rendering/frameratelimiter.ts\n\n/**\n * This module manages the framerate of the game.\n *\n * When on the title screen, the framerate (frequency of requestAnimationFrame calls)\n * is limited to 30fps to save GPU resources.\n */\n\nimport gameloader from '../chess/gameloader.js';\n\n// Variables -------------------------------------------------\n\n/**\n * Target framerate when not in a game.\n *\n * I cannot actually tell a difference between 30fps and 240fps there.\n */\nconst TARGET_FPS_TITLE_SCREEN = 30;\n\n// State -----------------------------------------------------\n\n/** Timestamp of the last frame that was actually rendered */\nlet lastFrameTime = 0;\n\n/**\n * Set to true when we hear the canvas_resize event. We should bypass fps throttling and render the next frame immediately.\n *\n * Patches bug where resizing the window on the title screen (where fps is throttled) causes\n * rapid black flickering when the canvas is black, but we're waiting to render the next frame.\n */\nlet canvasResized: boolean = false;\n\ndocument.addEventListener('canvas_resize', () => (canvasResized = true));\n\n// Functions -------------------------------------------------\n\n/**\n * Request an animation frame, with throttling applied when on the title screen.\n * This is a wrapper for calls to requestAnimationFrame().\n * @param callback - The callback function to execute on the next frame\n */\nfunction requestFrame(callback: FrameRequestCallback): void {\n\t// Not in a game (title screen), throttle.\n\tconst throttledCallback = (timestamp: number): void => {\n\t\t// If we're in a game, or canvas was resized, run at full speed.\n\t\tif (gameloader.areInAGame() || canvasResized) {\n\t\t\tcanvasResized = false;\n\t\t\tlastFrameTime = timestamp;\n\t\t\tcallback(timestamp);\n\t\t\treturn;\n\t\t}\n\n\t\t// On the very first frame, or after a long pause (e.g. tab was inactive),\n\t\t// reset the timer to the current time.\n\t\tif (lastFrameTime === 0 || timestamp - lastFrameTime > 200) {\n\t\t\tlastFrameTime = timestamp;\n\t\t}\n\n\t\t// Calculate time elapsed since the last scheduled frame\n\t\tconst elapsed = timestamp - lastFrameTime;\n\n\t\t// If enough time has passed, execute the callback\n\t\tconst millisPerFrame = 1000 / TARGET_FPS_TITLE_SCREEN;\n\t\tif (elapsed >= millisPerFrame) {\n\t\t\t// Instead of resetting lastFrameTime to the current 'timestamp',\n\t\t\t// we advance it by a fixed interval. This creates a steady \"tick\"\n\t\t\t// that is not affected by the monitor's specific refresh rate, fixing frame-skipping.\n\t\t\tlastFrameTime += millisPerFrame;\n\n\t\t\tcallback(timestamp);\n\t\t} else {\n\t\t\t// Not enough time has passed - schedule another check directly with requestAnimationFrame\n\t\t\trequestAnimationFrame(throttledCallback);\n\t\t}\n\t};\n\n\trequestAnimationFrame(throttledCallback);\n}\n\n// Exports --------------------------------------------------\n\nexport default {\n\trequestFrame,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/frametracker.ts",
    "content": "// src/client/scripts/esm/game/rendering/frametracker.ts\n\n/**\n * This script stores an internal variable that keeps track of whether\n * anything visual has changed on-screen in the game this frame.\n * If nothing has, we can save compute by skipping rendering.\n *\n * ZERO dependancies.\n */\n\n/** Whether there has been a visual change on-screen the past frame. */\nlet hasBeenVisualChange: boolean = true;\n\n/** The next frame will be rendered. Compute can be saved if nothing has visibly changed on-screen. */\nfunction onVisualChange(): void {\n\t// console.error(\"onVisualChange()\");\n\thasBeenVisualChange = true;\n}\n\n/** true if there has been a visual change on-screen since last frame. */\nfunction doWeRenderNextFrame(): boolean {\n\treturn hasBeenVisualChange;\n}\n\n/**\n * Resets {@link hasBeenVisualChange} to false, to prepare for next frame.\n * Call right after we finish a render frame.\n */\nfunction onFrameRender(): void {\n\thasBeenVisualChange = false;\n}\n\nexport default {\n\tonVisualChange,\n\tdoWeRenderNextFrame,\n\tonFrameRender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/gl-matrix.js",
    "content": "// src/client/scripts/esm/game/rendering/gl-matrix.js\n\n/* eslint-disable */\n/*!\n@fileoverview gl-matrix - High performance matrix and vector operations\n@author Brandon Jones\n@author Colin MacKenzie IV\n@version 3.4.0\n\nCopyright (c) 2015-2021, Brandon Jones, Colin MacKenzie IV.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n*/\n\n// This file has been trimmed for use for Infinite Chess, full file can be found here:\n// https://www.lcg.ufrj.br/WebGL/hws.edu-examples/doc-bump/gl-matrix.js.html\n\n'use strict';\n\n/**\n * Common utilities\n * @module glMatrix\n */\n// Configuration Constants\nvar EPSILON = 0.000001;\nvar ARRAY_TYPE = typeof Float32Array !== 'undefined' ? Float32Array : Array;\n/**\n * 4x4 Matrix<br>Format: column-major, when typed out it looks like row-major<br>The matrices are being post multiplied.\n * @module mat4\n */\n\n/**\n * Creates a new identity mat4\n *\n * @returns {mat4} a new 4x4 matrix\n */\n\nfunction create$5() {\n\tvar out = new ARRAY_TYPE(16);\n\n\tif (ARRAY_TYPE != Float32Array) {\n\t\tout[1] = 0;\n\t\tout[2] = 0;\n\t\tout[3] = 0;\n\t\tout[4] = 0;\n\t\tout[6] = 0;\n\t\tout[7] = 0;\n\t\tout[8] = 0;\n\t\tout[9] = 0;\n\t\tout[11] = 0;\n\t\tout[12] = 0;\n\t\tout[13] = 0;\n\t\tout[14] = 0;\n\t}\n\n\tout[0] = 1;\n\tout[5] = 1;\n\tout[10] = 1;\n\tout[15] = 1;\n\treturn out;\n}\n/**\n * Creates a new mat4 initialized with values from an existing matrix\n *\n * @param {ReadonlyMat4} a matrix to clone\n * @returns {mat4} a new 4x4 matrix\n */\n\nfunction clone$5(a) {\n\tvar out = new ARRAY_TYPE(16);\n\tout[0] = a[0];\n\tout[1] = a[1];\n\tout[2] = a[2];\n\tout[3] = a[3];\n\tout[4] = a[4];\n\tout[5] = a[5];\n\tout[6] = a[6];\n\tout[7] = a[7];\n\tout[8] = a[8];\n\tout[9] = a[9];\n\tout[10] = a[10];\n\tout[11] = a[11];\n\tout[12] = a[12];\n\tout[13] = a[13];\n\tout[14] = a[14];\n\tout[15] = a[15];\n\treturn out;\n}\n/**\n * Copy the values from one mat4 to another\n *\n * @param {mat4} out the receiving matrix\n * @param {ReadonlyMat4} a the source matrix\n * @returns {mat4} out\n */\n\nfunction copy$5(out, a) {\n\tout[0] = a[0];\n\tout[1] = a[1];\n\tout[2] = a[2];\n\tout[3] = a[3];\n\tout[4] = a[4];\n\tout[5] = a[5];\n\tout[6] = a[6];\n\tout[7] = a[7];\n\tout[8] = a[8];\n\tout[9] = a[9];\n\tout[10] = a[10];\n\tout[11] = a[11];\n\tout[12] = a[12];\n\tout[13] = a[13];\n\tout[14] = a[14];\n\tout[15] = a[15];\n\treturn out;\n}\n/**\n * Create a new mat4 with the given values\n *\n * @param {Number} m00 Component in column 0, row 0 position (index 0)\n * @param {Number} m01 Component in column 0, row 1 position (index 1)\n * @param {Number} m02 Component in column 0, row 2 position (index 2)\n * @param {Number} m03 Component in column 0, row 3 position (index 3)\n * @param {Number} m10 Component in column 1, row 0 position (index 4)\n * @param {Number} m11 Component in column 1, row 1 position (index 5)\n * @param {Number} m12 Component in column 1, row 2 position (index 6)\n * @param {Number} m13 Component in column 1, row 3 position (index 7)\n * @param {Number} m20 Component in column 2, row 0 position (index 8)\n * @param {Number} m21 Component in column 2, row 1 position (index 9)\n * @param {Number} m22 Component in column 2, row 2 position (index 10)\n * @param {Number} m23 Component in column 2, row 3 position (index 11)\n * @param {Number} m30 Component in column 3, row 0 position (index 12)\n * @param {Number} m31 Component in column 3, row 1 position (index 13)\n * @param {Number} m32 Component in column 3, row 2 position (index 14)\n * @param {Number} m33 Component in column 3, row 3 position (index 15)\n * @returns {mat4} A new mat4\n */\n\nfunction fromValues$5(\n\tm00,\n\tm01,\n\tm02,\n\tm03,\n\tm10,\n\tm11,\n\tm12,\n\tm13,\n\tm20,\n\tm21,\n\tm22,\n\tm23,\n\tm30,\n\tm31,\n\tm32,\n\tm33,\n) {\n\tvar out = new ARRAY_TYPE(16);\n\tout[0] = m00;\n\tout[1] = m01;\n\tout[2] = m02;\n\tout[3] = m03;\n\tout[4] = m10;\n\tout[5] = m11;\n\tout[6] = m12;\n\tout[7] = m13;\n\tout[8] = m20;\n\tout[9] = m21;\n\tout[10] = m22;\n\tout[11] = m23;\n\tout[12] = m30;\n\tout[13] = m31;\n\tout[14] = m32;\n\tout[15] = m33;\n\treturn out;\n}\n/**\n * Set the components of a mat4 to the given values\n *\n * @param {mat4} out the receiving matrix\n * @param {Number} m00 Component in column 0, row 0 position (index 0)\n * @param {Number} m01 Component in column 0, row 1 position (index 1)\n * @param {Number} m02 Component in column 0, row 2 position (index 2)\n * @param {Number} m03 Component in column 0, row 3 position (index 3)\n * @param {Number} m10 Component in column 1, row 0 position (index 4)\n * @param {Number} m11 Component in column 1, row 1 position (index 5)\n * @param {Number} m12 Component in column 1, row 2 position (index 6)\n * @param {Number} m13 Component in column 1, row 3 position (index 7)\n * @param {Number} m20 Component in column 2, row 0 position (index 8)\n * @param {Number} m21 Component in column 2, row 1 position (index 9)\n * @param {Number} m22 Component in column 2, row 2 position (index 10)\n * @param {Number} m23 Component in column 2, row 3 position (index 11)\n * @param {Number} m30 Component in column 3, row 0 position (index 12)\n * @param {Number} m31 Component in column 3, row 1 position (index 13)\n * @param {Number} m32 Component in column 3, row 2 position (index 14)\n * @param {Number} m33 Component in column 3, row 3 position (index 15)\n * @returns {mat4} out\n */\n\nfunction set$5(\n\tout,\n\tm00,\n\tm01,\n\tm02,\n\tm03,\n\tm10,\n\tm11,\n\tm12,\n\tm13,\n\tm20,\n\tm21,\n\tm22,\n\tm23,\n\tm30,\n\tm31,\n\tm32,\n\tm33,\n) {\n\tout[0] = m00;\n\tout[1] = m01;\n\tout[2] = m02;\n\tout[3] = m03;\n\tout[4] = m10;\n\tout[5] = m11;\n\tout[6] = m12;\n\tout[7] = m13;\n\tout[8] = m20;\n\tout[9] = m21;\n\tout[10] = m22;\n\tout[11] = m23;\n\tout[12] = m30;\n\tout[13] = m31;\n\tout[14] = m32;\n\tout[15] = m33;\n\treturn out;\n}\n/**\n * Set a mat4 to the identity matrix\n *\n * @param {mat4} out the receiving matrix\n * @returns {mat4} out\n */\n\nfunction identity$2(out) {\n\tout[0] = 1;\n\tout[1] = 0;\n\tout[2] = 0;\n\tout[3] = 0;\n\tout[4] = 0;\n\tout[5] = 1;\n\tout[6] = 0;\n\tout[7] = 0;\n\tout[8] = 0;\n\tout[9] = 0;\n\tout[10] = 1;\n\tout[11] = 0;\n\tout[12] = 0;\n\tout[13] = 0;\n\tout[14] = 0;\n\tout[15] = 1;\n\treturn out;\n}\n/**\n * Transpose the values of a mat4\n *\n * @param {mat4} out the receiving matrix\n * @param {ReadonlyMat4} a the source matrix\n * @returns {mat4} out\n */\n\nfunction transpose(out, a) {\n\t// If we are transposing ourselves we can skip a few steps but have to cache some values\n\tif (out === a) {\n\t\tvar a01 = a[1],\n\t\t\ta02 = a[2],\n\t\t\ta03 = a[3];\n\t\tvar a12 = a[6],\n\t\t\ta13 = a[7];\n\t\tvar a23 = a[11];\n\t\tout[1] = a[4];\n\t\tout[2] = a[8];\n\t\tout[3] = a[12];\n\t\tout[4] = a01;\n\t\tout[6] = a[9];\n\t\tout[7] = a[13];\n\t\tout[8] = a02;\n\t\tout[9] = a12;\n\t\tout[11] = a[14];\n\t\tout[12] = a03;\n\t\tout[13] = a13;\n\t\tout[14] = a23;\n\t} else {\n\t\tout[0] = a[0];\n\t\tout[1] = a[4];\n\t\tout[2] = a[8];\n\t\tout[3] = a[12];\n\t\tout[4] = a[1];\n\t\tout[5] = a[5];\n\t\tout[6] = a[9];\n\t\tout[7] = a[13];\n\t\tout[8] = a[2];\n\t\tout[9] = a[6];\n\t\tout[10] = a[10];\n\t\tout[11] = a[14];\n\t\tout[12] = a[3];\n\t\tout[13] = a[7];\n\t\tout[14] = a[11];\n\t\tout[15] = a[15];\n\t}\n\n\treturn out;\n}\n/**\n * Inverts a mat4\n *\n * @param {mat4} out the receiving matrix\n * @param {ReadonlyMat4} a the source matrix\n * @returns {mat4} out\n */\n\nfunction invert$2(out, a) {\n\tvar a00 = a[0],\n\t\ta01 = a[1],\n\t\ta02 = a[2],\n\t\ta03 = a[3];\n\tvar a10 = a[4],\n\t\ta11 = a[5],\n\t\ta12 = a[6],\n\t\ta13 = a[7];\n\tvar a20 = a[8],\n\t\ta21 = a[9],\n\t\ta22 = a[10],\n\t\ta23 = a[11];\n\tvar a30 = a[12],\n\t\ta31 = a[13],\n\t\ta32 = a[14],\n\t\ta33 = a[15];\n\tvar b00 = a00 * a11 - a01 * a10;\n\tvar b01 = a00 * a12 - a02 * a10;\n\tvar b02 = a00 * a13 - a03 * a10;\n\tvar b03 = a01 * a12 - a02 * a11;\n\tvar b04 = a01 * a13 - a03 * a11;\n\tvar b05 = a02 * a13 - a03 * a12;\n\tvar b06 = a20 * a31 - a21 * a30;\n\tvar b07 = a20 * a32 - a22 * a30;\n\tvar b08 = a20 * a33 - a23 * a30;\n\tvar b09 = a21 * a32 - a22 * a31;\n\tvar b10 = a21 * a33 - a23 * a31;\n\tvar b11 = a22 * a33 - a23 * a32; // Calculate the determinant\n\n\tvar det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;\n\n\tif (!det) {\n\t\treturn null;\n\t}\n\n\tdet = 1.0 / det;\n\tout[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det;\n\tout[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det;\n\tout[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det;\n\tout[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det;\n\tout[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det;\n\tout[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det;\n\tout[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det;\n\tout[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det;\n\tout[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det;\n\tout[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det;\n\tout[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det;\n\tout[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det;\n\tout[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det;\n\tout[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det;\n\tout[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det;\n\tout[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det;\n\treturn out;\n}\n/**\n * Calculates the adjugate of a mat4\n *\n * @param {mat4} out the receiving matrix\n * @param {ReadonlyMat4} a the source matrix\n * @returns {mat4} out\n */\n\nfunction adjoint(out, a) {\n\tvar a00 = a[0],\n\t\ta01 = a[1],\n\t\ta02 = a[2],\n\t\ta03 = a[3];\n\tvar a10 = a[4],\n\t\ta11 = a[5],\n\t\ta12 = a[6],\n\t\ta13 = a[7];\n\tvar a20 = a[8],\n\t\ta21 = a[9],\n\t\ta22 = a[10],\n\t\ta23 = a[11];\n\tvar a30 = a[12],\n\t\ta31 = a[13],\n\t\ta32 = a[14],\n\t\ta33 = a[15];\n\tvar b00 = a00 * a11 - a01 * a10;\n\tvar b01 = a00 * a12 - a02 * a10;\n\tvar b02 = a00 * a13 - a03 * a10;\n\tvar b03 = a01 * a12 - a02 * a11;\n\tvar b04 = a01 * a13 - a03 * a11;\n\tvar b05 = a02 * a13 - a03 * a12;\n\tvar b06 = a20 * a31 - a21 * a30;\n\tvar b07 = a20 * a32 - a22 * a30;\n\tvar b08 = a20 * a33 - a23 * a30;\n\tvar b09 = a21 * a32 - a22 * a31;\n\tvar b10 = a21 * a33 - a23 * a31;\n\tvar b11 = a22 * a33 - a23 * a32;\n\tout[0] = a11 * b11 - a12 * b10 + a13 * b09;\n\tout[1] = a02 * b10 - a01 * b11 - a03 * b09;\n\tout[2] = a31 * b05 - a32 * b04 + a33 * b03;\n\tout[3] = a22 * b04 - a21 * b05 - a23 * b03;\n\tout[4] = a12 * b08 - a10 * b11 - a13 * b07;\n\tout[5] = a00 * b11 - a02 * b08 + a03 * b07;\n\tout[6] = a32 * b02 - a30 * b05 - a33 * b01;\n\tout[7] = a20 * b05 - a22 * b02 + a23 * b01;\n\tout[8] = a10 * b10 - a11 * b08 + a13 * b06;\n\tout[9] = a01 * b08 - a00 * b10 - a03 * b06;\n\tout[10] = a30 * b04 - a31 * b02 + a33 * b00;\n\tout[11] = a21 * b02 - a20 * b04 - a23 * b00;\n\tout[12] = a11 * b07 - a10 * b09 - a12 * b06;\n\tout[13] = a00 * b09 - a01 * b07 + a02 * b06;\n\tout[14] = a31 * b01 - a30 * b03 - a32 * b00;\n\tout[15] = a20 * b03 - a21 * b01 + a22 * b00;\n\treturn out;\n}\n/**\n * Calculates the determinant of a mat4\n *\n * @param {ReadonlyMat4} a the source matrix\n * @returns {Number} determinant of a\n */\n\nfunction determinant(a) {\n\tvar a00 = a[0],\n\t\ta01 = a[1],\n\t\ta02 = a[2],\n\t\ta03 = a[3];\n\tvar a10 = a[4],\n\t\ta11 = a[5],\n\t\ta12 = a[6],\n\t\ta13 = a[7];\n\tvar a20 = a[8],\n\t\ta21 = a[9],\n\t\ta22 = a[10],\n\t\ta23 = a[11];\n\tvar a30 = a[12],\n\t\ta31 = a[13],\n\t\ta32 = a[14],\n\t\ta33 = a[15];\n\tvar b0 = a00 * a11 - a01 * a10;\n\tvar b1 = a00 * a12 - a02 * a10;\n\tvar b2 = a01 * a12 - a02 * a11;\n\tvar b3 = a20 * a31 - a21 * a30;\n\tvar b4 = a20 * a32 - a22 * a30;\n\tvar b5 = a21 * a32 - a22 * a31;\n\tvar b6 = a00 * b5 - a01 * b4 + a02 * b3;\n\tvar b7 = a10 * b5 - a11 * b4 + a12 * b3;\n\tvar b8 = a20 * b2 - a21 * b1 + a22 * b0;\n\tvar b9 = a30 * b2 - a31 * b1 + a32 * b0; // Calculate the determinant\n\n\treturn a13 * b6 - a03 * b7 + a33 * b8 - a23 * b9;\n}\n/**\n * Multiplies two mat4s\n *\n * @param {mat4} out the receiving matrix\n * @param {ReadonlyMat4} a the first operand\n * @param {ReadonlyMat4} b the second operand\n * @returns {mat4} out\n */\n\nfunction multiply$5(out, a, b) {\n\tvar a00 = a[0],\n\t\ta01 = a[1],\n\t\ta02 = a[2],\n\t\ta03 = a[3];\n\tvar a10 = a[4],\n\t\ta11 = a[5],\n\t\ta12 = a[6],\n\t\ta13 = a[7];\n\tvar a20 = a[8],\n\t\ta21 = a[9],\n\t\ta22 = a[10],\n\t\ta23 = a[11];\n\tvar a30 = a[12],\n\t\ta31 = a[13],\n\t\ta32 = a[14],\n\t\ta33 = a[15]; // Cache only the current line of the second matrix\n\n\tvar b0 = b[0],\n\t\tb1 = b[1],\n\t\tb2 = b[2],\n\t\tb3 = b[3];\n\tout[0] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;\n\tout[1] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;\n\tout[2] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;\n\tout[3] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;\n\tb0 = b[4];\n\tb1 = b[5];\n\tb2 = b[6];\n\tb3 = b[7];\n\tout[4] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;\n\tout[5] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;\n\tout[6] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;\n\tout[7] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;\n\tb0 = b[8];\n\tb1 = b[9];\n\tb2 = b[10];\n\tb3 = b[11];\n\tout[8] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;\n\tout[9] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;\n\tout[10] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;\n\tout[11] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;\n\tb0 = b[12];\n\tb1 = b[13];\n\tb2 = b[14];\n\tb3 = b[15];\n\tout[12] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;\n\tout[13] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;\n\tout[14] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;\n\tout[15] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;\n\treturn out;\n}\n/**\n * Translate a mat4 by the given vector\n *\n * @param {mat4} out the receiving matrix\n * @param {ReadonlyMat4} a the matrix to translate\n * @param {ReadonlyVec3} v vector to translate by\n * @returns {mat4} out\n */\n\nfunction translate$1(out, a, v) {\n\tvar x = v[0],\n\t\ty = v[1],\n\t\tz = v[2];\n\tvar a00, a01, a02, a03;\n\tvar a10, a11, a12, a13;\n\tvar a20, a21, a22, a23;\n\n\tif (a === out) {\n\t\tout[12] = a[0] * x + a[4] * y + a[8] * z + a[12];\n\t\tout[13] = a[1] * x + a[5] * y + a[9] * z + a[13];\n\t\tout[14] = a[2] * x + a[6] * y + a[10] * z + a[14];\n\t\tout[15] = a[3] * x + a[7] * y + a[11] * z + a[15];\n\t} else {\n\t\ta00 = a[0];\n\t\ta01 = a[1];\n\t\ta02 = a[2];\n\t\ta03 = a[3];\n\t\ta10 = a[4];\n\t\ta11 = a[5];\n\t\ta12 = a[6];\n\t\ta13 = a[7];\n\t\ta20 = a[8];\n\t\ta21 = a[9];\n\t\ta22 = a[10];\n\t\ta23 = a[11];\n\t\tout[0] = a00;\n\t\tout[1] = a01;\n\t\tout[2] = a02;\n\t\tout[3] = a03;\n\t\tout[4] = a10;\n\t\tout[5] = a11;\n\t\tout[6] = a12;\n\t\tout[7] = a13;\n\t\tout[8] = a20;\n\t\tout[9] = a21;\n\t\tout[10] = a22;\n\t\tout[11] = a23;\n\t\tout[12] = a00 * x + a10 * y + a20 * z + a[12];\n\t\tout[13] = a01 * x + a11 * y + a21 * z + a[13];\n\t\tout[14] = a02 * x + a12 * y + a22 * z + a[14];\n\t\tout[15] = a03 * x + a13 * y + a23 * z + a[15];\n\t}\n\n\treturn out;\n}\n/**\n * Scales the mat4 by the dimensions in the given vec3 not using vectorization\n *\n * @param {mat4} out the receiving matrix\n * @param {ReadonlyMat4} a the matrix to scale\n * @param {ReadonlyVec3} v the vec3 to scale the matrix by\n * @returns {mat4} out\n **/\n\nfunction scale$5(out, a, v) {\n\tvar x = v[0],\n\t\ty = v[1],\n\t\tz = v[2];\n\tout[0] = a[0] * x;\n\tout[1] = a[1] * x;\n\tout[2] = a[2] * x;\n\tout[3] = a[3] * x;\n\tout[4] = a[4] * y;\n\tout[5] = a[5] * y;\n\tout[6] = a[6] * y;\n\tout[7] = a[7] * y;\n\tout[8] = a[8] * z;\n\tout[9] = a[9] * z;\n\tout[10] = a[10] * z;\n\tout[11] = a[11] * z;\n\tout[12] = a[12];\n\tout[13] = a[13];\n\tout[14] = a[14];\n\tout[15] = a[15];\n\treturn out;\n}\n/**\n * Rotates a mat4 by the given angle around the given axis\n *\n * @param {mat4} out the receiving matrix\n * @param {ReadonlyMat4} a the matrix to rotate\n * @param {Number} rad the angle to rotate the matrix by\n * @param {ReadonlyVec3} axis the axis to rotate around\n * @returns {mat4} out\n */\n\nfunction rotate$1(out, a, rad, axis) {\n\tvar x = axis[0],\n\t\ty = axis[1],\n\t\tz = axis[2];\n\tvar len = Math.hypot(x, y, z);\n\tvar s, c, t;\n\tvar a00, a01, a02, a03;\n\tvar a10, a11, a12, a13;\n\tvar a20, a21, a22, a23;\n\tvar b00, b01, b02;\n\tvar b10, b11, b12;\n\tvar b20, b21, b22;\n\n\tif (len < EPSILON) {\n\t\treturn null;\n\t}\n\n\tlen = 1 / len;\n\tx *= len;\n\ty *= len;\n\tz *= len;\n\ts = Math.sin(rad);\n\tc = Math.cos(rad);\n\tt = 1 - c;\n\ta00 = a[0];\n\ta01 = a[1];\n\ta02 = a[2];\n\ta03 = a[3];\n\ta10 = a[4];\n\ta11 = a[5];\n\ta12 = a[6];\n\ta13 = a[7];\n\ta20 = a[8];\n\ta21 = a[9];\n\ta22 = a[10];\n\ta23 = a[11]; // Construct the elements of the rotation matrix\n\n\tb00 = x * x * t + c;\n\tb01 = y * x * t + z * s;\n\tb02 = z * x * t - y * s;\n\tb10 = x * y * t - z * s;\n\tb11 = y * y * t + c;\n\tb12 = z * y * t + x * s;\n\tb20 = x * z * t + y * s;\n\tb21 = y * z * t - x * s;\n\tb22 = z * z * t + c; // Perform rotation-specific matrix multiplication\n\n\tout[0] = a00 * b00 + a10 * b01 + a20 * b02;\n\tout[1] = a01 * b00 + a11 * b01 + a21 * b02;\n\tout[2] = a02 * b00 + a12 * b01 + a22 * b02;\n\tout[3] = a03 * b00 + a13 * b01 + a23 * b02;\n\tout[4] = a00 * b10 + a10 * b11 + a20 * b12;\n\tout[5] = a01 * b10 + a11 * b11 + a21 * b12;\n\tout[6] = a02 * b10 + a12 * b11 + a22 * b12;\n\tout[7] = a03 * b10 + a13 * b11 + a23 * b12;\n\tout[8] = a00 * b20 + a10 * b21 + a20 * b22;\n\tout[9] = a01 * b20 + a11 * b21 + a21 * b22;\n\tout[10] = a02 * b20 + a12 * b21 + a22 * b22;\n\tout[11] = a03 * b20 + a13 * b21 + a23 * b22;\n\n\tif (a !== out) {\n\t\t// If the source and destination differ, copy the unchanged last row\n\t\tout[12] = a[12];\n\t\tout[13] = a[13];\n\t\tout[14] = a[14];\n\t\tout[15] = a[15];\n\t}\n\n\treturn out;\n}\n/**\n * Rotates a matrix by the given angle around the X axis\n *\n * @param {mat4} out the receiving matrix\n * @param {ReadonlyMat4} a the matrix to rotate\n * @param {Number} rad the angle to rotate the matrix by\n * @returns {mat4} out\n */\n\nfunction rotateX$3(out, a, rad) {\n\tvar s = Math.sin(rad);\n\tvar c = Math.cos(rad);\n\tvar a10 = a[4];\n\tvar a11 = a[5];\n\tvar a12 = a[6];\n\tvar a13 = a[7];\n\tvar a20 = a[8];\n\tvar a21 = a[9];\n\tvar a22 = a[10];\n\tvar a23 = a[11];\n\n\tif (a !== out) {\n\t\t// If the source and destination differ, copy the unchanged rows\n\t\tout[0] = a[0];\n\t\tout[1] = a[1];\n\t\tout[2] = a[2];\n\t\tout[3] = a[3];\n\t\tout[12] = a[12];\n\t\tout[13] = a[13];\n\t\tout[14] = a[14];\n\t\tout[15] = a[15];\n\t} // Perform axis-specific matrix multiplication\n\n\tout[4] = a10 * c + a20 * s;\n\tout[5] = a11 * c + a21 * s;\n\tout[6] = a12 * c + a22 * s;\n\tout[7] = a13 * c + a23 * s;\n\tout[8] = a20 * c - a10 * s;\n\tout[9] = a21 * c - a11 * s;\n\tout[10] = a22 * c - a12 * s;\n\tout[11] = a23 * c - a13 * s;\n\treturn out;\n}\n/**\n * Rotates a matrix by the given angle around the Y axis\n *\n * @param {mat4} out the receiving matrix\n * @param {ReadonlyMat4} a the matrix to rotate\n * @param {Number} rad the angle to rotate the matrix by\n * @returns {mat4} out\n */\n\nfunction rotateY$3(out, a, rad) {\n\tvar s = Math.sin(rad);\n\tvar c = Math.cos(rad);\n\tvar a00 = a[0];\n\tvar a01 = a[1];\n\tvar a02 = a[2];\n\tvar a03 = a[3];\n\tvar a20 = a[8];\n\tvar a21 = a[9];\n\tvar a22 = a[10];\n\tvar a23 = a[11];\n\n\tif (a !== out) {\n\t\t// If the source and destination differ, copy the unchanged rows\n\t\tout[4] = a[4];\n\t\tout[5] = a[5];\n\t\tout[6] = a[6];\n\t\tout[7] = a[7];\n\t\tout[12] = a[12];\n\t\tout[13] = a[13];\n\t\tout[14] = a[14];\n\t\tout[15] = a[15];\n\t} // Perform axis-specific matrix multiplication\n\n\tout[0] = a00 * c - a20 * s;\n\tout[1] = a01 * c - a21 * s;\n\tout[2] = a02 * c - a22 * s;\n\tout[3] = a03 * c - a23 * s;\n\tout[8] = a00 * s + a20 * c;\n\tout[9] = a01 * s + a21 * c;\n\tout[10] = a02 * s + a22 * c;\n\tout[11] = a03 * s + a23 * c;\n\treturn out;\n}\n/**\n * Rotates a matrix by the given angle around the Z axis\n *\n * @param {mat4} out the receiving matrix\n * @param {ReadonlyMat4} a the matrix to rotate\n * @param {Number} rad the angle to rotate the matrix by\n * @returns {mat4} out\n */\n\nfunction rotateZ$3(out, a, rad) {\n\tvar s = Math.sin(rad);\n\tvar c = Math.cos(rad);\n\tvar a00 = a[0];\n\tvar a01 = a[1];\n\tvar a02 = a[2];\n\tvar a03 = a[3];\n\tvar a10 = a[4];\n\tvar a11 = a[5];\n\tvar a12 = a[6];\n\tvar a13 = a[7];\n\n\tif (a !== out) {\n\t\t// If the source and destination differ, copy the unchanged last row\n\t\tout[8] = a[8];\n\t\tout[9] = a[9];\n\t\tout[10] = a[10];\n\t\tout[11] = a[11];\n\t\tout[12] = a[12];\n\t\tout[13] = a[13];\n\t\tout[14] = a[14];\n\t\tout[15] = a[15];\n\t} // Perform axis-specific matrix multiplication\n\n\tout[0] = a00 * c + a10 * s;\n\tout[1] = a01 * c + a11 * s;\n\tout[2] = a02 * c + a12 * s;\n\tout[3] = a03 * c + a13 * s;\n\tout[4] = a10 * c - a00 * s;\n\tout[5] = a11 * c - a01 * s;\n\tout[6] = a12 * c - a02 * s;\n\tout[7] = a13 * c - a03 * s;\n\treturn out;\n}\n/**\n * Creates a matrix from a vector translation\n * This is equivalent to (but much faster than):\n *\n *     mat4.identity(dest);\n *     mat4.translate(dest, dest, vec);\n *\n * @param {mat4} out mat4 receiving operation result\n * @param {ReadonlyVec3} v Translation vector\n * @returns {mat4} out\n */\n\nfunction fromTranslation$1(out, v) {\n\tout[0] = 1;\n\tout[1] = 0;\n\tout[2] = 0;\n\tout[3] = 0;\n\tout[4] = 0;\n\tout[5] = 1;\n\tout[6] = 0;\n\tout[7] = 0;\n\tout[8] = 0;\n\tout[9] = 0;\n\tout[10] = 1;\n\tout[11] = 0;\n\tout[12] = v[0];\n\tout[13] = v[1];\n\tout[14] = v[2];\n\tout[15] = 1;\n\treturn out;\n}\n/**\n * Creates a matrix from a vector scaling\n * This is equivalent to (but much faster than):\n *\n *     mat4.identity(dest);\n *     mat4.scale(dest, dest, vec);\n *\n * @param {mat4} out mat4 receiving operation result\n * @param {ReadonlyVec3} v Scaling vector\n * @returns {mat4} out\n */\n\nfunction fromScaling(out, v) {\n\tout[0] = v[0];\n\tout[1] = 0;\n\tout[2] = 0;\n\tout[3] = 0;\n\tout[4] = 0;\n\tout[5] = v[1];\n\tout[6] = 0;\n\tout[7] = 0;\n\tout[8] = 0;\n\tout[9] = 0;\n\tout[10] = v[2];\n\tout[11] = 0;\n\tout[12] = 0;\n\tout[13] = 0;\n\tout[14] = 0;\n\tout[15] = 1;\n\treturn out;\n}\n/**\n * Creates a matrix from a given angle around a given axis\n * This is equivalent to (but much faster than):\n *\n *     mat4.identity(dest);\n *     mat4.rotate(dest, dest, rad, axis);\n *\n * @param {mat4} out mat4 receiving operation result\n * @param {Number} rad the angle to rotate the matrix by\n * @param {ReadonlyVec3} axis the axis to rotate around\n * @returns {mat4} out\n */\n\nfunction fromRotation$1(out, rad, axis) {\n\tvar x = axis[0],\n\t\ty = axis[1],\n\t\tz = axis[2];\n\tvar len = Math.hypot(x, y, z);\n\tvar s, c, t;\n\n\tif (len < EPSILON) {\n\t\treturn null;\n\t}\n\n\tlen = 1 / len;\n\tx *= len;\n\ty *= len;\n\tz *= len;\n\ts = Math.sin(rad);\n\tc = Math.cos(rad);\n\tt = 1 - c; // Perform rotation-specific matrix multiplication\n\n\tout[0] = x * x * t + c;\n\tout[1] = y * x * t + z * s;\n\tout[2] = z * x * t - y * s;\n\tout[3] = 0;\n\tout[4] = x * y * t - z * s;\n\tout[5] = y * y * t + c;\n\tout[6] = z * y * t + x * s;\n\tout[7] = 0;\n\tout[8] = x * z * t + y * s;\n\tout[9] = y * z * t - x * s;\n\tout[10] = z * z * t + c;\n\tout[11] = 0;\n\tout[12] = 0;\n\tout[13] = 0;\n\tout[14] = 0;\n\tout[15] = 1;\n\treturn out;\n}\n/**\n * Creates a matrix from the given angle around the X axis\n * This is equivalent to (but much faster than):\n *\n *     mat4.identity(dest);\n *     mat4.rotateX(dest, dest, rad);\n *\n * @param {mat4} out mat4 receiving operation result\n * @param {Number} rad the angle to rotate the matrix by\n * @returns {mat4} out\n */\n\nfunction fromXRotation(out, rad) {\n\tvar s = Math.sin(rad);\n\tvar c = Math.cos(rad); // Perform axis-specific matrix multiplication\n\n\tout[0] = 1;\n\tout[1] = 0;\n\tout[2] = 0;\n\tout[3] = 0;\n\tout[4] = 0;\n\tout[5] = c;\n\tout[6] = s;\n\tout[7] = 0;\n\tout[8] = 0;\n\tout[9] = -s;\n\tout[10] = c;\n\tout[11] = 0;\n\tout[12] = 0;\n\tout[13] = 0;\n\tout[14] = 0;\n\tout[15] = 1;\n\treturn out;\n}\n/**\n * Creates a matrix from the given angle around the Y axis\n * This is equivalent to (but much faster than):\n *\n *     mat4.identity(dest);\n *     mat4.rotateY(dest, dest, rad);\n *\n * @param {mat4} out mat4 receiving operation result\n * @param {Number} rad the angle to rotate the matrix by\n * @returns {mat4} out\n */\n\nfunction fromYRotation(out, rad) {\n\tvar s = Math.sin(rad);\n\tvar c = Math.cos(rad); // Perform axis-specific matrix multiplication\n\n\tout[0] = c;\n\tout[1] = 0;\n\tout[2] = -s;\n\tout[3] = 0;\n\tout[4] = 0;\n\tout[5] = 1;\n\tout[6] = 0;\n\tout[7] = 0;\n\tout[8] = s;\n\tout[9] = 0;\n\tout[10] = c;\n\tout[11] = 0;\n\tout[12] = 0;\n\tout[13] = 0;\n\tout[14] = 0;\n\tout[15] = 1;\n\treturn out;\n}\n/**\n * Creates a matrix from the given angle around the Z axis\n * This is equivalent to (but much faster than):\n *\n *     mat4.identity(dest);\n *     mat4.rotateZ(dest, dest, rad);\n *\n * @param {mat4} out mat4 receiving operation result\n * @param {Number} rad the angle to rotate the matrix by\n * @returns {mat4} out\n */\n\nfunction fromZRotation(out, rad) {\n\tvar s = Math.sin(rad);\n\tvar c = Math.cos(rad); // Perform axis-specific matrix multiplication\n\n\tout[0] = c;\n\tout[1] = s;\n\tout[2] = 0;\n\tout[3] = 0;\n\tout[4] = -s;\n\tout[5] = c;\n\tout[6] = 0;\n\tout[7] = 0;\n\tout[8] = 0;\n\tout[9] = 0;\n\tout[10] = 1;\n\tout[11] = 0;\n\tout[12] = 0;\n\tout[13] = 0;\n\tout[14] = 0;\n\tout[15] = 1;\n\treturn out;\n}\n/**\n * Creates a matrix from a quaternion rotation and vector translation\n * This is equivalent to (but much faster than):\n *\n *     mat4.identity(dest);\n *     mat4.translate(dest, vec);\n *     let quatMat = mat4.create();\n *     quat4.toMat4(quat, quatMat);\n *     mat4.multiply(dest, quatMat);\n *\n * @param {mat4} out mat4 receiving operation result\n * @param {quat4} q Rotation quaternion\n * @param {ReadonlyVec3} v Translation vector\n * @returns {mat4} out\n */\n\nfunction fromRotationTranslation$1(out, q, v) {\n\t// Quaternion math\n\tvar x = q[0],\n\t\ty = q[1],\n\t\tz = q[2],\n\t\tw = q[3];\n\tvar x2 = x + x;\n\tvar y2 = y + y;\n\tvar z2 = z + z;\n\tvar xx = x * x2;\n\tvar xy = x * y2;\n\tvar xz = x * z2;\n\tvar yy = y * y2;\n\tvar yz = y * z2;\n\tvar zz = z * z2;\n\tvar wx = w * x2;\n\tvar wy = w * y2;\n\tvar wz = w * z2;\n\tout[0] = 1 - (yy + zz);\n\tout[1] = xy + wz;\n\tout[2] = xz - wy;\n\tout[3] = 0;\n\tout[4] = xy - wz;\n\tout[5] = 1 - (xx + zz);\n\tout[6] = yz + wx;\n\tout[7] = 0;\n\tout[8] = xz + wy;\n\tout[9] = yz - wx;\n\tout[10] = 1 - (xx + yy);\n\tout[11] = 0;\n\tout[12] = v[0];\n\tout[13] = v[1];\n\tout[14] = v[2];\n\tout[15] = 1;\n\treturn out;\n}\n/**\n * Creates a new mat4 from a dual quat.\n *\n * @param {mat4} out Matrix\n * @param {ReadonlyQuat2} a Dual Quaternion\n * @returns {mat4} mat4 receiving operation result\n */\n\nfunction fromQuat2(out, a) {\n\tvar translation = new ARRAY_TYPE(3);\n\tvar bx = -a[0],\n\t\tby = -a[1],\n\t\tbz = -a[2],\n\t\tbw = a[3],\n\t\tax = a[4],\n\t\tay = a[5],\n\t\taz = a[6],\n\t\taw = a[7];\n\tvar magnitude = bx * bx + by * by + bz * bz + bw * bw; //Only scale if it makes sense\n\n\tif (magnitude > 0) {\n\t\ttranslation[0] = ((ax * bw + aw * bx + ay * bz - az * by) * 2) / magnitude;\n\t\ttranslation[1] = ((ay * bw + aw * by + az * bx - ax * bz) * 2) / magnitude;\n\t\ttranslation[2] = ((az * bw + aw * bz + ax * by - ay * bx) * 2) / magnitude;\n\t} else {\n\t\ttranslation[0] = (ax * bw + aw * bx + ay * bz - az * by) * 2;\n\t\ttranslation[1] = (ay * bw + aw * by + az * bx - ax * bz) * 2;\n\t\ttranslation[2] = (az * bw + aw * bz + ax * by - ay * bx) * 2;\n\t}\n\n\tfromRotationTranslation$1(out, a, translation);\n\treturn out;\n}\n/**\n * Returns the translation vector component of a transformation\n *  matrix. If a matrix is built with fromRotationTranslation,\n *  the returned vector will be the same as the translation vector\n *  originally supplied.\n * @param  {vec3} out Vector to receive translation component\n * @param  {ReadonlyMat4} mat Matrix to be decomposed (input)\n * @return {vec3} out\n */\n\nfunction getTranslation$1(out, mat) {\n\tout[0] = mat[12];\n\tout[1] = mat[13];\n\tout[2] = mat[14];\n\treturn out;\n}\n/**\n * Returns the scaling factor component of a transformation\n *  matrix. If a matrix is built with fromRotationTranslationScale\n *  with a normalized Quaternion paramter, the returned vector will be\n *  the same as the scaling vector\n *  originally supplied.\n * @param  {vec3} out Vector to receive scaling factor component\n * @param  {ReadonlyMat4} mat Matrix to be decomposed (input)\n * @return {vec3} out\n */\n\nfunction getScaling(out, mat) {\n\tvar m11 = mat[0];\n\tvar m12 = mat[1];\n\tvar m13 = mat[2];\n\tvar m21 = mat[4];\n\tvar m22 = mat[5];\n\tvar m23 = mat[6];\n\tvar m31 = mat[8];\n\tvar m32 = mat[9];\n\tvar m33 = mat[10];\n\tout[0] = Math.hypot(m11, m12, m13);\n\tout[1] = Math.hypot(m21, m22, m23);\n\tout[2] = Math.hypot(m31, m32, m33);\n\treturn out;\n}\n/**\n * Returns a quaternion representing the rotational component\n *  of a transformation matrix. If a matrix is built with\n *  fromRotationTranslation, the returned quaternion will be the\n *  same as the quaternion originally supplied.\n * @param {quat} out Quaternion to receive the rotation component\n * @param {ReadonlyMat4} mat Matrix to be decomposed (input)\n * @return {quat} out\n */\n\nfunction getRotation(out, mat) {\n\tvar scaling = new ARRAY_TYPE(3);\n\tgetScaling(scaling, mat);\n\tvar is1 = 1 / scaling[0];\n\tvar is2 = 1 / scaling[1];\n\tvar is3 = 1 / scaling[2];\n\tvar sm11 = mat[0] * is1;\n\tvar sm12 = mat[1] * is2;\n\tvar sm13 = mat[2] * is3;\n\tvar sm21 = mat[4] * is1;\n\tvar sm22 = mat[5] * is2;\n\tvar sm23 = mat[6] * is3;\n\tvar sm31 = mat[8] * is1;\n\tvar sm32 = mat[9] * is2;\n\tvar sm33 = mat[10] * is3;\n\tvar trace = sm11 + sm22 + sm33;\n\tvar S = 0;\n\n\tif (trace > 0) {\n\t\tS = Math.sqrt(trace + 1.0) * 2;\n\t\tout[3] = 0.25 * S;\n\t\tout[0] = (sm23 - sm32) / S;\n\t\tout[1] = (sm31 - sm13) / S;\n\t\tout[2] = (sm12 - sm21) / S;\n\t} else if (sm11 > sm22 && sm11 > sm33) {\n\t\tS = Math.sqrt(1.0 + sm11 - sm22 - sm33) * 2;\n\t\tout[3] = (sm23 - sm32) / S;\n\t\tout[0] = 0.25 * S;\n\t\tout[1] = (sm12 + sm21) / S;\n\t\tout[2] = (sm31 + sm13) / S;\n\t} else if (sm22 > sm33) {\n\t\tS = Math.sqrt(1.0 + sm22 - sm11 - sm33) * 2;\n\t\tout[3] = (sm31 - sm13) / S;\n\t\tout[0] = (sm12 + sm21) / S;\n\t\tout[1] = 0.25 * S;\n\t\tout[2] = (sm23 + sm32) / S;\n\t} else {\n\t\tS = Math.sqrt(1.0 + sm33 - sm11 - sm22) * 2;\n\t\tout[3] = (sm12 - sm21) / S;\n\t\tout[0] = (sm31 + sm13) / S;\n\t\tout[1] = (sm23 + sm32) / S;\n\t\tout[2] = 0.25 * S;\n\t}\n\n\treturn out;\n}\n/**\n * Decomposes a transformation matrix into its rotation, translation\n * and scale components. Returns only the rotation component\n * @param  {quat} out_r Quaternion to receive the rotation component\n * @param  {vec3} out_t Vector to receive the translation vector\n * @param  {vec3} out_s Vector to receive the scaling factor\n * @param  {ReadonlyMat4} mat Matrix to be decomposed (input)\n * @returns {quat} out_r\n */\n\nfunction decompose(out_r, out_t, out_s, mat) {\n\tout_t[0] = mat[12];\n\tout_t[1] = mat[13];\n\tout_t[2] = mat[14];\n\tvar m11 = mat[0];\n\tvar m12 = mat[1];\n\tvar m13 = mat[2];\n\tvar m21 = mat[4];\n\tvar m22 = mat[5];\n\tvar m23 = mat[6];\n\tvar m31 = mat[8];\n\tvar m32 = mat[9];\n\tvar m33 = mat[10];\n\tout_s[0] = Math.hypot(m11, m12, m13);\n\tout_s[1] = Math.hypot(m21, m22, m23);\n\tout_s[2] = Math.hypot(m31, m32, m33);\n\tvar is1 = 1 / out_s[0];\n\tvar is2 = 1 / out_s[1];\n\tvar is3 = 1 / out_s[2];\n\tvar sm11 = m11 * is1;\n\tvar sm12 = m12 * is2;\n\tvar sm13 = m13 * is3;\n\tvar sm21 = m21 * is1;\n\tvar sm22 = m22 * is2;\n\tvar sm23 = m23 * is3;\n\tvar sm31 = m31 * is1;\n\tvar sm32 = m32 * is2;\n\tvar sm33 = m33 * is3;\n\tvar trace = sm11 + sm22 + sm33;\n\tvar S = 0;\n\n\tif (trace > 0) {\n\t\tS = Math.sqrt(trace + 1.0) * 2;\n\t\tout_r[3] = 0.25 * S;\n\t\tout_r[0] = (sm23 - sm32) / S;\n\t\tout_r[1] = (sm31 - sm13) / S;\n\t\tout_r[2] = (sm12 - sm21) / S;\n\t} else if (sm11 > sm22 && sm11 > sm33) {\n\t\tS = Math.sqrt(1.0 + sm11 - sm22 - sm33) * 2;\n\t\tout_r[3] = (sm23 - sm32) / S;\n\t\tout_r[0] = 0.25 * S;\n\t\tout_r[1] = (sm12 + sm21) / S;\n\t\tout_r[2] = (sm31 + sm13) / S;\n\t} else if (sm22 > sm33) {\n\t\tS = Math.sqrt(1.0 + sm22 - sm11 - sm33) * 2;\n\t\tout_r[3] = (sm31 - sm13) / S;\n\t\tout_r[0] = (sm12 + sm21) / S;\n\t\tout_r[1] = 0.25 * S;\n\t\tout_r[2] = (sm23 + sm32) / S;\n\t} else {\n\t\tS = Math.sqrt(1.0 + sm33 - sm11 - sm22) * 2;\n\t\tout_r[3] = (sm12 - sm21) / S;\n\t\tout_r[0] = (sm31 + sm13) / S;\n\t\tout_r[1] = (sm23 + sm32) / S;\n\t\tout_r[2] = 0.25 * S;\n\t}\n\n\treturn out_r;\n}\n/**\n * Creates a matrix from a quaternion rotation, vector translation and vector scale\n * This is equivalent to (but much faster than):\n *\n *     mat4.identity(dest);\n *     mat4.translate(dest, vec);\n *     let quatMat = mat4.create();\n *     quat4.toMat4(quat, quatMat);\n *     mat4.multiply(dest, quatMat);\n *     mat4.scale(dest, scale)\n *\n * @param {mat4} out mat4 receiving operation result\n * @param {quat4} q Rotation quaternion\n * @param {ReadonlyVec3} v Translation vector\n * @param {ReadonlyVec3} s Scaling vector\n * @returns {mat4} out\n */\n\nfunction fromRotationTranslationScale(out, q, v, s) {\n\t// Quaternion math\n\tvar x = q[0],\n\t\ty = q[1],\n\t\tz = q[2],\n\t\tw = q[3];\n\tvar x2 = x + x;\n\tvar y2 = y + y;\n\tvar z2 = z + z;\n\tvar xx = x * x2;\n\tvar xy = x * y2;\n\tvar xz = x * z2;\n\tvar yy = y * y2;\n\tvar yz = y * z2;\n\tvar zz = z * z2;\n\tvar wx = w * x2;\n\tvar wy = w * y2;\n\tvar wz = w * z2;\n\tvar sx = s[0];\n\tvar sy = s[1];\n\tvar sz = s[2];\n\tout[0] = (1 - (yy + zz)) * sx;\n\tout[1] = (xy + wz) * sx;\n\tout[2] = (xz - wy) * sx;\n\tout[3] = 0;\n\tout[4] = (xy - wz) * sy;\n\tout[5] = (1 - (xx + zz)) * sy;\n\tout[6] = (yz + wx) * sy;\n\tout[7] = 0;\n\tout[8] = (xz + wy) * sz;\n\tout[9] = (yz - wx) * sz;\n\tout[10] = (1 - (xx + yy)) * sz;\n\tout[11] = 0;\n\tout[12] = v[0];\n\tout[13] = v[1];\n\tout[14] = v[2];\n\tout[15] = 1;\n\treturn out;\n}\n/**\n * Creates a matrix from a quaternion rotation, vector translation and vector scale, rotating and scaling around the given origin\n * This is equivalent to (but much faster than):\n *\n *     mat4.identity(dest);\n *     mat4.translate(dest, vec);\n *     mat4.translate(dest, origin);\n *     let quatMat = mat4.create();\n *     quat4.toMat4(quat, quatMat);\n *     mat4.multiply(dest, quatMat);\n *     mat4.scale(dest, scale)\n *     mat4.translate(dest, negativeOrigin);\n *\n * @param {mat4} out mat4 receiving operation result\n * @param {quat4} q Rotation quaternion\n * @param {ReadonlyVec3} v Translation vector\n * @param {ReadonlyVec3} s Scaling vector\n * @param {ReadonlyVec3} o The origin vector around which to scale and rotate\n * @returns {mat4} out\n */\n\nfunction fromRotationTranslationScaleOrigin(out, q, v, s, o) {\n\t// Quaternion math\n\tvar x = q[0],\n\t\ty = q[1],\n\t\tz = q[2],\n\t\tw = q[3];\n\tvar x2 = x + x;\n\tvar y2 = y + y;\n\tvar z2 = z + z;\n\tvar xx = x * x2;\n\tvar xy = x * y2;\n\tvar xz = x * z2;\n\tvar yy = y * y2;\n\tvar yz = y * z2;\n\tvar zz = z * z2;\n\tvar wx = w * x2;\n\tvar wy = w * y2;\n\tvar wz = w * z2;\n\tvar sx = s[0];\n\tvar sy = s[1];\n\tvar sz = s[2];\n\tvar ox = o[0];\n\tvar oy = o[1];\n\tvar oz = o[2];\n\tvar out0 = (1 - (yy + zz)) * sx;\n\tvar out1 = (xy + wz) * sx;\n\tvar out2 = (xz - wy) * sx;\n\tvar out4 = (xy - wz) * sy;\n\tvar out5 = (1 - (xx + zz)) * sy;\n\tvar out6 = (yz + wx) * sy;\n\tvar out8 = (xz + wy) * sz;\n\tvar out9 = (yz - wx) * sz;\n\tvar out10 = (1 - (xx + yy)) * sz;\n\tout[0] = out0;\n\tout[1] = out1;\n\tout[2] = out2;\n\tout[3] = 0;\n\tout[4] = out4;\n\tout[5] = out5;\n\tout[6] = out6;\n\tout[7] = 0;\n\tout[8] = out8;\n\tout[9] = out9;\n\tout[10] = out10;\n\tout[11] = 0;\n\tout[12] = v[0] + ox - (out0 * ox + out4 * oy + out8 * oz);\n\tout[13] = v[1] + oy - (out1 * ox + out5 * oy + out9 * oz);\n\tout[14] = v[2] + oz - (out2 * ox + out6 * oy + out10 * oz);\n\tout[15] = 1;\n\treturn out;\n}\n/**\n * Calculates a 4x4 matrix from the given quaternion\n *\n * @param {mat4} out mat4 receiving operation result\n * @param {ReadonlyQuat} q Quaternion to create matrix from\n *\n * @returns {mat4} out\n */\n\nfunction fromQuat(out, q) {\n\tvar x = q[0],\n\t\ty = q[1],\n\t\tz = q[2],\n\t\tw = q[3];\n\tvar x2 = x + x;\n\tvar y2 = y + y;\n\tvar z2 = z + z;\n\tvar xx = x * x2;\n\tvar yx = y * x2;\n\tvar yy = y * y2;\n\tvar zx = z * x2;\n\tvar zy = z * y2;\n\tvar zz = z * z2;\n\tvar wx = w * x2;\n\tvar wy = w * y2;\n\tvar wz = w * z2;\n\tout[0] = 1 - yy - zz;\n\tout[1] = yx + wz;\n\tout[2] = zx - wy;\n\tout[3] = 0;\n\tout[4] = yx - wz;\n\tout[5] = 1 - xx - zz;\n\tout[6] = zy + wx;\n\tout[7] = 0;\n\tout[8] = zx + wy;\n\tout[9] = zy - wx;\n\tout[10] = 1 - xx - yy;\n\tout[11] = 0;\n\tout[12] = 0;\n\tout[13] = 0;\n\tout[14] = 0;\n\tout[15] = 1;\n\treturn out;\n}\n/**\n * Generates a frustum matrix with the given bounds\n *\n * @param {mat4} out mat4 frustum matrix will be written into\n * @param {Number} left Left bound of the frustum\n * @param {Number} right Right bound of the frustum\n * @param {Number} bottom Bottom bound of the frustum\n * @param {Number} top Top bound of the frustum\n * @param {Number} near Near bound of the frustum\n * @param {Number} far Far bound of the frustum\n * @returns {mat4} out\n */\n\nfunction frustum(out, left, right, bottom, top, near, far) {\n\tvar rl = 1 / (right - left);\n\tvar tb = 1 / (top - bottom);\n\tvar nf = 1 / (near - far);\n\tout[0] = near * 2 * rl;\n\tout[1] = 0;\n\tout[2] = 0;\n\tout[3] = 0;\n\tout[4] = 0;\n\tout[5] = near * 2 * tb;\n\tout[6] = 0;\n\tout[7] = 0;\n\tout[8] = (right + left) * rl;\n\tout[9] = (top + bottom) * tb;\n\tout[10] = (far + near) * nf;\n\tout[11] = -1;\n\tout[12] = 0;\n\tout[13] = 0;\n\tout[14] = far * near * 2 * nf;\n\tout[15] = 0;\n\treturn out;\n}\n/**\n * Generates a perspective projection matrix with the given bounds.\n * The near/far clip planes correspond to a normalized device coordinate Z range of [-1, 1],\n * which matches WebGL/OpenGL's clip volume.\n * Passing null/undefined/no value for far will generate infinite projection matrix.\n *\n * @param {mat4} out mat4 frustum matrix will be written into\n * @param {number} fovy Vertical field of view in radians\n * @param {number} aspect Aspect ratio. typically viewport width/height\n * @param {number} near Near bound of the frustum\n * @param {number} far Far bound of the frustum, can be null or Infinity\n * @returns {mat4} out\n */\n\nfunction perspectiveNO(out, fovy, aspect, near, far) {\n\tvar f = 1.0 / Math.tan(fovy / 2);\n\tout[0] = f / aspect;\n\tout[1] = 0;\n\tout[2] = 0;\n\tout[3] = 0;\n\tout[4] = 0;\n\tout[5] = f;\n\tout[6] = 0;\n\tout[7] = 0;\n\tout[8] = 0;\n\tout[9] = 0;\n\tout[11] = -1;\n\tout[12] = 0;\n\tout[13] = 0;\n\tout[15] = 0;\n\n\tif (far != null && far !== Infinity) {\n\t\tvar nf = 1 / (near - far);\n\t\tout[10] = (far + near) * nf;\n\t\tout[14] = 2 * far * near * nf;\n\t} else {\n\t\tout[10] = -1;\n\t\tout[14] = -2 * near;\n\t}\n\n\treturn out;\n}\n/**\n * Alias for {@link mat4.perspectiveNO}\n * @function\n */\n\nvar perspective = perspectiveNO;\n/**\n * Generates a perspective projection matrix suitable for WebGPU with the given bounds.\n * The near/far clip planes correspond to a normalized device coordinate Z range of [0, 1],\n * which matches WebGPU/Vulkan/DirectX/Metal's clip volume.\n * Passing null/undefined/no value for far will generate infinite projection matrix.\n *\n * @param {mat4} out mat4 frustum matrix will be written into\n * @param {number} fovy Vertical field of view in radians\n * @param {number} aspect Aspect ratio. typically viewport width/height\n * @param {number} near Near bound of the frustum\n * @param {number} far Far bound of the frustum, can be null or Infinity\n * @returns {mat4} out\n */\n\nfunction perspectiveZO(out, fovy, aspect, near, far) {\n\tvar f = 1.0 / Math.tan(fovy / 2);\n\tout[0] = f / aspect;\n\tout[1] = 0;\n\tout[2] = 0;\n\tout[3] = 0;\n\tout[4] = 0;\n\tout[5] = f;\n\tout[6] = 0;\n\tout[7] = 0;\n\tout[8] = 0;\n\tout[9] = 0;\n\tout[11] = -1;\n\tout[12] = 0;\n\tout[13] = 0;\n\tout[15] = 0;\n\n\tif (far != null && far !== Infinity) {\n\t\tvar nf = 1 / (near - far);\n\t\tout[10] = far * nf;\n\t\tout[14] = far * near * nf;\n\t} else {\n\t\tout[10] = -1;\n\t\tout[14] = -near;\n\t}\n\n\treturn out;\n}\n/**\n * Generates a perspective projection matrix with the given field of view.\n * This is primarily useful for generating projection matrices to be used\n * with the still experiemental WebVR API.\n *\n * @param {mat4} out mat4 frustum matrix will be written into\n * @param {Object} fov Object containing the following values: upDegrees, downDegrees, leftDegrees, rightDegrees\n * @param {number} near Near bound of the frustum\n * @param {number} far Far bound of the frustum\n * @returns {mat4} out\n */\n\nfunction perspectiveFromFieldOfView(out, fov, near, far) {\n\tvar upTan = Math.tan((fov.upDegrees * Math.PI) / 180.0);\n\tvar downTan = Math.tan((fov.downDegrees * Math.PI) / 180.0);\n\tvar leftTan = Math.tan((fov.leftDegrees * Math.PI) / 180.0);\n\tvar rightTan = Math.tan((fov.rightDegrees * Math.PI) / 180.0);\n\tvar xScale = 2.0 / (leftTan + rightTan);\n\tvar yScale = 2.0 / (upTan + downTan);\n\tout[0] = xScale;\n\tout[1] = 0.0;\n\tout[2] = 0.0;\n\tout[3] = 0.0;\n\tout[4] = 0.0;\n\tout[5] = yScale;\n\tout[6] = 0.0;\n\tout[7] = 0.0;\n\tout[8] = -((leftTan - rightTan) * xScale * 0.5);\n\tout[9] = (upTan - downTan) * yScale * 0.5;\n\tout[10] = far / (near - far);\n\tout[11] = -1.0;\n\tout[12] = 0.0;\n\tout[13] = 0.0;\n\tout[14] = (far * near) / (near - far);\n\tout[15] = 0.0;\n\treturn out;\n}\n/**\n * Generates a orthogonal projection matrix with the given bounds.\n * The near/far clip planes correspond to a normalized device coordinate Z range of [-1, 1],\n * which matches WebGL/OpenGL's clip volume.\n *\n * @param {mat4} out mat4 frustum matrix will be written into\n * @param {number} left Left bound of the frustum\n * @param {number} right Right bound of the frustum\n * @param {number} bottom Bottom bound of the frustum\n * @param {number} top Top bound of the frustum\n * @param {number} near Near bound of the frustum\n * @param {number} far Far bound of the frustum\n * @returns {mat4} out\n */\n\nfunction orthoNO(out, left, right, bottom, top, near, far) {\n\tvar lr = 1 / (left - right);\n\tvar bt = 1 / (bottom - top);\n\tvar nf = 1 / (near - far);\n\tout[0] = -2 * lr;\n\tout[1] = 0;\n\tout[2] = 0;\n\tout[3] = 0;\n\tout[4] = 0;\n\tout[5] = -2 * bt;\n\tout[6] = 0;\n\tout[7] = 0;\n\tout[8] = 0;\n\tout[9] = 0;\n\tout[10] = 2 * nf;\n\tout[11] = 0;\n\tout[12] = (left + right) * lr;\n\tout[13] = (top + bottom) * bt;\n\tout[14] = (far + near) * nf;\n\tout[15] = 1;\n\treturn out;\n}\n/**\n * Alias for {@link mat4.orthoNO}\n * @function\n */\n\nvar ortho = orthoNO;\n/**\n * Generates a orthogonal projection matrix with the given bounds.\n * The near/far clip planes correspond to a normalized device coordinate Z range of [0, 1],\n * which matches WebGPU/Vulkan/DirectX/Metal's clip volume.\n *\n * @param {mat4} out mat4 frustum matrix will be written into\n * @param {number} left Left bound of the frustum\n * @param {number} right Right bound of the frustum\n * @param {number} bottom Bottom bound of the frustum\n * @param {number} top Top bound of the frustum\n * @param {number} near Near bound of the frustum\n * @param {number} far Far bound of the frustum\n * @returns {mat4} out\n */\n\nfunction orthoZO(out, left, right, bottom, top, near, far) {\n\tvar lr = 1 / (left - right);\n\tvar bt = 1 / (bottom - top);\n\tvar nf = 1 / (near - far);\n\tout[0] = -2 * lr;\n\tout[1] = 0;\n\tout[2] = 0;\n\tout[3] = 0;\n\tout[4] = 0;\n\tout[5] = -2 * bt;\n\tout[6] = 0;\n\tout[7] = 0;\n\tout[8] = 0;\n\tout[9] = 0;\n\tout[10] = nf;\n\tout[11] = 0;\n\tout[12] = (left + right) * lr;\n\tout[13] = (top + bottom) * bt;\n\tout[14] = near * nf;\n\tout[15] = 1;\n\treturn out;\n}\n/**\n * Generates a look-at matrix with the given eye position, focal point, and up axis.\n * If you want a matrix that actually makes an object look at another object, you should use targetTo instead.\n *\n * @param {mat4} out mat4 frustum matrix will be written into\n * @param {ReadonlyVec3} eye Position of the viewer\n * @param {ReadonlyVec3} center Point the viewer is looking at\n * @param {ReadonlyVec3} up vec3 pointing up\n * @returns {mat4} out\n */\n\nfunction lookAt(out, eye, center, up) {\n\tvar x0, x1, x2, y0, y1, y2, z0, z1, z2, len;\n\tvar eyex = eye[0];\n\tvar eyey = eye[1];\n\tvar eyez = eye[2];\n\tvar upx = up[0];\n\tvar upy = up[1];\n\tvar upz = up[2];\n\tvar centerx = center[0];\n\tvar centery = center[1];\n\tvar centerz = center[2];\n\n\tif (\n\t\tMath.abs(eyex - centerx) < EPSILON &&\n\t\tMath.abs(eyey - centery) < EPSILON &&\n\t\tMath.abs(eyez - centerz) < EPSILON\n\t) {\n\t\treturn identity$2(out);\n\t}\n\n\tz0 = eyex - centerx;\n\tz1 = eyey - centery;\n\tz2 = eyez - centerz;\n\tlen = 1 / Math.hypot(z0, z1, z2);\n\tz0 *= len;\n\tz1 *= len;\n\tz2 *= len;\n\tx0 = upy * z2 - upz * z1;\n\tx1 = upz * z0 - upx * z2;\n\tx2 = upx * z1 - upy * z0;\n\tlen = Math.hypot(x0, x1, x2);\n\n\tif (!len) {\n\t\tx0 = 0;\n\t\tx1 = 0;\n\t\tx2 = 0;\n\t} else {\n\t\tlen = 1 / len;\n\t\tx0 *= len;\n\t\tx1 *= len;\n\t\tx2 *= len;\n\t}\n\n\ty0 = z1 * x2 - z2 * x1;\n\ty1 = z2 * x0 - z0 * x2;\n\ty2 = z0 * x1 - z1 * x0;\n\tlen = Math.hypot(y0, y1, y2);\n\n\tif (!len) {\n\t\ty0 = 0;\n\t\ty1 = 0;\n\t\ty2 = 0;\n\t} else {\n\t\tlen = 1 / len;\n\t\ty0 *= len;\n\t\ty1 *= len;\n\t\ty2 *= len;\n\t}\n\n\tout[0] = x0;\n\tout[1] = y0;\n\tout[2] = z0;\n\tout[3] = 0;\n\tout[4] = x1;\n\tout[5] = y1;\n\tout[6] = z1;\n\tout[7] = 0;\n\tout[8] = x2;\n\tout[9] = y2;\n\tout[10] = z2;\n\tout[11] = 0;\n\tout[12] = -(x0 * eyex + x1 * eyey + x2 * eyez);\n\tout[13] = -(y0 * eyex + y1 * eyey + y2 * eyez);\n\tout[14] = -(z0 * eyex + z1 * eyey + z2 * eyez);\n\tout[15] = 1;\n\treturn out;\n}\n/**\n * Generates a matrix that makes something look at something else.\n *\n * @param {mat4} out mat4 frustum matrix will be written into\n * @param {ReadonlyVec3} eye Position of the viewer\n * @param {ReadonlyVec3} center Point the viewer is looking at\n * @param {ReadonlyVec3} up vec3 pointing up\n * @returns {mat4} out\n */\n\nfunction targetTo(out, eye, target, up) {\n\tvar eyex = eye[0],\n\t\teyey = eye[1],\n\t\teyez = eye[2],\n\t\tupx = up[0],\n\t\tupy = up[1],\n\t\tupz = up[2];\n\tvar z0 = eyex - target[0],\n\t\tz1 = eyey - target[1],\n\t\tz2 = eyez - target[2];\n\tvar len = z0 * z0 + z1 * z1 + z2 * z2;\n\n\tif (len > 0) {\n\t\tlen = 1 / Math.sqrt(len);\n\t\tz0 *= len;\n\t\tz1 *= len;\n\t\tz2 *= len;\n\t}\n\n\tvar x0 = upy * z2 - upz * z1,\n\t\tx1 = upz * z0 - upx * z2,\n\t\tx2 = upx * z1 - upy * z0;\n\tlen = x0 * x0 + x1 * x1 + x2 * x2;\n\n\tif (len > 0) {\n\t\tlen = 1 / Math.sqrt(len);\n\t\tx0 *= len;\n\t\tx1 *= len;\n\t\tx2 *= len;\n\t}\n\n\tout[0] = x0;\n\tout[1] = x1;\n\tout[2] = x2;\n\tout[3] = 0;\n\tout[4] = z1 * x2 - z2 * x1;\n\tout[5] = z2 * x0 - z0 * x2;\n\tout[6] = z0 * x1 - z1 * x0;\n\tout[7] = 0;\n\tout[8] = z0;\n\tout[9] = z1;\n\tout[10] = z2;\n\tout[11] = 0;\n\tout[12] = eyex;\n\tout[13] = eyey;\n\tout[14] = eyez;\n\tout[15] = 1;\n\treturn out;\n}\n/**\n * Returns a string representation of a mat4\n *\n * @param {ReadonlyMat4} a matrix to represent as a string\n * @returns {String} string representation of the matrix\n */\n\nfunction str$5(a) {\n\t// prettier-ignore\n\treturn \"mat4(\" + a[0] + \", \" + a[1] + \", \" + a[2] + \", \" + a[3] + \", \" + a[4] + \", \" + a[5] + \", \" + a[6] + \", \" + a[7] + \", \" + a[8] + \", \" + a[9] + \", \" + a[10] + \", \" + a[11] + \", \" + a[12] + \", \" + a[13] + \", \" + a[14] + \", \" + a[15] + \")\";\n}\n/**\n * Returns Frobenius norm of a mat4\n *\n * @param {ReadonlyMat4} a the matrix to calculate Frobenius norm of\n * @returns {Number} Frobenius norm\n */\n\nfunction frob(a) {\n\t// prettier-ignore\n\treturn Math.hypot(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10], a[11], a[12], a[13], a[14], a[15]);\n}\n/**\n * Adds two mat4's\n *\n * @param {mat4} out the receiving matrix\n * @param {ReadonlyMat4} a the first operand\n * @param {ReadonlyMat4} b the second operand\n * @returns {mat4} out\n */\n\nfunction add$5(out, a, b) {\n\tout[0] = a[0] + b[0];\n\tout[1] = a[1] + b[1];\n\tout[2] = a[2] + b[2];\n\tout[3] = a[3] + b[3];\n\tout[4] = a[4] + b[4];\n\tout[5] = a[5] + b[5];\n\tout[6] = a[6] + b[6];\n\tout[7] = a[7] + b[7];\n\tout[8] = a[8] + b[8];\n\tout[9] = a[9] + b[9];\n\tout[10] = a[10] + b[10];\n\tout[11] = a[11] + b[11];\n\tout[12] = a[12] + b[12];\n\tout[13] = a[13] + b[13];\n\tout[14] = a[14] + b[14];\n\tout[15] = a[15] + b[15];\n\treturn out;\n}\n/**\n * Subtracts matrix b from matrix a\n *\n * @param {mat4} out the receiving matrix\n * @param {ReadonlyMat4} a the first operand\n * @param {ReadonlyMat4} b the second operand\n * @returns {mat4} out\n */\n\nfunction subtract$3(out, a, b) {\n\tout[0] = a[0] - b[0];\n\tout[1] = a[1] - b[1];\n\tout[2] = a[2] - b[2];\n\tout[3] = a[3] - b[3];\n\tout[4] = a[4] - b[4];\n\tout[5] = a[5] - b[5];\n\tout[6] = a[6] - b[6];\n\tout[7] = a[7] - b[7];\n\tout[8] = a[8] - b[8];\n\tout[9] = a[9] - b[9];\n\tout[10] = a[10] - b[10];\n\tout[11] = a[11] - b[11];\n\tout[12] = a[12] - b[12];\n\tout[13] = a[13] - b[13];\n\tout[14] = a[14] - b[14];\n\tout[15] = a[15] - b[15];\n\treturn out;\n}\n/**\n * Multiply each element of the matrix by a scalar.\n *\n * @param {mat4} out the receiving matrix\n * @param {ReadonlyMat4} a the matrix to scale\n * @param {Number} b amount to scale the matrix's elements by\n * @returns {mat4} out\n */\n\nfunction multiplyScalar(out, a, b) {\n\tout[0] = a[0] * b;\n\tout[1] = a[1] * b;\n\tout[2] = a[2] * b;\n\tout[3] = a[3] * b;\n\tout[4] = a[4] * b;\n\tout[5] = a[5] * b;\n\tout[6] = a[6] * b;\n\tout[7] = a[7] * b;\n\tout[8] = a[8] * b;\n\tout[9] = a[9] * b;\n\tout[10] = a[10] * b;\n\tout[11] = a[11] * b;\n\tout[12] = a[12] * b;\n\tout[13] = a[13] * b;\n\tout[14] = a[14] * b;\n\tout[15] = a[15] * b;\n\treturn out;\n}\n/**\n * Adds two mat4's after multiplying each element of the second operand by a scalar value.\n *\n * @param {mat4} out the receiving vector\n * @param {ReadonlyMat4} a the first operand\n * @param {ReadonlyMat4} b the second operand\n * @param {Number} scale the amount to scale b's elements by before adding\n * @returns {mat4} out\n */\n\nfunction multiplyScalarAndAdd(out, a, b, scale) {\n\tout[0] = a[0] + b[0] * scale;\n\tout[1] = a[1] + b[1] * scale;\n\tout[2] = a[2] + b[2] * scale;\n\tout[3] = a[3] + b[3] * scale;\n\tout[4] = a[4] + b[4] * scale;\n\tout[5] = a[5] + b[5] * scale;\n\tout[6] = a[6] + b[6] * scale;\n\tout[7] = a[7] + b[7] * scale;\n\tout[8] = a[8] + b[8] * scale;\n\tout[9] = a[9] + b[9] * scale;\n\tout[10] = a[10] + b[10] * scale;\n\tout[11] = a[11] + b[11] * scale;\n\tout[12] = a[12] + b[12] * scale;\n\tout[13] = a[13] + b[13] * scale;\n\tout[14] = a[14] + b[14] * scale;\n\tout[15] = a[15] + b[15] * scale;\n\treturn out;\n}\n\n/**\n * Returns whether the matrices have exactly the same elements in the same position (when compared with ===)\n *\n * @param {ReadonlyMat4} a The first matrix.\n * @param {ReadonlyMat4} b The second matrix.\n * @returns {Boolean} True if the matrices are equal, false otherwise.\n */\n\nfunction exactEquals$5(a, b) {\n\t// prettier-ignore\n\treturn a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3] && a[4] === b[4] && a[5] === b[5] && a[6] === b[6] && a[7] === b[7] && a[8] === b[8] && a[9] === b[9] && a[10] === b[10] && a[11] === b[11] && a[12] === b[12] && a[13] === b[13] && a[14] === b[14] && a[15] === b[15];\n}\n/**\n * Returns whether the matrices have approximately the same elements in the same position.\n *\n * @param {ReadonlyMat4} a The first matrix.\n * @param {ReadonlyMat4} b The second matrix.\n * @returns {Boolean} True if the matrices are equal, false otherwise.\n */\n\nfunction equals$5(a, b) {\n\tvar a0 = a[0],\n\t\ta1 = a[1],\n\t\ta2 = a[2],\n\t\ta3 = a[3];\n\tvar a4 = a[4],\n\t\ta5 = a[5],\n\t\ta6 = a[6],\n\t\ta7 = a[7];\n\tvar a8 = a[8],\n\t\ta9 = a[9],\n\t\ta10 = a[10],\n\t\ta11 = a[11];\n\tvar a12 = a[12],\n\t\ta13 = a[13],\n\t\ta14 = a[14],\n\t\ta15 = a[15];\n\tvar b0 = b[0],\n\t\tb1 = b[1],\n\t\tb2 = b[2],\n\t\tb3 = b[3];\n\tvar b4 = b[4],\n\t\tb5 = b[5],\n\t\tb6 = b[6],\n\t\tb7 = b[7];\n\tvar b8 = b[8],\n\t\tb9 = b[9],\n\t\tb10 = b[10],\n\t\tb11 = b[11];\n\tvar b12 = b[12],\n\t\tb13 = b[13],\n\t\tb14 = b[14],\n\t\tb15 = b[15];\n\treturn (\n\t\tMath.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) &&\n\t\tMath.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)) &&\n\t\tMath.abs(a2 - b2) <= EPSILON * Math.max(1.0, Math.abs(a2), Math.abs(b2)) &&\n\t\tMath.abs(a3 - b3) <= EPSILON * Math.max(1.0, Math.abs(a3), Math.abs(b3)) &&\n\t\tMath.abs(a4 - b4) <= EPSILON * Math.max(1.0, Math.abs(a4), Math.abs(b4)) &&\n\t\tMath.abs(a5 - b5) <= EPSILON * Math.max(1.0, Math.abs(a5), Math.abs(b5)) &&\n\t\tMath.abs(a6 - b6) <= EPSILON * Math.max(1.0, Math.abs(a6), Math.abs(b6)) &&\n\t\tMath.abs(a7 - b7) <= EPSILON * Math.max(1.0, Math.abs(a7), Math.abs(b7)) &&\n\t\tMath.abs(a8 - b8) <= EPSILON * Math.max(1.0, Math.abs(a8), Math.abs(b8)) &&\n\t\tMath.abs(a9 - b9) <= EPSILON * Math.max(1.0, Math.abs(a9), Math.abs(b9)) &&\n\t\tMath.abs(a10 - b10) <= EPSILON * Math.max(1.0, Math.abs(a10), Math.abs(b10)) &&\n\t\tMath.abs(a11 - b11) <= EPSILON * Math.max(1.0, Math.abs(a11), Math.abs(b11)) &&\n\t\tMath.abs(a12 - b12) <= EPSILON * Math.max(1.0, Math.abs(a12), Math.abs(b12)) &&\n\t\tMath.abs(a13 - b13) <= EPSILON * Math.max(1.0, Math.abs(a13), Math.abs(b13)) &&\n\t\tMath.abs(a14 - b14) <= EPSILON * Math.max(1.0, Math.abs(a14), Math.abs(b14)) &&\n\t\tMath.abs(a15 - b15) <= EPSILON * Math.max(1.0, Math.abs(a15), Math.abs(b15))\n\t);\n}\n/**\n * Alias for {@link mat4.multiply}\n * @function\n */\n\nvar mul$5 = multiply$5;\n/**\n * Alias for {@link mat4.subtract}\n * @function\n */\n\nvar sub$3 = subtract$3;\n\nexport default {\n\t__proto__: null,\n\tcreate: create$5,\n\tclone: clone$5,\n\tcopy: copy$5,\n\tfromValues: fromValues$5,\n\tset: set$5,\n\tidentity: identity$2,\n\ttranspose: transpose,\n\tinvert: invert$2,\n\tadjoint: adjoint,\n\tdeterminant: determinant,\n\tmultiply: multiply$5,\n\ttranslate: translate$1,\n\tscale: scale$5,\n\trotate: rotate$1,\n\trotateX: rotateX$3,\n\trotateY: rotateY$3,\n\trotateZ: rotateZ$3,\n\tfromTranslation: fromTranslation$1,\n\tfromScaling: fromScaling,\n\tfromRotation: fromRotation$1,\n\tfromXRotation: fromXRotation,\n\tfromYRotation: fromYRotation,\n\tfromZRotation: fromZRotation,\n\tfromRotationTranslation: fromRotationTranslation$1,\n\tfromQuat2: fromQuat2,\n\tgetTranslation: getTranslation$1,\n\tgetScaling: getScaling,\n\tgetRotation: getRotation,\n\tdecompose: decompose,\n\tfromRotationTranslationScale: fromRotationTranslationScale,\n\tfromRotationTranslationScaleOrigin: fromRotationTranslationScaleOrigin,\n\tfromQuat: fromQuat,\n\tfrustum: frustum,\n\tperspectiveNO: perspectiveNO,\n\tperspective: perspective,\n\tperspectiveZO: perspectiveZO,\n\tperspectiveFromFieldOfView: perspectiveFromFieldOfView,\n\torthoNO: orthoNO,\n\tortho: ortho,\n\torthoZO: orthoZO,\n\tlookAt: lookAt,\n\ttargetTo: targetTo,\n\tstr: str$5,\n\tfrob: frob,\n\tadd: add$5,\n\tsubtract: subtract$3,\n\tmultiplyScalar: multiplyScalar,\n\tmultiplyScalarAndAdd: multiplyScalarAndAdd,\n\texactEquals: exactEquals$5,\n\tequals: equals$5,\n\tmul: mul$5,\n\tsub: sub$3,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/highlights/annotations/annotations.ts",
    "content": "// src/client/scripts/esm/game/rendering/highlights/annotations/annotations.ts\n\n/**\n * This script manages all annotations\n * * Squares\n * * Arrows\n * * Rays\n */\n\nimport type { Ray } from '../../../../../../../shared/util/math/vectors.js';\nimport type { BDCoords, Coords } from '../../../../../../../shared/chess/util/coordutil.js';\n\nimport jsutil from '../../../../../../../shared/util/jsutil.js';\nimport bdcoords from '../../../../../../../shared/chess/util/bdcoords.js';\nimport coordutil from '../../../../../../../shared/chess/util/coordutil.js';\n\nimport gameslot from '../../../chess/gameslot.js';\nimport drawrays from './drawrays.js';\nimport keybinds from '../../../misc/keybinds.js';\nimport { Mouse } from '../../../input.js';\nimport drawarrows from './drawarrows.js';\nimport gameloader from '../../../chess/gameloader.js';\nimport drawsquares from './drawsquares.js';\nimport preferences from '../../../../components/header/preferences.js';\nimport { GameBus } from '../../../GameBus.js';\n\n// Types -----------------------------------------------------------------------\n\n/** An object storing all visible annotations for a specific ply. */\ninterface Annotes {\n\t/** First type of annotation: A square highlight. */\n\tSquares: Coords[];\n\t/** Second type of annoation: An arrow draw from one square to another. */\n\tArrows: Arrow[];\n\t/**\n\t * Third type of annotation: A ray of infinite square highlights,\n\t * starting from a square and going to infinity.\n\t */\n\tRays: Ray[];\n}\n\ntype Square = Coords;\n\n/** Second type of annoation: An arrow draw from one square to another. */\ninterface Arrow {\n\tstart: Coords;\n\tend: Coords;\n\n\t/** The bigint vector pointing from the start coords to the end coords. NOT normalized. */\n\tvector: Coords;\n\t/** The precalculated difference going from start to the end. Same as the vector, but as a BigDecimal. */\n\tdifference: BDCoords;\n\t/** The precalculated ratio of the x difference to the distance (hypotenuse, total length). Doesn't need extreme precision. */\n\txRatio: number;\n\t/** The precalculated ratio of the y difference to the distance (hypotenuse, total length). Doesn't need extreme precision. */\n\tyRatio: number;\n}\n\n// Variables -------------------------------------------------------------------\n\n/** The annotations tied to specific move plies, when lingering annotations is OFF. */\nconst annotes_plies: Annotes[] = [];\n/** The main list of annotations, when lingering annotations is ON. */\nlet annotes_linger: Annotes = getEmptyAnnotes();\n\n// Events ---------------------------------------------------------------------\n\nGameBus.addEventListener('piece-selected', () => {\n\t// Erase all the annotations of the current ply, if lingering annotations is OFF.\n\tif (preferences.getLingeringAnnotationsMode()) return; // Don't clear annotations on piece selection in this mode\n\t// Clear the annotations of the current ply\n\tconst annotes = getRelevantAnnotes();\n\tclearAnnotes(annotes);\n});\nGameBus.addEventListener('game-unloaded', () => {\n\t// Clear all user-drawn highlights\n\tresetState();\n});\n\n// Getters ---------------------------------------------------------------------\n\n/** Returns the list of all Square highlights currently visible. */\nfunction getSquares(): Coords[] {\n\treturn getRelevantAnnotes().Squares;\n}\n\n/** Returns the list of all Arrow highlights currently visible. */\nfunction getArrows(): Arrow[] {\n\treturn getRelevantAnnotes().Arrows;\n}\n\n/** Returns the list of all Ray highlights currently visible. */\nfunction getRays(): Ray[] {\n\treturn getRelevantAnnotes().Rays;\n}\n\n// Helpers ---------------------------------------------------------------------\n\n/**\n * Returns the visible annotations according to the current Lingering Annotations mode:\n * 1. OFF => Returns current ply's annotes\n * 2. ON => Returns main annotes\n */\nfunction getRelevantAnnotes(): Annotes {\n\tconst enabled = preferences.getLingeringAnnotationsMode();\n\tif (enabled) return annotes_linger;\n\telse {\n\t\tconst index = gameslot.getGamefile()!.boardsim.state.local.moveIndex + 1; // Change -1 based to 0 based index\n\t\t// Ensure its initialized\n\t\tif (!annotes_plies[index]) annotes_plies[index] = getEmptyAnnotes();\n\t\treturn annotes_plies[index];\n\t}\n}\n\n/** Event listener for when we change the Lingering Annotations mode */\ndocument.addEventListener('lingering-annotations-toggle', (e) => {\n\tif (!gameloader.areInAGame()) return;\n\tconst enabled: boolean = e.detail;\n\tconst ply = gameslot.getGamefile()!.boardsim.state.local.moveIndex + 1; // Change -1 based to 0 based index\n\tif (enabled) {\n\t\t/** Transfer annotes from the ply to {@link annotes_linger} */\n\t\tannotes_linger = jsutil.deepCopyObject(annotes_plies[ply]!);\n\t} else {\n\t\t/** Transfer annotes from {@link annotes_linger} to the current ply */\n\t\tannotes_plies[ply] = jsutil.deepCopyObject(annotes_linger);\n\t\t// Clear these\n\t\tclearAnnotes(annotes_linger);\n\t}\n});\n\n/** Returns an empty Annotes object. */\nfunction getEmptyAnnotes(): Annotes {\n\treturn { Squares: [], Arrows: [], Rays: [] };\n}\n\n/** Erases all the annotes of the provided annotations. */\nfunction clearAnnotes(annotes: Annotes): void {\n\tannotes.Squares.length = 0;\n\tannotes.Arrows.length = 0;\n\tannotes.Rays.length = 0;\n}\n\n// Functions -------------------------------------------------------------------\n\n/** Main Adds/deletes annotations */\nfunction update(): void {\n\tconst mouseKeybind = keybinds.getAnnotationMouseButton();\n\tif (mouseKeybind === undefined) return; // No button is assigned to drawing annotations currently\n\t// When this throws, we need to go into drawarrows, drawsquares, and drawrays update methods\n\t// and make it so the mouse button is accepted as an argument.\n\tif (mouseKeybind !== Mouse.RIGHT)\n\t\tthrow Error('Annote drawing only supports right mouse button.');\n\n\tconst annotes = getRelevantAnnotes();\n\n\t// Arrows first since it reads if there was a click, but Squares will claim the click.\n\tdrawarrows.update(annotes.Arrows);\n\tdrawsquares.update(annotes.Squares);\n\tdrawrays.update(annotes.Rays);\n}\n\n/**\n * Collapses all annotations. The behavior is:\n * A. Atleast 1 ray => Erase all rays and add more Squares at all their intersections.\n * B. Else => Erase all annotes.\n */\nfunction Collapse(): void {\n\tconst annotes = getRelevantAnnotes();\n\n\tif (annotes.Rays.length > 0) {\n\t\t// Collapse rays instead of erasing all annotations.\n\t\t// Can map to integer Coords since the argument we pass in ensures we only get back integer intersections.\n\t\tconst additionalSquares = drawrays\n\t\t\t.collapseRays(annotes.Rays, true)\n\t\t\t.map((i) => bdcoords.coordsToBigInt(i));\n\t\tfor (const newSquare of additionalSquares) {\n\t\t\t// Avoid adding duplicates\n\t\t\tif (annotes.Squares.every((s) => !coordutil.areCoordsEqual(s, newSquare)))\n\t\t\t\tannotes.Squares.push(newSquare);\n\t\t}\n\t\tannotes.Rays.length = 0; // Erase all rays\n\t\tdrawrays.dispatchRayCountEvent(annotes.Rays);\n\t} else clearAnnotes(annotes);\n}\n\nfunction resetState(): void {\n\tannotes_plies.length = 0;\n\tclearAnnotes(annotes_linger);\n\tdrawarrows.stopDrawing();\n\tdrawrays.stopDrawing();\n\tdrawsquares.clearPresetOverrides();\n\tdrawrays.clearPresetOverrides();\n}\n\n// Rendering ----------------------------------------------------------\n\n/** Renders the annotations that should be rendered below the pieces */\nfunction render_belowPieces(): void {\n\tconst annotes = getRelevantAnnotes();\n\tdrawsquares.render(annotes.Squares);\n\tdrawrays.render(annotes.Rays);\n}\n\nfunction render_abovePieces(): void {\n\tconst annotes = getRelevantAnnotes();\n\tdrawarrows.render(annotes.Arrows);\n}\n\n// Exports ----------------------------------------------------------\n\nexport default {\n\tgetSquares,\n\tgetArrows,\n\tgetRays,\n\n\tupdate,\n\tCollapse,\n\tresetState,\n\trender_belowPieces,\n\trender_abovePieces,\n};\n\nexport type { Square, Arrow, Ray };\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/highlights/annotations/drawarrows.ts",
    "content": "// src/client/scripts/esm/game/rendering/highlights/annotations/drawarrows.ts\n\n/**\n * This script allows the user to draw arrows on the board.\n *\n * Helpful for analysis, and requested by many.\n */\n\nimport type { Arrow } from './annotations.js';\nimport type { Color } from '../../../../../../../shared/util/math/math.js';\nimport type {\n\tBoundingBoxBD,\n\tDoubleBoundingBox,\n} from '../../../../../../../shared/util/math/bounds.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport vectors from '../../../../../../../shared/util/math/vectors.js';\nimport geometry from '../../../../../../../shared/util/math/geometry.js';\nimport bdcoords from '../../../../../../../shared/chess/util/bdcoords.js';\nimport coordutil, {\n\tBDCoords,\n\tCoords,\n\tDoubleCoords,\n} from '../../../../../../../shared/chess/util/coordutil.js';\n\nimport space from '../../../misc/space.js';\nimport mouse from '../../../../util/mouse.js';\nimport camera from '../../camera.js';\nimport snapping from '../snapping.js';\nimport boardpos from '../../boardpos.js';\nimport { Mouse } from '../../../input.js';\nimport preferences from '../../../../components/header/preferences.js';\nimport { createRenderable } from '../../../../webgl/Renderable.js';\n\n// Constants -----------------------------------------------------------------\n\n/** Properties for the drawn arrows.*/\nconst ARROW = {\n\t/** Width of the arrow's rectangular body, where 1.0 spans a full square. */\n\tBODY_WIDTH: 0.24, // Default: 0.24\n\t/** Width of the base of the arrowhead (perpendicular to arrow direction), where 1.0 spans a full square. */\n\tTIP_WIDTH: 0.55, // Default: 0.55\n\t/** Length of the arrowhead (along arrow direction), where 1.0 spans a full square. */\n\tTIP_LENGTH: 0.37, // Default: 0.37\n\t/**\n\t * The minimum desired length of the arrow's body, as a proportion of the total arrow length.\n\t * E.g., 0.5 means the body should try to be at least 50% of the total arrow length.\n\t * If the arrow is too short for both this proportional body and the ARROW.TIP_LENGTH,\n\t * both body and tip lengths will be adjusted.\n\t * Valid range: [0.0, 1.0]. 0.0 means no minimum proportional body length is enforced beyond\n\t * what's left after the tip takes ARROW.TIP_LENGTH. 1.0 means the arrow tries to be all body.\n\t */\n\tMIN_BODY_PROPORTION: 0.4, // Default: 0.4   Example: Body should be at least 30% of total arrow length\n\t/** Offset of the arrow's base from the starting coordinate, in percentage of 1 tile width. */\n\tBASE_OFFSET: 0.35,\n};\n\nconst ZERO = bd.fromBigInt(0n);\nconst ONE = bd.fromBigInt(1n);\n\n/** This will be defined if we are CURRENTLY drawing an arrow. */\nlet drag_start: Coords | undefined;\n/** The ID of the pointer that is drawing the arrow. */\nlet pointerId: string | undefined;\n/** The last known position of the pointer drawing an arrow. */\nlet pointerWorld: DoubleCoords | undefined;\n\n// Updating -----------------------------------------------------------------\n\n/**\n * Tests if the user has started/finished drawing new arrows,\n * or deleting any existing ones.\n * REQUIRES THE HOVERED HIGHLIGHTS to be updated prior to calling this!\n * @param arrows - All arrow annotations currently on the board.\n */\nfunction update(arrows: Arrow[]): void {\n\tconst respectiveListener = mouse.getRelevantListener();\n\n\tif (!drag_start) {\n\t\t// Test if right mouse down (start drawing)\n\t\tif (mouse.isMouseDown(Mouse.RIGHT) && !mouse.isMouseDoubleClickDragged(Mouse.RIGHT)) {\n\t\t\tmouse.claimMouseDown(Mouse.RIGHT); // Claim to prevent the same pointer dragging the board\n\t\t\tpointerId = respectiveListener.getMouseId(Mouse.RIGHT)!;\n\t\t\tpointerWorld = mouse.getPointerWorld(pointerId!);\n\t\t\tif (!pointerWorld) return stopDrawing(); // Maybe we're looking into sky?\n\n\t\t\tconst closestEntityToWorld = snapping.getClosestEntityToWorld(pointerWorld);\n\t\t\tconst snapCoords = snapping.getWorldSnapCoords(pointerWorld);\n\n\t\t\tif (boardpos.areZoomedOut() && (closestEntityToWorld || snapCoords)) {\n\t\t\t\tif (closestEntityToWorld) {\n\t\t\t\t\t// Snap to nearest hovered entity\n\t\t\t\t\tdrag_start = coordutil.copyCoords(closestEntityToWorld.coords);\n\t\t\t\t} else {\n\t\t\t\t\t// Snap to the current snap\n\t\t\t\t\tdrag_start = [...snapCoords!];\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// No snap\n\t\t\t\tdrag_start = space.convertWorldSpaceToCoords_Rounded(pointerWorld);\n\t\t\t}\n\t\t}\n\t} else {\n\t\t// Currently drawing an arrow\n\n\t\t// Test if pointer released (finalize arrow)\n\t\tif (respectiveListener.pointerExists(pointerId!))\n\t\t\tpointerWorld = mouse.getPointerWorld(pointerId!); // Update its last known position\n\t\tif (!respectiveListener.isPointerHeld(pointerId!)) {\n\t\t\t// Prevents accidentally drawing tiny arrows while zoomed out if we intend to draw square\n\t\t\tif (!mouse.isMouseClicked(Mouse.RIGHT)) addDrawnArrow(arrows);\n\t\t\t// else We drew a square highlight instead of an arrow\n\t\t\tstopDrawing();\n\t\t}\n\t}\n}\n\nfunction stopDrawing(): void {\n\tdrag_start = undefined;\n\tpointerId = undefined;\n\tpointerWorld = undefined;\n}\n\n/** If the given pointer is currently being used to draw an arrow, this stops using it. */\nfunction stealPointer(pointerIdToSteal: string): void {\n\tif (pointerId !== pointerIdToSteal) return; // Not the pointer drawing the arrow, don't stop using it.\n\tstopDrawing();\n}\n\n/**\n * Adds the currently drawn arrow to the list.\n * If a matching arrow already exists, that will be removed instead.\n * @param arrows - All arrows currently visible on the board.\n * @returns An object containing the results, such as whether a change was made, and what arrow was deleted if any.\n */\nfunction addDrawnArrow(arrows: Arrow[]): { changed: boolean; deletedArrow?: Arrow } {\n\tif (!pointerWorld) return { changed: false }; // Probably stopped drawing while looking into sky?\n\n\t// console.log(\"Adding drawn arrow\");\n\tlet drag_end: Coords;\n\n\tconst closestEntityToWorld = snapping.getClosestEntityToWorld(pointerWorld);\n\tconst snapCoords = snapping.getWorldSnapCoords(pointerWorld);\n\n\tif (boardpos.areZoomedOut() && (closestEntityToWorld || snapCoords)) {\n\t\tif (closestEntityToWorld) {\n\t\t\t// Snap to nearest hovered entity\n\t\t\tdrag_end = coordutil.copyCoords(closestEntityToWorld.coords);\n\t\t} else {\n\t\t\t// Snap to the current snap\n\t\t\tdrag_end = [...snapCoords!];\n\t\t}\n\t} else {\n\t\t// No snap\n\t\tdrag_end = space.convertWorldSpaceToCoords_Rounded(pointerWorld);\n\t}\n\n\t// Skip if end equals start (no arrow drawn)\n\tif (coordutil.areCoordsEqual(drag_start!, drag_end)) return { changed: false };\n\n\t// If a matching arrow already exists, remove that instead.\n\tfor (let i = 0; i < arrows.length; i++) {\n\t\tconst arrow = arrows[i]!;\n\t\tif (\n\t\t\tcoordutil.areCoordsEqual(arrow.start, drag_start!) &&\n\t\t\tcoordutil.areCoordsEqual(arrow.end, drag_end)\n\t\t) {\n\t\t\tarrows.splice(i, 1); // Remove the existing arrow\n\t\t\treturn { changed: true, deletedArrow: arrow }; // No new arrow added\n\t\t}\n\t}\n\n\t// Precalculate other arrow properties\n\n\tconst vector: Coords = coordutil.subtractCoords(drag_end, drag_start!);\n\tconst difference: BDCoords = bdcoords.FromCoords(vector);\n\t// Since the difference can be arbitrarily large, we need to normalize it\n\t// NEAR the range 0-1 (don't matter if it's not exact) so that we can use javascript numbers.\n\tconst normalizedVector: DoubleCoords = vectors.normalizeVectorBD(difference);\n\tconst normalizedVectorHypot: number = Math.hypot(...normalizedVector);\n\n\t// Add the arrow\n\tarrows.push({\n\t\tstart: drag_start!,\n\t\tend: drag_end,\n\t\tvector,\n\t\tdifference,\n\t\txRatio: normalizedVector[0] / normalizedVectorHypot,\n\t\tyRatio: normalizedVector[1] / normalizedVectorHypot,\n\t});\n\treturn { changed: true };\n}\n\n// Rendering -----------------------------------------------------------------\n\nfunction render(arrows: Arrow[]): void {\n\t// Add the arrow currently being drawn\n\tconst drawingCurrentlyDrawn = drag_start ? addDrawnArrow(arrows) : { changed: false };\n\n\t// Early exit if no arrows to draw\n\tif (arrows.length > 0) {\n\t\t// Construct the data\n\t\tconst color = preferences.getAnnoteArrowColor();\n\t\tconst data: number[] = arrows.flatMap((arrow) => getDataArrow(arrow, color));\n\n\t\t// Render\n\t\tcreateRenderable(data, 2, 'TRIANGLES', 'color', true).render(); // No transform needed\n\t}\n\n\t// Remove the arrow currently being drawn\n\tif (drawingCurrentlyDrawn.changed) {\n\t\tif (drawingCurrentlyDrawn.deletedArrow)\n\t\t\tarrows.push(drawingCurrentlyDrawn.deletedArrow); // Restore the deleted arrow if any\n\t\telse arrows.pop();\n\t}\n}\n\n/**\n * Generates vertex data for a single arrow.\n * @param startWorld - The starting coordinates [x, y] of the arrow's base (world space).\n * @param endWorld - The ending coordinates [x, y] of the arrow's tip (world space).\n * @param color - The color [r, g, b, a] of the arrow.\n * @returns The vertex data for the arrow (x,y, r,g,b,a).\n */\nfunction getDataArrow(arrow: Arrow, color: Color): number[] {\n\t// First we need to shift the arrow's base a little away from the center of the starting square.\n\n\t// The distance in squares between the start and end coordinates.\n\tconst totalLengthSquares: BigDecimal = vectors.euclideanDistance(arrow.start, arrow.end);\n\n\tconst entityWidthWorld: number = snapping.getEntityWidthWorld();\n\t// How many squares wide highlights are at this zoom distance.\n\tconst entityWidthSquares: BigDecimal = boardpos.areZoomedOut()\n\t\t? space.convertWorldSpaceToGrid(entityWidthWorld)\n\t\t: ONE;\n\n\t// The size of entities at this zoom level.\n\tconst size = boardpos.areZoomedOut() ? entityWidthWorld : boardpos.getBoardScaleAsNumber();\n\n\t// How much the arrow base is offset from the start coordinate.\n\tconst arrowBaseOffsetWorld: number = ARROW.BASE_OFFSET * size;\n\tconst arrowBaseOffsetSquares: BigDecimal = bd.multiplyFloating(\n\t\tentityWidthSquares,\n\t\tbd.fromNumber(ARROW.BASE_OFFSET),\n\t);\n\n\t// If the arrow length <= base offset, don't draw it (it would have negative length).\n\tif (bd.compare(totalLengthSquares, arrowBaseOffsetSquares) <= 0) return [];\n\n\t// Calculate the base and tip world space coordinates\n\tlet startWorld = space.convertCoordToWorldSpace(bdcoords.FromCoords(arrow.start));\n\tlet endWorld = space.convertCoordToWorldSpace(bdcoords.FromCoords(arrow.end));\n\t// Apply the base offset to the start world coordinates\n\t// so the arrow base doesn't start exactly at the center of the square.\n\tstartWorld[0] += arrow.xRatio * arrowBaseOffsetWorld;\n\tstartWorld[1] += arrow.yRatio * arrowBaseOffsetWorld;\n\n\t// -----------------------------------------------------------------------------------------\n\t// Make sure the start and end world points don't overflow to Infinity.\n\t// To resolve this, we are going to cap the start and end world points to the view distance.\n\n\tconst viewBox: DoubleBoundingBox = camera.getPerspectiveScreenBox(); // World space view box\n\t// Convert to squares\n\tconst boardPos: BDCoords = boardpos.getBoardPos();\n\tconst boardScale: BigDecimal = boardpos.getBoardScale();\n\tconst viewBoxTiles: BoundingBoxBD = {\n\t\tleft: space.convertWorldSpaceToCoords_Axis(viewBox.left, boardScale, boardPos[0]),\n\t\tright: space.convertWorldSpaceToCoords_Axis(viewBox.right, boardScale, boardPos[0]),\n\t\tbottom: space.convertWorldSpaceToCoords_Axis(viewBox.bottom, boardScale, boardPos[1]),\n\t\ttop: space.convertWorldSpaceToCoords_Axis(viewBox.top, boardScale, boardPos[1]),\n\t};\n\n\t// Now take the arrow's vector, and calculate its intersections with this box.\n\tconst intersections = geometry.findLineBoxIntersectionsBD(\n\t\tbdcoords.FromCoords(arrow.start),\n\t\tarrow.vector,\n\t\tviewBoxTiles,\n\t);\n\n\tif (intersections.length < 2) return []; // Arrow not visible on screen\n\n\t// Make sure the arrow body passes through the screen.\n\tif (!intersections[1]!.positiveDotProduct) return []; // start point lies beyond screen\n\t// Also check if the first intersection dot product of the vector pointing from the END coords is positive.\n\tconst dotProductEndToFirstIntersection = vectors.dotProductBD(\n\t\tcoordutil.subtractBDCoords(intersections[0]!.coords!, bdcoords.FromCoords(arrow.end)),\n\t\tvectors.negateBDVector(arrow.difference),\n\t);\n\tif (bd.compare(dotProductEndToFirstIntersection, ZERO) < 0) return []; // end point lies before screen\n\n\t// startWorld: Make sure it doesn't come before the first intersection.\n\t// If it does, set it to the first intersection.\n\t// To do this, we're going to have to compare dot products.\n\tconst firstIntersectionWorld = space.convertCoordToWorldSpace(intersections[0]!.coords!);\n\tconst startToFirstIntersection: DoubleCoords = coordutil.subtractDoubleCoords(\n\t\tfirstIntersectionWorld,\n\t\tstartWorld,\n\t);\n\tconst startToEnd: DoubleCoords = coordutil.subtractDoubleCoords(endWorld, startWorld);\n\tconst dotProductStart = vectors.dotProductDoubles(startToFirstIntersection, startToEnd);\n\tif (dotProductStart > 0) startWorld = firstIntersectionWorld; // startWorld lies before the first intersection, clamp it to the first intersection.\n\n\t// endWorld: Make sure it doesn't go past the last intersection.\n\t// If it does, set it to the last intersection.\n\tconst lastIntersectionWorld = space.convertCoordToWorldSpace(intersections[1]!.coords!);\n\tconst endToLastIntersection: DoubleCoords = coordutil.subtractDoubleCoords(\n\t\tlastIntersectionWorld,\n\t\tendWorld,\n\t);\n\tconst endToStart: DoubleCoords = vectors.negateDoubleVector(startToEnd);\n\tconst dotProductEnd = vectors.dotProductDoubles(endToLastIntersection, endToStart);\n\tif (dotProductEnd > 0) endWorld = lastIntersectionWorld; // endWorld lies past the last intersection, clamp it to the last intersection.\n\n\t// -----------------------------------------------------------------------------------------\n\n\t// Great! Arrow is visible on screen, and start/end world coords are clamped properly.\n\t// Now we can generate the arrow vertex data.\n\n\tconst [r, g, b, a] = color;\n\tconst vertices: number[] = [];\n\n\tconst bodyWidthArg = ARROW.BODY_WIDTH * size;\n\tconst tipWidthArg = ARROW.TIP_WIDTH * size;\n\tconst desiredTipLength = ARROW.TIP_LENGTH * size;\n\n\tconst sx = startWorld[0];\n\tconst sy = startWorld[1];\n\tconst ex = endWorld[0];\n\tconst ey = endWorld[1];\n\n\tconst dx = ex - sx;\n\tconst dy = ey - sy;\n\tconst length = vectors.euclideanDistanceDoubles(startWorld, endWorld); // World space length from base to tip\n\n\t// Helpers\n\t// prettier-ignore\n\tconst addQuad = (x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number): void => {\n\t\tvertices.push(x1, y1, r, g, b, a, x2, y2, r, g, b, a, x3, y3, r, g, b, a);\n\t\tvertices.push(x3, y3, r, g, b, a, x4, y4, r, g, b, a, x1, y1, r, g, b, a);\n\t};\n\t// prettier-ignore\n\tconst addTriangle = (x1: number, y1: number, x2: number, y2: number, x3: number, y3: number): void => {\n\t\tvertices.push(x1, y1, r, g, b, a, x2, y2, r, g, b, a, x3, y3, r, g, b, a);\n\t};\n\n\tconst ndx = dx / length; // Normalized direction vector x\n\tconst ndy = dy / length; // Normalized direction vector y\n\tconst pdx = -ndy; // Perpendicular vector x\n\tconst pdy = ndx; // Perpendicular vector y\n\n\tlet actualBodyWidth: number;\n\tlet actualTipLength: number;\n\tlet actualTipWidth: number;\n\n\t// --- Calculate actual body and tip lengths based on total length and desired proportions ---\n\n\t// Minimum body length based on its desired proportion of the total length.\n\tconst proportionallyMinBodyLength = length * ARROW.MIN_BODY_PROPORTION;\n\n\t// Length remaining for the body if the tip takes its full desiredTipLength.\n\tconst bodyLengthIfFullTip = length - desiredTipLength;\n\n\tif (bodyLengthIfFullTip >= proportionallyMinBodyLength) {\n\t\t// Case 1: Enough space for the full desiredTipLength, AND\n\t\t// the remaining body (length - desiredTipLength) meets or exceeds the proportionallyMinBodyLength.\n\t\t// This is the \"ideal\" scenario where the tip gets its desired length.\n\t\tactualTipLength = desiredTipLength;\n\t\tactualTipWidth = tipWidthArg; // Tip length is as desired, so tip width is as desired.\n\t\tactualBodyWidth = bodyWidthArg;\n\t} else {\n\t\t// Case 2: Not enough space for both full desiredTipLength AND proportionallyMinBodyLength.\n\t\t// This is the \"constrained\" scenario.\n\t\t// Body gets its proportionallyMinBodyLength.\n\t\tconst actualBodyLength = proportionallyMinBodyLength;\n\t\t// Tip gets the rest of the total length.\n\t\tactualTipLength = length - actualBodyLength;\n\t\t// Scale body width and tip width based on how their actual length compares to their desired length.\n\t\t// desiredTipLength is guaranteed > ARROW_DRAW_THRESHOLD here.\n\t\tconst ratio = actualTipLength / desiredTipLength;\n\t\tactualBodyWidth = bodyWidthArg * ratio;\n\t\tactualTipWidth = tipWidthArg * ratio;\n\t}\n\n\t// Draw Both Body and Tip\n\n\tconst halfActualTipWidth = actualTipWidth / 2;\n\tconst halfActualBodyWidth = actualBodyWidth / 2;\n\n\t// Junction point (where body meets tip base) is 'actualTipLength' back from the end point 'ex, ey'.\n\tconst tipBaseCenterX = ex - ndx * actualTipLength;\n\tconst tipBaseCenterY = ey - ndy * actualTipLength;\n\n\t// Tip vertices\n\tconst tipPointX = ex;\n\tconst tipPointY = ey; // Tip apex is at the arrow's end point\n\tconst tipWing1X = tipBaseCenterX + pdx * halfActualTipWidth;\n\tconst tipWing1Y = tipBaseCenterY + pdy * halfActualTipWidth;\n\tconst tipWing2X = tipBaseCenterX - pdx * halfActualTipWidth;\n\tconst tipWing2Y = tipBaseCenterY - pdy * halfActualTipWidth;\n\taddTriangle(tipPointX, tipPointY, tipWing1X, tipWing1Y, tipWing2X, tipWing2Y);\n\n\t// Body vertices (rectangle from startCoords to tipBaseCenter)\n\tconst bodyStartLeftX = sx + pdx * halfActualBodyWidth;\n\tconst bodyStartLeftY = sy + pdy * halfActualBodyWidth;\n\tconst bodyStartRightX = sx - pdx * halfActualBodyWidth;\n\tconst bodyStartRightY = sy - pdy * halfActualBodyWidth;\n\n\tconst bodyEndLeftX = tipBaseCenterX + pdx * halfActualBodyWidth;\n\tconst bodyEndLeftY = tipBaseCenterY + pdy * halfActualBodyWidth;\n\tconst bodyEndRightX = tipBaseCenterX - pdx * halfActualBodyWidth;\n\tconst bodyEndRightY = tipBaseCenterY - pdy * halfActualBodyWidth;\n\t// prettier-ignore\n\taddQuad(bodyStartLeftX, bodyStartLeftY, bodyEndLeftX, bodyEndLeftY, bodyEndRightX, bodyEndRightY, bodyStartRightX, bodyStartRightY);\n\n\treturn vertices;\n}\n\n// Exports -------------------------------------------------------------------\n\nexport default {\n\tupdate,\n\tstopDrawing,\n\tstealPointer,\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/highlights/annotations/drawrays.ts",
    "content": "// src/client/scripts/esm/game/rendering/highlights/annotations/drawrays.ts\n\n/**\n * This script allows the user to draw rays on the board.\n *\n * Helpful for analysis.\n */\n\nimport type { Color } from '../../../../../../../shared/util/math/math.js';\n\nimport variant from '../../../../../../../shared/chess/variants/variant.js';\nimport bdcoords from '../../../../../../../shared/chess/util/bdcoords.js';\nimport vectors, { Ray } from '../../../../../../../shared/util/math/vectors.js';\nimport geometry, { BaseRay } from '../../../../../../../shared/util/math/geometry.js';\nimport coordutil, {\n\tBDCoords,\n\tCoords,\n\tDoubleCoords,\n} from '../../../../../../../shared/chess/util/coordutil.js';\n\nimport space from '../../../misc/space.js';\nimport mouse from '../../../../util/mouse.js';\nimport meshes from '../../meshes.js';\nimport snapping from '../snapping.js';\nimport gameslot from '../../../chess/gameslot.js';\nimport boardpos from '../../boardpos.js';\nimport { Mouse } from '../../../input.js';\nimport preferences from '../../../../components/header/preferences.js';\nimport annotations from './annotations.js';\nimport legalmovemodel from '../legalmovemodel.js';\nimport highlightline, { Line } from '../highlightline.js';\nimport selectedpiecehighlightline from '../selectedpiecehighlightline.js';\n\n// Variables -----------------------------------------------------------------\n\n/** The color of preset rays for the variant. */\nconst PRESET_RAY_COLOR: Color = [1, 0.2, 0, 0.24]; // Default: 0.18   Transparent orange (makes preset rays less noticeable/distracting)\n\n/**\n * The preset ray overrides if provided from the ICN.\n * These override the variant's preset rays.\n */\nlet preset_rays: BaseRay[] | undefined;\n\n/** This will be defined if we are CURRENTLY drawing a ray. */\nlet drag_start: Coords | undefined;\n/** The ID of the pointer that is drawing the ray. */\nlet pointerId: string | undefined;\n/** The last known position of the pointer drawing a ray. */\nlet pointerWorld: DoubleCoords | undefined;\n\n// Getters -------------------------------------------------------------------\n\n/** Whether a ray is currently being drawn. */\nfunction areDrawing(): boolean {\n\treturn drag_start !== undefined;\n}\n\n/** Returns all the preset rays in the current variant. */\nfunction getPresetRays(): Ray[] {\n\tconst baseRays = preset_rays ?? variant.getRayPresets(gameslot.getGamefile()!.boardsim.variant);\n\t// Maps a list of plain rays to a new Ray list that contains their line coefficient info.\n\treturn baseRays.map((r) => {\n\t\treturn {\n\t\t\tstart: r.start,\n\t\t\tvector: r.vector,\n\t\t\tline: vectors.getLineGeneralFormFromCoordsAndVec(r.start, r.vector),\n\t\t};\n\t});\n}\n\n// Updating -----------------------------------------------------------------\n\n/**\n * Tests if the user has started/finished drawing new rays,\n * or deleting any existing ones.\n * REQUIRES THE HOVERED HIGHLIGHTS to be updated prior to calling this!\n * @param rays - All ray annotations currently on the board.\n */\nfunction update(rays: Ray[]): void {\n\tconst respectiveListener = mouse.getRelevantListener();\n\n\tif (!drag_start) {\n\t\t// Not currently drawing a ray\n\t\tif (mouse.isMouseDoubleClickDragged(Mouse.RIGHT)) {\n\t\t\t// Double click drag this frame\n\t\t\tmouse.claimMouseDown(Mouse.RIGHT); // Claim to prevent the same pointer dragging the board\n\t\t\tpointerId = respectiveListener.getMouseId(Mouse.RIGHT)!;\n\t\t\tpointerWorld = mouse.getPointerWorld(pointerId!);\n\t\t\tif (!pointerWorld) return stopDrawing(); // Could have double click dragged while looking into sky?\n\n\t\t\tconst closestEntityToWorld = snapping.getClosestEntityToWorld(pointerWorld);\n\t\t\tconst snapCoords = snapping.getWorldSnapCoords(pointerWorld);\n\n\t\t\tif ((boardpos.areZoomedOut() && closestEntityToWorld) || snapCoords) {\n\t\t\t\tif (snapCoords) drag_start = coordutil.copyCoords(snapCoords);\n\t\t\t\telse if (closestEntityToWorld) {\n\t\t\t\t\t// Snap to nearest hovered entity\n\t\t\t\t\tdrag_start = coordutil.copyCoords(closestEntityToWorld.coords);\n\t\t\t\t} else throw Error('How did we get here?');\n\t\t\t} else {\n\t\t\t\t// No snap\n\t\t\t\tdrag_start = space.convertWorldSpaceToCoords_Rounded(pointerWorld);\n\t\t\t}\n\t\t\t// console.log(\"Ray drag start:\", drag_start);\n\t\t}\n\t} else {\n\t\t// Currently drawing a ray\n\n\t\t// Test if pointer released (finalize ray)\n\t\t// If not released, delete any Square present on the Ray start\n\t\tif (respectiveListener.pointerExists(pointerId!))\n\t\t\tpointerWorld = mouse.getPointerWorld(pointerId!); // Update its last known position\n\t\tif (respectiveListener.isPointerHeld(pointerId!)) {\n\t\t\t// Pointer is still holding\n\t\t\tif (!pointerWorld) return; // Maybe we're looking into sky?\n\t\t\tconst pointerCoords = space.convertWorldSpaceToCoords_Rounded(pointerWorld);\n\t\t\t// If the mouse coords is different from the drag start, now delete any Squares off of the start coords of the ray.\n\t\t\t// This prevents the start coord from being highlighted too opaque.\n\t\t\tif (!coordutil.areCoordsEqual(pointerCoords, drag_start!)) {\n\t\t\t\tconst squares = annotations.getSquares();\n\t\t\t\tconst index = squares.findIndex((coords) =>\n\t\t\t\t\tcoordutil.areCoordsEqual(coords, drag_start!),\n\t\t\t\t);\n\t\t\t\tif (index !== -1) {\n\t\t\t\t\tsquares.splice(index, 1); // Remove the square highlight\n\t\t\t\t\t// console.log(\"Removed square highlight.\");\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// The pointer is no longer being held\n\t\t\t// Prevents accidentally ray drawing if we intend to draw square\n\t\t\tif (!mouse.isMouseClicked(Mouse.RIGHT)) {\n\t\t\t\taddDrawnRay(rays); // Finalize the ray\n\t\t\t\tdispatchRayCountEvent(rays);\n\t\t\t}\n\t\t\tstopDrawing();\n\t\t}\n\t}\n}\n\nfunction getPointerId(): string {\n\tif (!pointerId)\n\t\tthrow Error(\n\t\t\t\"Pointer ID is undefined. Don't call drawrays.getPointerId() if not drawing a ray.\",\n\t\t);\n\treturn pointerId;\n}\n\nfunction stopDrawing(): void {\n\tdrag_start = undefined;\n\tpointerId = undefined;\n\tpointerWorld = undefined;\n}\n\n/** If the given pointer is currently being used to draw a ray, this stops using it. */\nfunction stealPointer(pointerIdToSteal: string): void {\n\tif (pointerId !== pointerIdToSteal) return; // Not the pointer drawing the ray, don't stop using it.\n\tstopDrawing();\n}\n\n/** Returns all the Rays converted to Lines, which are rendered easily. */\nfunction getLines(rays: Ray[], color: Color): Line[] {\n\tconst boundingBox = highlightline.getRenderRange();\n\n\tconst lines: Line[] = [];\n\tfor (const ray of rays) {\n\t\tconst rayStartBD = bdcoords.FromCoords(ray.start);\n\n\t\t// Find the points it intersects the screen\n\t\tconst intersectionPoints = geometry.findLineBoxIntersectionsBD(\n\t\t\trayStartBD,\n\t\t\tray.vector,\n\t\t\tboundingBox,\n\t\t);\n\t\tif (intersectionPoints.length < 2) continue; // Ray has no intersections with screen, not visible, don't render.\n\t\tif (\n\t\t\t!intersectionPoints[0]!.positiveDotProduct &&\n\t\t\t!intersectionPoints[1]!.positiveDotProduct\n\t\t)\n\t\t\tcontinue; // Ray STARTS off screen and goes in the opposite direction. Not visible.\n\n\t\tconst start = intersectionPoints[0]!.positiveDotProduct\n\t\t\t? intersectionPoints[0]!.coords\n\t\t\t: rayStartBD;\n\n\t\tlines.push({\n\t\t\tstart,\n\t\t\tend: intersectionPoints[1]!.coords,\n\t\t\tcoefficients: ray.line,\n\t\t\tcolor,\n\t\t});\n\t}\n\n\treturn lines;\n}\n\n/**\n * Adds the currently drawn ray to the list.\n * If a matching ray already exists, that will be removed instead.\n * Any coincident rays are removed.\n * @param rays - All rays currently visible on the board.\n * @returns An object containing the results, such as whether the ray was added, and what rays were deleted if any.\n */\nfunction addDrawnRay(rays: Ray[]): { added: boolean; deletedRays?: Ray[] } {\n\tif (!pointerWorld) return { added: false }; // Probably stopped drawing while looking into sky?\n\n\tconst drag_end = space.convertWorldSpaceToCoords_Rounded(pointerWorld);\n\n\t// Skip if end equals start (no ray drawn)\n\tif (coordutil.areCoordsEqual(drag_start!, drag_end)) return { added: false };\n\n\t// const vector_unnormalized = coordutil.subtractCoords(drag_end, drag_start!);\n\tconst mouseTileCoords = space.convertWorldSpaceToCoords(pointerWorld);\n\tconst vector_unnormalized = coordutil.subtractBDCoords(\n\t\tmouseTileCoords,\n\t\tbdcoords.FromCoords(drag_start!),\n\t);\n\tconst vector = findClosestPredefinedVector(\n\t\tvector_unnormalized,\n\t\tgameslot.getGamefile()!.boardsim.pieces.hippogonalsPresent,\n\t);\n\tconst line = vectors.getLineGeneralFormFromCoordsAndVec(drag_start!, vector);\n\n\tconst deletedRays: Ray[] = [];\n\n\t// If any existing rays are coincident, remove those.\n\tfor (let i = rays.length - 1; i >= 0; i--) {\n\t\t// Iterate backwards since we're modifying the list as we go\n\t\tconst ray = rays[i]!;\n\t\tif (!coordutil.areCoordsEqual(ray.vector, vector)) continue; // Not parallel (assumes vectors are normalized)\n\t\tif (coordutil.areCoordsEqual(ray.start, drag_start!)) {\n\t\t\t// Identical, erase the existing one instead.\n\t\t\trays.splice(i, 1); // Remove the existing ray\n\t\t\tdeletedRays.push(ray);\n\t\t\t// console.log(\"Erasing ray.\");\n\t\t\treturn { added: false, deletedRays };\n\t\t}\n\t\tconst line2 = ray.line;\n\t\tif (vectors.areLinesInGeneralFormEqual(line, line2)) {\n\t\t\t// Coincident\n\t\t\t// Calculate the dot product the ray's vectors.\n\t\t\t// If it's positive, they point in the same direction, otherwise opposite.\n\t\t\tconst dotProd = vectors.dotProduct(vector, ray.vector);\n\t\t\tif (dotProd > 0) {\n\t\t\t\t// Positive, they point in same direction\n\t\t\t\t// Which one is contained in the other?\n\t\t\t\tconst vecToComparingRayStart = coordutil.subtractCoords(ray.start, drag_start!);\n\t\t\t\tconst dotProd2 = vectors.dotProduct(vector, vecToComparingRayStart);\n\t\t\t\tif (dotProd2 > 0) {\n\t\t\t\t\t// Positive = comparing ray is contained within the new ray\n\t\t\t\t\t// Remove this comparing ray in favor of the new one\n\t\t\t\t\trays.splice(i, 1);\n\t\t\t\t\tdeletedRays.push(ray);\n\t\t\t\t\t// console.log(\"Removed ray in favor of new.\");\n\t\t\t\t} else {\n\t\t\t\t\t// Skip adding the new one (it already exists contained in this comparing one)\n\t\t\t\t\t// console.log(\"Ray is already contained in another.\");\n\t\t\t\t\tif (deletedRays.length > 0)\n\t\t\t\t\t\tthrow Error(\n\t\t\t\t\t\t\t'Should not be any rays deleted if ray to be added is contained within another!',\n\t\t\t\t\t\t);\n\t\t\t\t\treturn { added: false };\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Negative, they point in opposite directions\n\t\t\t\t// Keep both\n\t\t\t\tconsole.log('Rays point in opposite directions.');\n\t\t\t}\n\t\t}\n\t}\n\n\t// Add the ray\n\tconst ray = { start: drag_start!, vector, line };\n\trays.push(ray);\n\t// console.log(\"Added ray:\", ray);\n\treturn { added: true, deletedRays };\n}\n\n/**\n * Finds the VECTOR whose angle most closely matches the angle of the given targetVector.\n * This helps us snap the ray's direction to a slide direction in the game.\n */\nfunction findClosestPredefinedVector(targetVector: BDCoords, searchHippogonals: boolean): Coords {\n\t// Since the targetVector can be arbitrarily large, we need to normalize it\n\t// NEAR the range 0-1 (don't matter if it's not exact) so that we can use javascript numbers.\n\tconst normalizedVector = vectors.normalizeVectorBD(targetVector);\n\n\t// Now we can use small numbers\n\tconst targetAngle = Math.atan2(normalizedVector[1], normalizedVector[0]); // Y value first\n\n\t// prettier-ignore\n\tconst searchVectors: Coords[] = searchHippogonals ? [\n\t\t...vectors.VECTORS_ORTHOGONAL,\n\t\t...vectors.VECTORS_DIAGONAL,\n\t\t...vectors.VECTORS_HIPPOGONAL\n\t] : [\n\t\t...vectors.VECTORS_ORTHOGONAL,\n\t\t...vectors.VECTORS_DIAGONAL\n\t];\n\t// Add the negation of all vectors\n\tfor (let i = searchVectors.length - 1; i >= 0; i--) {\n\t\tsearchVectors.push(vectors.negateVector(searchVectors[i]!));\n\t}\n\n\tlet minAbsoluteAngleDifference = Infinity;\n\t// Initialize with the first vector\n\tlet closestVector: Coords = searchVectors[0]!;\n\n\tfor (const predefinedVector of searchVectors) {\n\t\tconst predifinedVectorDouble: DoubleCoords =\n\t\t\tvectors.convertVectorToDoubles(predefinedVector);\n\t\tconst angle = Math.atan2(predifinedVectorDouble[1], predifinedVectorDouble[0]);\n\t\t// Calculate the difference in angles\n\t\tlet angleDifferenceRad = targetAngle - angle;\n\n\t\t// Normalize angleDifferenceRad to the shortest signed angle in the range [-PI, PI].\n\t\t// This ensures that angles like -179 deg and 179 deg are considered close (2 deg diff), not far (358 deg diff).\n\t\t// Example: diff = 350 deg (almost 2PI). Normalized: -10 deg.\n\t\t//          diff = -350 deg. Normalized: 10 deg.\n\t\tangleDifferenceRad =\n\t\t\tangleDifferenceRad - 2 * Math.PI * Math.round(angleDifferenceRad / (2 * Math.PI));\n\n\t\tconst currentAbsoluteAngleDifference = Math.abs(angleDifferenceRad);\n\n\t\tif (currentAbsoluteAngleDifference < minAbsoluteAngleDifference) {\n\t\t\tminAbsoluteAngleDifference = currentAbsoluteAngleDifference;\n\t\t\tclosestVector = predefinedVector;\n\t\t}\n\t}\n\n\treturn closestVector;\n}\n\n/**\n * Collapses all existing rays into a list of intersection coords points.\n *\n * This includes all drawn ray starts, all intersections between drawn & all rays,\n * and all intersections between drawn rays and the selected piece's legal move rays/segments.\n */\nfunction collapseRays(rays_drawn: Ray[], trimDecimals: boolean): BDCoords[] {\n\tconst intersections: BDCoords[] = [];\n\n\tconst rays_preset = getPresetRays();\n\tconst rays_all: Ray[] = [...rays_drawn, ...rays_preset];\n\n\tif (rays_all.length === 0) return intersections;\n\n\t// First add the start coords of all rays to the list of intersections\n\tfor (const ray of rays_drawn) addSquare_NoDuplicates(bdcoords.FromCoords(ray.start));\n\n\t// Then add all the intersection points of the rays (drawn against drawn + preset, SKIP preset against preset)\n\tfor (let a = 0; a < rays_drawn.length; a++) {\n\t\tconst ray1 = rays_drawn[a]!; // Gauranteed drawn ray\n\t\tfor (let b = a + 1; b < rays_all.length; b++) {\n\t\t\tconst ray2 = rays_all[b]!; // Could be drawn or preset ray\n\n\t\t\t// Calculate where they intersect\n\t\t\tconst intsect = geometry.intersectRays(ray1, ray2);\n\t\t\tif (intsect === undefined) continue; // No intersection, skip.\n\n\t\t\t// Verify the intersection point is an integer\n\t\t\tif (trimDecimals && !bdcoords.areCoordsIntegers(intsect)) continue; // Not an integer, don't collapse.\n\t\t\t// OPTIONAL: Floor() the coords and add it anyway, even if not integer.\n\t\t\t// intsect = space.roundCoords(intsect);\n\n\t\t\t// Push it to the collapsed coord intersections if there isn't a duplicate already\n\t\t\taddSquare_NoDuplicates(intsect);\n\t\t}\n\t}\n\n\t// Add all the intersection points of the drawn rays with all\n\t// the components of the selected piece's legal move lines.\n\n\tconst { rays: selectedPieceRays, segments: selectedPieceSegments } =\n\t\tselectedpiecehighlightline.getLineComponents();\n\n\tfor (const ray of rays_all) {\n\t\t// Selected piece legal move RAYS\n\t\tfor (const legalRay of selectedPieceRays) {\n\t\t\tconst intsect = geometry.intersectRays(ray, legalRay);\n\t\t\tif (intsect === undefined) continue; // No intersection, skip.\n\n\t\t\t// Verify the intersection point is an integer\n\t\t\tif (trimDecimals && !bdcoords.areCoordsIntegers(intsect)) continue; // Not an integer, don't collapse.\n\n\t\t\t// Push it to the collapsed coord intersections if there isn't a duplicate already\n\t\t\taddSquare_NoDuplicates(intsect);\n\t\t}\n\t\t// Selected piece legal move SEGMENTS\n\t\tfor (const segment of selectedPieceSegments) {\n\t\t\tconst intsect = geometry.intersectRayAndSegment(ray, segment.start, segment.end);\n\t\t\tif (intsect === undefined) continue; // No intersection, skip.\n\n\t\t\t// Verify the intersection point is an integer\n\t\t\tif (trimDecimals && !bdcoords.areCoordsIntegers(intsect)) continue; // Not an integer, don't collapse.\n\n\t\t\t// Push it to the collapsed coord intersections if there isn't a duplicate already\n\t\t\taddSquare_NoDuplicates(intsect);\n\t\t}\n\t}\n\n\tfunction addSquare_NoDuplicates(coords: BDCoords): void {\n\t\tif (intersections.every((coords2) => !coordutil.areBDCoordsEqual(coords, coords2)))\n\t\t\tintersections.push(coords);\n\t}\n\n\treturn intersections;\n}\n\nfunction dispatchRayCountEvent(rays: Ray[]): void {\n\tdocument.dispatchEvent(new CustomEvent('ray-count-change', { detail: rays.length }));\n}\n\n/**\n * Sets the preset rays, if they were specified in the ICN.\n * These override the variant's preset rays.\n */\nfunction setPresetOverrides(prs: BaseRay[]): void {\n\tif (preset_rays)\n\t\tthrow Error('Preset rays already initialized. Did you forget to clearPresetOverrides()?');\n\tpreset_rays = prs;\n}\n\n/** Returns the preset ray overrides from the ICN. */\nfunction getPresetOverrides(): BaseRay[] | undefined {\n\treturn preset_rays;\n}\n\n/** Clears the preset ray overrides from the ICN. */\nfunction clearPresetOverrides(): void {\n\tpreset_rays = undefined;\n}\n\n// Rendering -----------------------------------------------------------------\n\n/** Renders all existing rays, including preset rays. */\nfunction render(rays: Ray[]): void {\n\t// Add the ray currently being drawn\n\tconst drawingCurrentlyDrawn = drag_start ? addDrawnRay(rays) : { added: false };\n\n\tconst presetRays = getPresetRays();\n\n\tconst drawnRaysColor = preferences.getAnnoteSquareColor();\n\tconst presetRaysColor: Color = [...PRESET_RAY_COLOR];\n\n\tgenAndRenderRays(rays, drawnRaysColor);\n\tgenAndRenderRays(presetRays, presetRaysColor);\n\n\t// Remove the ray currently being drawn\n\tif (drawingCurrentlyDrawn.added) rays.pop();\n\t// Restore the deleted rays if any\n\tif (drawingCurrentlyDrawn.deletedRays) rays.push(...drawingCurrentlyDrawn.deletedRays);\n}\n\n/** Generates and renders a model for the given rays and color. */\nfunction genAndRenderRays(rays: Ray[], color: Color): void {\n\tif (rays.length === 0) return; // Nothing to render\n\n\tif (boardpos.areZoomedOut()) {\n\t\t// Zoomed out, render rays as highlight lines\n\t\tcolor[3] = 1; // Highlightlines are fully opaque\n\t\tconst lines = getLines(rays, color);\n\t\thighlightline.genLinesModel(lines).render();\n\t} else {\n\t\t// Zoomed in, render rays as infinite legal move highlights\n\t\tconst { position, scale } = meshes.getBoardRenderTransform(legalmovemodel.getOffset());\n\n\t\tlegalmovemodel.genModelForRays(rays, color).render(position, scale);\n\t}\n}\n\n// Exports -------------------------------------------------------------------\n\nexport default {\n\tPRESET_RAY_COLOR,\n\tareDrawing,\n\tgetPresetRays,\n\tupdate,\n\tgetPointerId,\n\tstealPointer,\n\tstopDrawing,\n\tgetLines,\n\tfindClosestPredefinedVector,\n\tcollapseRays,\n\tdispatchRayCountEvent,\n\tsetPresetOverrides,\n\tgetPresetOverrides,\n\tclearPresetOverrides,\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/highlights/annotations/drawsquares.ts",
    "content": "// src/client/scripts/esm/game/rendering/highlights/annotations/drawsquares.ts\n\n/**\n * This script allows the user to highlight squares on the board.\n *\n * Helpful for analysis, and requested by many.\n */\n\nimport type { Color } from '../../../../../../../shared/util/math/math.js';\nimport type { Square } from './annotations.js';\nimport type { Coords, DoubleCoords } from '../../../../../../../shared/chess/util/coordutil.js';\n\nimport vectors from '../../../../../../../shared/util/math/vectors.js';\nimport variant from '../../../../../../../shared/chess/variants/variant.js';\nimport bdcoords from '../../../../../../../shared/chess/util/bdcoords.js';\nimport coordutil from '../../../../../../../shared/chess/util/coordutil.js';\n\nimport space from '../../../misc/space.js';\nimport mouse from '../../../../util/mouse.js';\nimport snapping from '../snapping.js';\nimport boardpos from '../../boardpos.js';\nimport gameslot from '../../../chess/gameslot.js';\nimport guipause from '../../../gui/guipause.js';\nimport { Mouse } from '../../../input.js';\nimport preferences from '../../../../components/header/preferences.js';\nimport squarerendering from '../squarerendering.js';\n\n// Constants -----------------------------------------------------------------\n\n/** The color of preset squares for the variant. */\nconst PRESET_SQUARE_COLOR: Color = [1, 0.2, 0, 0.24]; // Default: 0.19   Transparent orange (makes preset squares less noticeable/distracting)\n\n/**\n * To make single Square highlight more visible than rays (which\n * include a LOT of squares), lone squares get an opacity offset.\n */\nconst OPACITY_OFFSET = 0.08;\n\n/** ADDITONAL (not overriding) opacity when hovering over highlights. */\nconst HOVER_OPACITY = 0.5;\n\n// Variables -----------------------------------------------------------------\n\n/**\n * The preset square overrides if provided from the ICN.\n * These override the variant's preset squares.\n */\nlet preset_squares: Square[] | undefined;\n\n// Updating -----------------------------------------------------------------\n\n/** Returns a list of all square highlights being hovered over by any pointer. */\nfunction getAllSquaresHovered(highlights: Square[]): Coords[] {\n\tconst allHovered: Square[] = [];\n\n\tfor (const pointerWorld of mouse.getAllPointerWorlds()) {\n\t\tconst hovered = getSquaresBelowWorld(highlights, pointerWorld, false).squares;\n\t\thovered.forEach((coords) => {\n\t\t\t// Prevent duplicates\n\t\t\tif (!allHovered.some((c) => coordutil.areCoordsEqual(c, coords)))\n\t\t\t\tallHovered.push(coords);\n\t\t});\n\t}\n\treturn allHovered;\n}\n\n/** Returns a list of Square highlight coordinates that are all being hovered over by the provided world coords. */\nfunction getSquaresBelowWorld(\n\thighlights: Square[],\n\tworld: DoubleCoords,\n\ttrackDists: boolean,\n): { squares: Coords[]; dists?: number[] } {\n\tconst squares: Square[] = [];\n\tconst dists: number[] = [];\n\n\tconst entityHalfWidthWorld = snapping.getEntityWidthWorld() / 2;\n\n\t// Iterate through each highlight to see if the mouse world is within ENTITY_WIDTH_VPIXELS of it\n\thighlights.forEach((coords) => {\n\t\tconst coordsWorld = space.convertCoordToWorldSpace(bdcoords.FromCoords(coords));\n\t\tconst dist_cheby = vectors.chebyshevDistanceDoubles(coordsWorld, world);\n\t\tif (dist_cheby < entityHalfWidthWorld) {\n\t\t\tsquares.push(coords);\n\t\t\t// Upgrade the distance to euclidean\n\t\t\tif (trackDists) dists.push(vectors.euclideanDistanceDoubles(coordsWorld, world));\n\t\t}\n\t});\n\n\tif (trackDists) return { squares, dists };\n\telse return { squares };\n}\n\n/**\n * Tests if the user has added any new square highlights,\n * or deleted any existing ones.\n * REQUIRES THE HOVERED HIGHLIGHTS to be updated prior to calling this!\n * @param highlights - All square highlights currently on the board.\n */\nfunction update(highlights: Square[]): void {\n\t// If the pointer simulated a right click, add a highlight!\n\tif (mouse.isMouseClicked(Mouse.RIGHT)) {\n\t\tmouse.claimMouseClick(Mouse.RIGHT); // Claim the click so other scripts don't also use it\n\t\tconst pointerWorld = mouse.getMouseWorld(Mouse.RIGHT);\n\t\tif (!pointerWorld) return; // Maybe we're looking into sky?\n\t\tconst pointerSquare: Coords = space.convertWorldSpaceToCoords_Rounded(pointerWorld);\n\n\t\tconst closestEntityToWorld = snapping.getClosestEntityToWorld(pointerWorld);\n\t\tconst snapCoords = snapping.getWorldSnapCoords(pointerWorld);\n\n\t\tif (boardpos.areZoomedOut() && (closestEntityToWorld || snapCoords)) {\n\t\t\t// Zoomed out & snapping one thing => Snapping behavior\n\t\t\tif (closestEntityToWorld) {\n\t\t\t\t// Now that we have the closest hovered entity, toggle the highlight on its coords.\n\t\t\t\tconst index = highlights.findIndex((coords) =>\n\t\t\t\t\tcoordutil.areCoordsEqual(coords, closestEntityToWorld.coords),\n\t\t\t\t);\n\t\t\t\tif (index !== -1)\n\t\t\t\t\thighlights.splice(index, 1); // Already highlighted, Remove\n\t\t\t\telse highlights.push(closestEntityToWorld.coords); // Add\n\t\t\t} else if (snapCoords) {\n\t\t\t\t// Toggle the highlight on its coords.\n\t\t\t\tconst index = highlights.findIndex((coords) =>\n\t\t\t\t\tcoordutil.areCoordsEqual(coords, snapCoords),\n\t\t\t\t);\n\t\t\t\tif (index !== -1)\n\t\t\t\t\tthrow Error(\n\t\t\t\t\t\t'Snap is present, but the highlight already exists. If it exists than it should have been snapped to.',\n\t\t\t\t\t);\n\t\t\t\thighlights.push(snapCoords); // Add\n\t\t\t} else throw Error('Snapping behavior but no snapCoords or hovered entity found.');\n\t\t} else {\n\t\t\t// Zoomed in OR zoomed out with no snap => Normal behavior\n\t\t\t// Check if the square is already highlighted\n\t\t\tconst index = highlights.findIndex((coords) =>\n\t\t\t\tcoordutil.areCoordsEqual(coords, pointerSquare),\n\t\t\t);\n\n\t\t\tif (index !== -1)\n\t\t\t\thighlights.splice(index, 1); // Remove\n\t\t\telse highlights.push(pointerSquare); // Add\n\t\t}\n\t}\n}\n\n/**\n * Sets the preset squares, if they were specified in the ICN.\n * These override the variant's preset squares.\n */\nfunction setPresetOverrides(pss: Coords[]): void {\n\tif (preset_squares)\n\t\tthrow Error(\n\t\t\t'Preset squares already initialized. Did you forget to clearPresetOverrides()?',\n\t\t);\n\tpreset_squares = pss;\n}\n\n/** Returns the preset square overrides from the ICN. */\nfunction getPresetOverrides(): Coords[] | undefined {\n\treturn preset_squares;\n}\n\n/** Clears the preset ray overrides from the ICN. */\nfunction clearPresetOverrides(): void {\n\tpreset_squares = undefined;\n}\n\n// Rendering -----------------------------------------------------------------\n\nfunction render(highlights: Square[]): void {\n\tconst presetSquares =\n\t\tpreset_squares ?? variant.getSquarePresets(gameslot.getGamefile()!.boardsim.variant);\n\n\t// If we're zoomed out, then the size of the highlights is constant.\n\tconst u_size = boardpos.areZoomedOut()\n\t\t? snapping.getEntityWidthWorld()\n\t\t: boardpos.getBoardScaleAsNumber();\n\n\t// Render preset squares (only if zoomed in)\n\tif (!boardpos.areZoomedOut() && presetSquares.length > 0)\n\t\tsquarerendering\n\t\t\t.genModel(presetSquares, PRESET_SQUARE_COLOR)\n\t\t\t.render(undefined, undefined, { u_size });\n\n\t// Early exit if no drawn-squares to draw\n\tif (highlights.length === 0) return;\n\n\t// Render main highlights\n\tconst color = preferences.getAnnoteSquareColor();\n\tcolor[3] += OPACITY_OFFSET; // Add opacity offset to make it more visible than rays\n\n\tsquarerendering.genModel(highlights, color).render(undefined, undefined, { u_size });\n\n\t// Render hovered highlights\n\n\tif (!boardpos.areZoomedOut() || guipause.areWePaused()) return; // Don't increase opacity of highlighgts when zoomed in\n\n\tconst allHovered = getAllSquaresHovered(highlights);\n\tif (allHovered.length > 0) {\n\t\tconst hoverColor = preferences.getAnnoteSquareColor();\n\t\thoverColor[3] = HOVER_OPACITY;\n\t\tsquarerendering.genModel(allHovered, hoverColor).render(undefined, undefined, { u_size });\n\t}\n}\n\n// Exports -------------------------------------------------------------------\n\nexport default {\n\tPRESET_SQUARE_COLOR,\n\tHOVER_OPACITY,\n\tupdate,\n\tgetAllSquaresHovered,\n\tgetSquaresBelowWorld,\n\tsetPresetOverrides,\n\tgetPresetOverrides,\n\tclearPresetOverrides,\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/highlights/checkhighlight.ts",
    "content": "// src/client/scripts/esm/game/rendering/highlights/checkhighlight.ts\n\n/**\n * This script renders the red glow surrounding\n * royal pieces currently in check.\n */\n\nimport type { Board } from '../../../../../../shared/chess/logic/gamefile.js';\nimport type { Color } from '../../../../../../shared/util/math/math.js';\nimport type { BDCoords, Coords } from '../../../../../../shared/chess/util/coordutil.js';\n\nimport bdcoords from '../../../../../../shared/chess/util/bdcoords.js';\nimport gamefileutility from '../../../../../../shared/chess/util/gamefileutility.js';\n\nimport space from '../../misc/space.js';\nimport boardpos from '../boardpos.js';\nimport primitives from '../primitives.js';\nimport preferences from '../../../components/header/preferences.js';\nimport { Renderable, createRenderable } from '../../../webgl/Renderable.js';\n\n// Functions -----------------------------------------------------------------------\n\n/**\n * Renders the red glow around all pieces in check on the currently-viewed move.\n */\nfunction render(boardsim: Board): void {\n\tconst royalsInCheck = gamefileutility.getCheckCoordsOfCurrentViewedPosition(boardsim);\n\tif (royalsInCheck.length === 0) return; // Nothing in check\n\n\tconst model = genCheckHighlightModel(royalsInCheck);\n\tmodel.render();\n}\n\n/**\n * Generates the buffer model of the red-glow around each royal piece currently in check.\n */\nfunction genCheckHighlightModel(royalsInCheck: Coords[]): Renderable {\n\tconst color = preferences.getCheckHighlightColor(); // [r,g,b,a]\n\tconst colorOfPerimeter: Color = [color[0], color[1], color[2], 0]; // Same color, but zero opacity\n\n\tconst outRad = 0.65 * boardpos.getBoardScaleAsNumber();\n\tconst inRad = 0.3 * boardpos.getBoardScaleAsNumber();\n\tconst resolution = 20;\n\n\tconst data: number[] = [];\n\tfor (let i = 0; i < royalsInCheck.length; i++) {\n\t\tconst thisRoyalInCheckCoordsBD: BDCoords = bdcoords.FromCoords(royalsInCheck[i]!);\n\t\t// This currently doesn't work for squareCenters other than 0.5. I will need to add + 0.5 - board.getSquareCenter()\n\t\t// Create a math function for returning the world-space point of the CENTER of the provided coordinate!\n\t\tconst worldSpaceCoord = space.convertCoordToWorldSpace(thisRoyalInCheckCoordsBD);\n\t\tconst x = worldSpaceCoord[0];\n\t\tconst y = worldSpaceCoord[1];\n\n\t\tconst dataCircle: number[] = primitives.Circle(x, y, inRad, resolution, color);\n\t\t// prettier-ignore\n\t\tconst dataRing: number[] = primitives.Ring(x, y, inRad, outRad, resolution, color, colorOfPerimeter);\n\t\tdata.push(...dataCircle);\n\t\tdata.push(...dataRing);\n\t}\n\n\treturn createRenderable(data, 2, 'TRIANGLES', 'color', true);\n}\n\nexport default {\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/highlights/highlightline.ts",
    "content": "// src/client/scripts/esm/game/rendering/highlights/highlightline.ts\n\n/**\n * This script renders our single-line legal sliding moves\n * when we are zoomed out far.\n */\n\nimport type { Color } from '../../../../../../shared/util/math/math.js';\nimport type { BDCoords } from '../../../../../../shared/chess/util/coordutil.js';\nimport type { BoundingBoxBD } from '../../../../../../shared/util/math/bounds.js';\nimport type { LineCoefficients } from '../../../../../../shared/util/math/vectors.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport space from '../../misc/space.js';\nimport boardpos from '../boardpos.js';\nimport boardtiles from '../boardtiles.js';\nimport perspective from '../perspective.js';\nimport { Renderable, createRenderable } from '../../../webgl/Renderable.js';\n\n/**\n * A single highlight line.\n *\n * Coords are clamped to screen edge, since\n * we can't render lines out to infinity.\n */\ninterface Line {\n\t/** The starting point coords. May have floating point innaccuracy. */\n\tstart: BDCoords;\n\t/** The ending point coords. May have floating point innaccuracy. */\n\tend: BDCoords;\n\t/** The equation of the line in general form. [A,B,C]. PERFECT integers, use this for calculating intersections. */\n\tcoefficients: LineCoefficients;\n\t/** The color of the line. */\n\tcolor: Color;\n\t/**\n\t * The piece type that should be displayed when hovering over the line, if there is one.\n\t * Otherwise, a glow dot is rendered when hovering over it.\n\t */\n\tpiece?: number;\n}\n\n/**\n * Returns the respective bounding box inside which we should render highlight lines out to,\n * according to whether we're in perspective mode or not.\n */\nfunction getRenderRange(): BoundingBoxBD {\n\tif (!perspective.getEnabled()) {\n\t\t// 2D mode\n\t\treturn boardtiles.gboundingBoxFloat();\n\t} else {\n\t\t// Perspective mode\n\n\t\tconst distToRenderBoardBD: BigDecimal = bd.fromNumber(perspective.distToRenderBoard);\n\t\tconst scale: BigDecimal = boardpos.getBoardScale();\n\t\tconst position = boardpos.getBoardPos();\n\n\t\tconst distToRenderBoard_Tiles: BigDecimal = bd.divideFloating(distToRenderBoardBD, scale);\n\n\t\t// Shift the box based on our current board position\n\t\treturn {\n\t\t\tleft: bd.subtract(position[0], distToRenderBoard_Tiles),\n\t\t\tright: bd.add(position[0], distToRenderBoard_Tiles),\n\t\t\tbottom: bd.subtract(position[1], distToRenderBoard_Tiles),\n\t\t\ttop: bd.add(position[1], distToRenderBoard_Tiles),\n\t\t};\n\t}\n}\n\nfunction genLinesModel(lines: Line[]): Renderable {\n\tconst data: number[] = lines.flatMap((line) => getLineData(line));\n\treturn createRenderable(data, 2, 'LINES', 'color', true);\n}\n\nfunction getLineData(line: Line): number[] {\n\tconst startWorld = space.convertCoordToWorldSpace(line.start);\n\tconst endWorld = space.convertCoordToWorldSpace(line.end);\n\tconst [r, g, b, a] = line.color;\n\t// prettier-ignore\n\treturn [\n\t\t//         Vertex                 Color\n\t\tstartWorld[0], startWorld[1],   r, g, b, a,\n\t\tendWorld[0], endWorld[1],       r, g, b, a\n\t];\n}\n\nexport default {\n\tgetRenderRange,\n\tgenLinesModel,\n};\n\nexport type { Line };\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/highlights/highlights.ts",
    "content": "// src/client/scripts/esm/game/rendering/highlights/highlights.ts\n\n/**\n * This script renders all highlights:\n *\n * Last move\n * Check\n * Legal moves (of selected piece and hovered arrows)\n */\n\nimport type { Board } from '../../../../../../shared/chess/logic/gamefile.js';\nimport type { Color } from '../../../../../../shared/util/math/math.js';\n\nimport moveutil from '../../../../../../shared/chess/util/moveutil.js';\n\nimport boardpos from '../boardpos.js';\nimport premoves from '../../chess/premoves.js';\nimport movehints from './movehints.js';\nimport enginegame from '../../misc/enginegame.js';\nimport annotations from './annotations/annotations.js';\nimport preferences from '../../../components/header/preferences.js';\nimport checkhighlight from './checkhighlight.js';\nimport squarerendering from './squarerendering.js';\nimport legalmovehighlights from './legalmovehighlights.js';\nimport specialrighthighlights from './specialrighthighlights.js';\n\n/**\n * Renders all highlights, including:\n *\n * Last move highlight\n * Red Check highlight\n * Legal move highlights\n * Hovered arrows legal move highlights\n * Outline of highlights render box\n */\nfunction render(boardsim: Board): void {\n\tif (!boardpos.areZoomedOut()) {\n\t\t// Zoomed in\n\t\thighlightLastMove(boardsim);\n\t\tcheckhighlight.render(boardsim);\n\t\tlegalmovehighlights.render();\n\t\tspecialrighthighlights.render(); // Should be after legalmovehighlights.render(), since that updates model_Offset\n\t}\n\tpremoves.render(); // Premove highlights\n\t// Needs to render EVEN if zoomed out (different mode)\n\tannotations.render_belowPieces(); // The square highlights added by the user\n\tmovehints.render(); // Individual legal move hints when in check\n\tenginegame.render(); // Engine games can render a debug of engine generated moves\n}\n\n/** Highlights the start and end squares of the most recently played move. */\nfunction highlightLastMove(boardsim: Board): void {\n\tconst lastMove = moveutil.getCurrentMove(boardsim);\n\tif (!lastMove) return; // Don't render if last move is undefined.\n\n\tconst color: Color = preferences.getLastMoveHighlightColor();\n\tconst u_size: number = boardpos.getBoardScaleAsNumber();\n\n\tsquarerendering\n\t\t.genModel([lastMove.startCoords, lastMove.endCoords], color)\n\t\t.render(undefined, undefined, { u_size });\n}\n\nexport default {\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/highlights/legalmovehighlights.ts",
    "content": "// src/client/scripts/esm/game/rendering/highlights/legalmovehighlights.ts\n\n/**\n * [ZOOMED IN] This script renders legal moves of:\n *\n * * Selected piece\n * * All hovered arrows\n */\n\nimport type { Color } from '../../../../../../shared/util/math/math.js';\nimport type { Piece } from '../../../../../../shared/chess/util/boardutil.js';\nimport type { LegalMoves } from '../../../../../../shared/chess/logic/legalmoves.js';\n\nimport typeutil from '../../../../../../shared/chess/util/typeutil.js';\nimport coordutil from '../../../../../../shared/chess/util/coordutil.js';\n\nimport camera from '../camera.js';\nimport meshes from '../meshes.js';\nimport selection from '../../chess/selection.js';\nimport preferences from '../../../components/header/preferences.js';\nimport piecemodels from '../piecemodels.js';\nimport { GameBus } from '../../GameBus.js';\nimport frametracker from '../frametracker.js';\nimport legalmovemodel from './legalmovemodel.js';\nimport legalmoveshapes from '../instancedshapes.js';\nimport arrowlegalmovehighlights from '../arrows/arrowlegalmovehighlights.js';\nimport { RenderableInstanced, createRenderable_Instanced } from '../../../webgl/Renderable.js';\n\n// Variables -----------------------------------------------------------------------------\n\n/** The current piece selected, if there is one. */\nlet pieceSelected: Piece | undefined;\n/** The current selected piece's legal moves, if there is one. */\nlet selectedPieceLegalMoves: LegalMoves | undefined;\n\n/**\n * A buffer model that contains the single square\n * highlight immediately underneath the selected piece.\n */\nlet model_SelectedPiece: RenderableInstanced | undefined;\n\n/**\n * An model using instanced-rendering for rendering the\n * non-capturing selected piece's legal move highlights\n */\nlet model_NonCapture: RenderableInstanced | undefined;\n/**\n * An model using instanced-rendering for rendering the\n * capturing selected piece's legal move highlights\n */\nlet model_Capture: RenderableInstanced | undefined;\n\n// Init Listeners --------------------------------------------------------------------------------\n\n// When the legal move shape settings is modified, regenerate the model of the highlights\ndocument.addEventListener('legalmove-shape-change', regenerateAll); // Custom Event\n\n// When the theme is changed, erase the models so they\n// will be regenerated next render call.\ndocument.addEventListener('theme-change', regenerateAll);\n\n// On Events -------------------------------------------------------------------------------------\n\nGameBus.addEventListener('piece-selected', (event) => {\n\tconst detail = event.detail;\n\tpieceSelected = detail.piece;\n\tselectedPieceLegalMoves = detail.legalMoves;\n\t// Generate the buffer model for the green legal move highlights.\n\tregenSelectedPieceLegalMovesHighlightsModel();\n});\n\nGameBus.addEventListener('piece-unselected', () => {\n\tpieceSelected = undefined;\n\tselectedPieceLegalMoves = undefined;\n\n\t// Erase models\n\tmodel_SelectedPiece = undefined;\n\tmodel_NonCapture = undefined;\n\tmodel_Capture = undefined;\n});\n\n// Rendering --------------------------------------------------------------------------------------\n\n/**\n * Renders the legal move highlights of the selected piece, all hovered arrows,\n * and outlines the box containing all of them.\n */\nfunction render(): void {\n\t// Sometimes when we are just panning around, our screen bounding box\n\t// exits the box containing our generating legal move highlights mesh.\n\t// When that happens, update the box and regenerate the highlights!\n\tconst changeMade = legalmovemodel.updateRenderRange();\n\tif (changeMade) regenerateAll();\n\n\trenderSelectedPieceLegalMoves();\n\tarrowlegalmovehighlights.renderEachHoveredPieceLegalMoves();\n\tif (camera.getDebug()) legalmovemodel.renderOutlineOfRenderBox();\n}\n\n/**\n * Regenerates both the models of our selected piece's legal move highlights,\n * and the models of pieces legal moves of which we're currently hovering over their arrow,\n * and the model of the special rights highlights.\n *\n * Basically everything that relies on {@link model_Offset}\n */\nfunction regenerateAll(): void {\n\tregenSelectedPieceLegalMovesHighlightsModel();\n\tarrowlegalmovehighlights.regenModelsOfHoveredPieces();\n}\n\n// Regenerates the model for all highlighted legal moves.\nfunction regenSelectedPieceLegalMovesHighlightsModel(): void {\n\tif (!pieceSelected) return;\n\t// console.log(\"Regenerating legal moves model..\");\n\n\t// The model of the selected piece's legal moves\n\tconst selectedPieceColor = typeutil.getColorFromType(pieceSelected!.type);\n\tconst color_options = {\n\t\tisOpponentPiece: selection.isOpponentPieceSelected(),\n\t\tisPremove: selection.arePremoving(),\n\t};\n\tconst color: Color = preferences.getLegalMoveHighlightColor(color_options);\n\tconst { NonCaptureModel, CaptureModel } =\n\t\tlegalmovemodel.generateModelsForPiecesLegalMoveHighlights(\n\t\t\tpieceSelected!.coords,\n\t\t\tselectedPieceLegalMoves!,\n\t\t\tselectedPieceColor,\n\t\t\tcolor,\n\t\t);\n\tmodel_NonCapture = NonCaptureModel;\n\tmodel_Capture = CaptureModel;\n\n\t// The selected piece highlight model\n\tconst vertexData: number[] = legalmoveshapes.getDataLegalMoveSquare(color);\n\tconst coords = pieceSelected!.coords;\n\tconst offsetCoord = coordutil.subtractCoords(coords, legalmovemodel.getOffset());\n\tconst instanceData: bigint[] = [...offsetCoord];\n\tmodel_SelectedPiece = createRenderable_Instanced(\n\t\tvertexData,\n\t\tpiecemodels.castBigIntArrayToFloat32(instanceData),\n\t\t'TRIANGLES',\n\t\t'colorInstanced',\n\t\ttrue,\n\t);\n\n\tframetracker.onVisualChange();\n}\n\n/**\n * Renders the current selected piece's legal move mesh,\n * IF a piece is selected.\n *\n * The mesh should have been pre-calculated.\n */\nfunction renderSelectedPieceLegalMoves(): void {\n\tif (!pieceSelected) return; // No model to render\n\n\tconst { position, scale } = meshes.getBoardRenderTransform(legalmovemodel.getOffset());\n\n\t// Render each of the models using instanced rendering.\n\tmodel_SelectedPiece!.render(position, scale);\n\tmodel_NonCapture!.render(position, scale);\n\tmodel_Capture!.render(position, scale);\n}\n\n// Exports -----------------------------------------------------------------------------------\n\nexport default {\n\t// Rendering\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/highlights/legalmovemodel.ts",
    "content": "// src/client/scripts/esm/game/rendering/highlights/legalmovemodel.ts\n\n/**\n * [ZOOMED IN] This script handles the model\n * generation of piece's legal move highlights.\n *\n * That also includes Rays.\n */\n\nimport type { Color } from '../../../../../../shared/util/math/math.js';\nimport type { Player } from '../../../../../../shared/chess/util/typeutil.js';\nimport type { MoveTagged } from '../../../../../../shared/chess/logic/movepiece.js';\nimport type { IgnoreFunction } from '../../../../../../shared/chess/logic/movesets.js';\nimport type { Board, FullGame } from '../../../../../../shared/chess/logic/gamefile.js';\nimport type { Ray, Vec2, Vec2Key } from '../../../../../../shared/util/math/vectors.js';\nimport type { LegalMoves, SlideLimits } from '../../../../../../shared/chess/logic/legalmoves.js';\nimport type {\n\tBDCoords,\n\tCoords,\n\tDoubleCoords,\n} from '../../../../../../shared/chess/util/coordutil.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport bimath from '../../../../../../shared/util/math/bimath.js';\nimport vectors from '../../../../../../shared/util/math/vectors.js';\nimport bdcoords from '../../../../../../shared/chess/util/bdcoords.js';\nimport coordutil from '../../../../../../shared/chess/util/coordutil.js';\nimport boardutil from '../../../../../../shared/chess/util/boardutil.js';\nimport checkresolver from '../../../../../../shared/chess/logic/checkresolver.js';\nimport geometry, { IntersectionPoint } from '../../../../../../shared/util/math/geometry.js';\nimport bounds, { BoundingBox, BoundingBoxBD } from '../../../../../../shared/util/math/bounds.js';\n\nimport space from '../../misc/space.js';\nimport meshes from '../meshes.js';\nimport gameslot from '../../chess/gameslot.js';\nimport boardpos from '../boardpos.js';\nimport boardtiles from '../boardtiles.js';\nimport primitives from '../primitives.js';\nimport preferences from '../../../components/header/preferences.js';\nimport piecemodels from '../piecemodels.js';\nimport perspective from '../perspective.js';\nimport legalmoveshapes from '../instancedshapes.js';\nimport instancedshapes from '../instancedshapes.js';\nimport {\n\tAttributeInfoInstanced,\n\tRenderableInstanced,\n\tcreateRenderable,\n\tcreateRenderable_Instanced,\n\tcreateRenderable_Instanced_GivenInfo,\n} from '../../../webgl/Renderable.js';\n\n// Types -----------------------------------------------------------------------\n\n/** Information for iterating the instance data of a legal move line as far as it needs to be rendered. */\ntype RayIterationInfo = {\n\t/** The first TRUE coordinate the ray starts on. */\n\tstartCoords: Coords;\n\t/** The OFFSET coordinate the ray starts on. */\n\tstartCoordsOffset: Coords;\n\t/** How many times to repeat a highlight in one direction for this given ray. */\n\titerationCount: number;\n};\n\n// Constants -----------------------------------------------------------------------------\n\n/** The attribute info for all legal move highlight instanced rendering models. */\nconst ATTRIB_INFO: AttributeInfoInstanced = {\n\tvertexDataAttribInfo: [\n\t\t{ name: 'a_position', numComponents: 2 },\n\t\t{ name: 'a_color', numComponents: 4 },\n\t],\n\tinstanceDataAttribInfo: [{ name: 'a_instanceposition', numComponents: 2 }],\n};\n\n/**\n * An offset applied to the legal move highlights mesh, to keep all of the\n * vertex data less than this number.\n *\n * The offset snaps to the nearest grid number of this size.\n *\n * Without an offset, the vertex data has no imposed limit to how big the numbers can\n * get, which ends up creating graphical glitches MUCH SOONER, because the\n * GPU is only capable of Float32s, NOT Float64s (which javascript numbers are).\n *\n * The legal move highlights offset will snap to this nearest number on the grid.\n *\n * For example, if we're at position [8700,0] on the board, then the legal move highlight\n * offset will snap to [10000,0], making it so that the vertex data only needs to contain\n * numbers around 1300 instead of 8700 without an offset.\n *\n * Using an offset means the vertex data ALWAYS remains less than 10000!\n */\nconst highlightedMovesRegenRange = 10_000n;\n\n/** The distance, in perspective mode, we want to aim to render legal moves highlights out to, or farther. */\nconst PERSPECTIVE_VIEW_RANGE = 1000;\n/** Amount of screens in number the render range bounding box should try to aim for beyond the screen. */\nconst multiplier = 4;\n/**\n * In perspective mode, visible range is considered 1000. This is the multiplier to that for the render range bounding box.\n */\nconst multiplier_perspective = 2;\n\nconst ZERO: BigDecimal = bd.fromBigInt(0n);\n\n// Variables -----------------------------------------------------------------------------\n\n/**\n * The current view box to generate visible legal moves inside.\n *\n * We can only generate the mesh up to a finite distance.\n * This box dynamically grows, shrinks, and translates,\n * to ALWAYS keep the entire screen in the box.\n *\n * By default it expands past the screen somewhat, so that a little\n * panning around doesn't immediately trigger this view box to change.\n *\n * THIS REPRESENTS THE INTEGER TILES INCLUDED IN THE RANGE.\n * For example, a `right` of 10 means it includes the X=10 tiles.\n */\nlet boundingBoxOfRenderRange: BoundingBox | undefined;\n\n/**\n * How much the vertex data of the highlight models has been offset, to make their numbers\n * close to zero, to avoid floating point imprecision.\n *\n * This is the nearest multiple of {@link highlightedMovesRegenRange} our camera is at.\n */\nlet model_Offset: Coords = [0n, 0n];\n\n// Updating Render Range and Offset --------------------------------------------------\n\n/** Returns {@link model_Offset} */\nfunction getOffset(): Coords {\n\treturn model_Offset;\n}\n\n/**\n * Updates the offset and bounding box universal to all rendered legal move highlights.\n * If a change is made, it calls to regenerate the model.\n * @returns Whether a change was made, updating it.\n */\nfunction updateRenderRange(): boolean {\n\t// Determine if our camera/screen exceeds the boundary of our render range box...\n\tif (isViewRangeContainedInRenderRange()) return false; // No change needed\n\n\t// Regenerate the legal move highlights render range bounding box\n\n\t// console.log(\"Recalculating bounding box of render range.\");\n\n\tconst [newWidth, newHeight] = perspective.getEnabled()\n\t\t? getDimensionsOfPerspectiveViewRange()\n\t\t: getDimensionsOfOrthographicViewRange();\n\n\tconst halfNewWidth: BigDecimal = bd.fromNumber(newWidth / 2);\n\tconst halfNewHeight: BigDecimal = bd.fromNumber(newHeight / 2);\n\n\tconst boardPos = boardpos.getBoardPos();\n\n\tboundingBoxOfRenderRange = {\n\t\tleft: space.roundCoord(bd.subtract(boardPos[0], halfNewWidth)),\n\t\tright: space.roundCoord(bd.add(boardPos[0], halfNewWidth)),\n\t\tbottom: space.roundCoord(bd.subtract(boardPos[1], halfNewHeight)),\n\t\ttop: space.roundCoord(bd.add(boardPos[1], halfNewHeight)),\n\t};\n\n\t/** Update our offset to the nearest grid-point multiple of {@link highlightedMovesRegenRange} */\n\tmodel_Offset = geometry.roundPointToNearestGridpoint(\n\t\tboardpos.getBoardPos(),\n\t\thighlightedMovesRegenRange,\n\t);\n\n\t// console.log(\"Shifted offset of highlights.\");\n\n\treturn true; // A change was made\n}\n\n/**\n * Returns whether our camera/screen view box is contained within\n * our legal move highlights render range box,\n * OR if it's significantly smaller than it.\n */\nfunction isViewRangeContainedInRenderRange(): boolean {\n\tif (!boundingBoxOfRenderRange) return false; // It isn't even initiated yet\n\n\t// The bounding box of what the camera currently sees on-screen.\n\tconst boundingBoxOfView: BoundingBoxBD = perspective.getEnabled()\n\t\t? getBoundingBoxOfPerspectiveView()\n\t\t: boardtiles.gboundingBoxFloat();\n\n\t// In 2D mode, we also care about whether the\n\t// camera box is significantly smaller than our render range.\n\tif (!perspective.getEnabled()) {\n\t\t// We can cast to number since we're confident it's going to be small (we are zoomed in)\n\t\tconst width: number = bd.toNumber(\n\t\t\tbd.subtract(boundingBoxOfView.right, boundingBoxOfView.left),\n\t\t);\n\t\tconst renderRangeWidth: number =\n\t\t\tNumber(boundingBoxOfRenderRange.right - boundingBoxOfRenderRange.left) + 1;\n\n\t\t// multiplier needs to be squared cause otherwise when we zoom in it regenerates the render box every frame.\n\t\tif (width * multiplier * multiplier < renderRangeWidth) return false;\n\t}\n\n\tconst floatingRenderRangeBox =\n\t\tmeshes.expandTileBoundingBoxToEncompassWholeSquare(boundingBoxOfRenderRange);\n\t// Whether the camera view box exceeds the boundaries of the render range\n\treturn bounds.boxContainsBoxBD(floatingRenderRangeBox, boundingBoxOfView);\n}\n\n/** [PERSPECTIVE] Returns our approximate camera view range bounding box. */\nfunction getBoundingBoxOfPerspectiveView(): BoundingBoxBD {\n\tconst boardPos = boardpos.getBoardPos();\n\tconst viewDist: BigDecimal = bd.fromNumber(PERSPECTIVE_VIEW_RANGE);\n\treturn {\n\t\tleft: bd.subtract(boardPos[0], viewDist),\n\t\tright: bd.add(boardPos[0], viewDist),\n\t\tbottom: bd.subtract(boardPos[1], viewDist),\n\t\ttop: bd.add(boardPos[1], viewDist),\n\t};\n}\n\n/** [PERSPECTIVE] Returns the target dimensions of the legal move highlights box. */\nfunction getDimensionsOfPerspectiveViewRange(): DoubleCoords {\n\tconst width = PERSPECTIVE_VIEW_RANGE * 2;\n\tconst newWidth = width * multiplier_perspective;\n\treturn [newWidth, newWidth];\n}\n\n/** [ORTHOGRAPHIC] Returns the target dimensions of the legal move highlights box. */\nfunction getDimensionsOfOrthographicViewRange(): DoubleCoords {\n\t// New improved method of calculating render bounding box\n\n\tconst boundingBoxOfView = boardtiles.gboundingBox(false);\n\tconst width: number = Number(boundingBoxOfView.right - boundingBoxOfView.left) + 1; // Need to +1 since the board bounding box just includes the integer squares, not floating point edges.\n\tconst height: number = Number(boundingBoxOfView.top - boundingBoxOfView.bottom) + 1;\n\n\tconst newWidth = width * multiplier;\n\tconst newHeight = height * multiplier;\n\n\tif (boardpos.areZoomedOut())\n\t\tthrow Error(\"Don't recalculate legal move highlights box zoomed out!\"); // Don't want to generate a stupidly large model\n\n\treturn [newWidth, newHeight];\n}\n\n// Generating Legal Move Buffer Models ----------------------------------------------------------------------------------\n\n/**\n * Generates the renderable instanced rendering buffer models for the\n * legal move highlights of the given piece's legal moves.\n * @param coords - The coordinates of the piece with the provided legal moves\n * @param legalMoves - The legal moves of which to generate the highlights models for.\n * @param friendlyColor - The color of friendly pieces\n * @param highlightColor - The color to use, which may vary depending on if the highlights are for your piece, opponent's, or a premove.\n */\nfunction generateModelsForPiecesLegalMoveHighlights(\n\tcoords: Coords,\n\tlegalMoves: LegalMoves,\n\tfriendlyColor: Player,\n\thighlightColor: Color,\n): { NonCaptureModel: RenderableInstanced; CaptureModel: RenderableInstanced } {\n\tconst usingDots = preferences.getLegalMovesShape() === 'dots';\n\n\t/** The vertex data OF A SINGLE INSTANCE of the NON-CAPTURING legal move highlight. Stride 6 (2 position, 4 color) */\n\tconst vertexData_NonCapture: number[] = usingDots\n\t\t? legalmoveshapes.getDataLegalMoveDot(highlightColor)\n\t\t: legalmoveshapes.getDataLegalMoveSquare(highlightColor);\n\t/** The instance-specific data of the NON-CAPTURING legal move highlights mesh. Stride 2 (2 instanceposition) */\n\tconst instanceData_NonCapture: bigint[] = [];\n\t/** The vertex data OF A SINGLE INSTANCE of the CAPTURING legal move highlight. Stride 6 (2 position, 4 color) */\n\tconst vertexData_Capture: number[] = usingDots\n\t\t? legalmoveshapes.getDataLegalMoveCornerTris(highlightColor)\n\t\t: legalmoveshapes.getDataLegalMoveSquare(highlightColor);\n\t/** The instance-specific data of the CAPTURING legal move highlights mesh. Stride 2 (2 instanceposition) */\n\tconst instanceData_Capture: bigint[] = [];\n\n\tconst gamefile = gameslot.getGamefile()!;\n\n\t// Data of short range moves within 3 tiles\n\tpushIndividual(instanceData_NonCapture, instanceData_Capture, legalMoves, gamefile.boardsim);\n\t// Potentially infinite data on sliding moves...\n\tpushSliding(\n\t\tinstanceData_NonCapture,\n\t\tinstanceData_Capture,\n\t\tcoords,\n\t\tlegalMoves,\n\t\tgamefile,\n\t\tfriendlyColor,\n\t);\n\n\treturn {\n\t\t// The NON-CAPTURING legal move highlights model\n\t\tNonCaptureModel: createRenderable_Instanced(\n\t\t\tvertexData_NonCapture,\n\t\t\tpiecemodels.castBigIntArrayToFloat32(instanceData_NonCapture),\n\t\t\t'TRIANGLES',\n\t\t\t'colorInstanced',\n\t\t\ttrue,\n\t\t),\n\t\t// The CAPTURING legal move highlights model\n\t\tCaptureModel: createRenderable_Instanced(\n\t\t\tvertexData_Capture,\n\t\t\tpiecemodels.castBigIntArrayToFloat32(instanceData_Capture),\n\t\t\t'TRIANGLES',\n\t\t\t'colorInstanced',\n\t\t\ttrue,\n\t\t),\n\t};\n}\n\n/**\n * Generates a single instanced rendering model of white box outlines for all squares\n * in the given legal moves (captures and non-captures alike share the same shape).\n * Used for rendering slide-move highlights when dragging an off-screen piece via its arrow indicator.\n * @param coords - The coordinates of the piece with the provided legal moves.\n * @param legalMoves - The legal moves of which to generate the outline model for.\n */\nfunction generateModelForSlideHighlightOutlines(\n\tcoords: Coords,\n\tlegalMoves: LegalMoves,\n): RenderableInstanced {\n\tconst vertexData: number[] = instancedshapes.getDataBoxOutline();\n\t/** The instance-specific position data. Stride 2 (2 instanceposition). Captures and non-captures share the same outline shape. */\n\tconst instanceData: bigint[] = [];\n\n\tconst gamefile = gameslot.getGamefile()!;\n\n\t// Pass the same array for both capture and non-capture — the outline looks identical for both.\n\tpushIndividual(instanceData, instanceData, legalMoves, gamefile.boardsim);\n\t// prettier-ignore\n\tpushSliding(instanceData, instanceData, coords, legalMoves, gamefile, gamefile.basegame.whosTurn);\n\n\treturn createRenderable_Instanced(\n\t\tvertexData,\n\t\tpiecemodels.castBigIntArrayToFloat32(instanceData),\n\t\t'TRIANGLES',\n\t\t'colorInstanced',\n\t\ttrue,\n\t);\n}\n\n// Individual Moves ------------------------------------------------------------------------------------------------------\n\n/**\n * Calculates instanceposition data of legal individual (jumping) moves and appends it to the provided instance data arrays.\n * @param instanceData_NonCapture - The running array of instance data for the NON-CAPTURING legal moves highlights mesh.\n * @param instanceData_Capture - The running array of instance data for the CAPTURING legal moves highlights mesh.\n * @param legalMoves - The piece legal moves to highlight\n * @param boardsim - A reference to the current loaded gamefile's board\n */\nfunction pushIndividual(\n\tinstanceData_NonCapture: bigint[],\n\tinstanceData_Capture: bigint[],\n\tlegalMoves: LegalMoves,\n\tboardsim: Board,\n): void {\n\t// Get an array of the list of individual legal squares the current selected piece can move to\n\tconst legalIndividuals: Coords[] = legalMoves.individual;\n\n\t// For each of these squares, calculate it's buffer data\n\tfor (const coord of legalIndividuals) {\n\t\tconst offsetCoord = coordutil.subtractCoords(coord, model_Offset);\n\t\tconst isPieceOnCoords = boardutil.isPieceOnCoords(boardsim.pieces, coord);\n\t\tif (isPieceOnCoords) instanceData_Capture.push(...offsetCoord);\n\t\telse instanceData_NonCapture.push(...offsetCoord);\n\t}\n}\n\n// Sliding Moves ------------------------------------------------------------------------------------------------------\n\n/**\n * Calculates instanceposition data of legal sliding moves and appends it to the running instance data arrays.\n * @param instanceData_NonCapture - The running array of instance data for the NON-CAPTURING legal moves highlights mesh.\n * @param instanceData_Capture - The running array of instance data for the CAPTURING legal moves highlights mesh.\n * @param coords - The coords of the piece with the provided legal moves\n * @param legalMoves - The piece legal moves to highlight\n * @param gamefile - A reference to the current loaded gamefile\n * @param friendlyColor - The color of friendly pieces\n */\nfunction pushSliding(\n\tinstanceData_NonCapture: bigint[],\n\tinstanceData_Capture: bigint[],\n\tcoords: Coords,\n\tlegalMoves: LegalMoves,\n\tgamefile: FullGame,\n\tfriendlyColor: Player,\n): void {\n\tfor (const [lineKey, limits] of Object.entries(legalMoves.sliding)) {\n\t\t// '1,0'\n\t\tconst line: Vec2 = vectors.getVec2FromKey(lineKey as Vec2Key); // [dx,dy]\n\n\t\t// The intersection points this slide direction intersects\n\t\t// our legal move highlights render range bounding box, if it does.\n\t\t// eslint-disable-next-line prefer-const\n\t\tlet [intsect1Tile, intsect2Tile] = geometry.findLineBoxIntersections(\n\t\t\tcoords,\n\t\t\tline,\n\t\t\tboundingBoxOfRenderRange!,\n\t\t);\n\n\t\tif (!intsect1Tile && !intsect2Tile) continue; // No intersection point (off the screen).\n\t\tif (!intsect2Tile) intsect2Tile = intsect1Tile; // If there's only one corner intersection, make the exit point the same as the entry.\n\n\t\t// prettier-ignore\n\t\tpushSlide(instanceData_NonCapture, instanceData_Capture, coords, line, intsect1Tile!, intsect2Tile!, limits, legalMoves.ignoreFunc, gamefile, friendlyColor, legalMoves.brute);\n\t}\n}\n\n/**\n * Adds the instanceposition data of a directional movement line, in both directions, of ANY SLOPED step to the running instance data arrays.\n * @param instanceData_NonCapture - The running array of instance data for the NON-CAPTURING legal moves highlights mesh.\n * @param instanceData_Capture - The running array of instance data for the CAPTURING legal moves highlights mesh.\n * @param coords - The coords of the piece with the provided legal moves\n * @param step - Of the line / moveset\n * @param intsect1 - What point this line intersect the left side of the screen box.\n * @param intsect2 - What point this line intersect the right side of the screen box.\n * @param limits - Slide limit: [-7,Infinity]. May be an offset segment (same-sign) for colinear blocking.\n * @param ignoreFunc - The ignore function\n * @param gamefile - A reference to the current loaded gamefile\n * @param friendlyColor - The color of friendly pieces\n * @param brute - If true, each move will be simulated as to whether it results in check, and if so, not added to the mesh data.\n */\nfunction pushSlide(\n\tinstanceData_NonCapture: bigint[],\n\tinstanceData_Capture: bigint[],\n\tcoords: Coords,\n\tstep: Vec2,\n\tintsect1: IntersectionPoint,\n\tintsect2: IntersectionPoint,\n\tlimits: SlideLimits,\n\tignoreFunc: IgnoreFunction,\n\tgamefile: FullGame,\n\tfriendlyColor: Player,\n\tbrute?: boolean,\n): void {\n\t// Compute the negated direction and flipped intersections (used in offset-negative and normal cases)\n\tconst negStep: Vec2 = vectors.negateVector(step);\n\n\t// Switch the order of intersections and negate their dot product\n\tconst negVecIntsect1: IntersectionPoint = {\n\t\tcoords: intsect2.coords,\n\t\tpositiveDotProduct: !intsect2.positiveDotProduct,\n\t};\n\tconst negVecIntsect2: IntersectionPoint = {\n\t\tcoords: intsect1.coords,\n\t\tpositiveDotProduct: !intsect1.positiveDotProduct,\n\t};\n\n\t// Special offset cases: the legal zone doesn't include the piece's own\n\t// square or span in both directions infinitely — e.g. a Huygen blocked\n\t// colinearly, so only squares 100-200 ahead are legal. Only one ray is rendered.\n\tif (limits[0] !== null && limits[0] > 0n) {\n\t\t// Offset positive: legal zone is entirely in the positive step direction.\n\t\tif (intsect2.positiveDotProduct) {\n\t\t\t// prettier-ignore\n\t\t\tpushRay(instanceData_NonCapture, instanceData_Capture, coords, step, intsect1, intsect2, limits[1], ignoreFunc, gamefile, friendlyColor, brute, limits[0]);\n\t\t}\n\t\treturn;\n\t}\n\tif (limits[1] !== null && limits[1] < 0n) {\n\t\t// Offset negative: legal zone is entirely in the negative step direction.\n\t\tif (negVecIntsect2.positiveDotProduct) {\n\t\t\tconst absLimit0 = limits[0] === null ? null : bimath.abs(limits[0]);\n\t\t\tconst absLimit1 = bimath.abs(limits[1]);\n\t\t\t// prettier-ignore\n\t\t\tpushRay(instanceData_NonCapture, instanceData_Capture, coords, negStep, negVecIntsect1, negVecIntsect2, absLimit0, ignoreFunc, gamefile, friendlyColor, brute, absLimit1);\n\t\t}\n\t\treturn;\n\t}\n\n\t// Normal case: limits span 0 (or one side is null). Render both rays outward from the piece.\n\tif (intsect2.positiveDotProduct) {\n\t\t// The start coords are either on screen, or the ray points towards the screen\n\t\t// prettier-ignore\n\t\tpushRay(instanceData_NonCapture, instanceData_Capture, coords, step,    intsect1, intsect2, limits[1], ignoreFunc, gamefile, friendlyColor, brute);\n\t} // else the start coords are off screen and ray points in the opposite direction of the screen\n\tif (negVecIntsect2.positiveDotProduct) {\n\t\t// The start coords are either on screen, or the ray points towards the screen\n\t\t// The first index of slide limit is always negative (or null) in the normal case\n\t\tconst absoluteSlideLimit = limits[0] === null ? null : bimath.abs(limits[0]);\n\t\t// prettier-ignore\n\t\tpushRay(instanceData_NonCapture, instanceData_Capture, coords, negStep, negVecIntsect1, negVecIntsect2, absoluteSlideLimit, ignoreFunc, gamefile, friendlyColor, brute);\n\t} // else the start coords are off screen and ray points in the opposite direction of the screen\n}\n\n/**\n * Adds the instanceposition data of a single directional ray (split in 2 from a normal slide) to the running instance data arrays.\n * @param instanceData_NonCapture - The running array of instance data for the NON-CAPTURING legal moves highlights mesh.\n * @param instanceData_Capture - The running array of instance data for the CAPTURING legal moves highlights mesh.\n * @param coords - The coords of the piece with the provided legal moves\n * @param step - Of the line / moveset\n * @param intsect1 - What point this line intersect the left side of the screen box.\n * @param intsect2 - What point this line intersect the right side of the screen box.\n * @param limit - Needs to be POSITIVE. The farthest number of steps the ray can travel.\n * @param ignoreFunc - The ignore function, to ignore squares\n * @param gamefile - A reference to the current loaded gamefile\n * @param friendlyColor - The color of friendly pieces\n * @param brute - If true, each move will be simulated as to whether it results in check, and if so, not added to the mesh data.\n * @param startStep - The step number of the first highlight, counting from the piece. Default 1. Use > 1 for offset segments.\n */\nfunction pushRay(\n\tinstanceData_NonCapture: bigint[],\n\tinstanceData_Capture: bigint[],\n\tcoords: Coords,\n\tstep: Vec2,\n\tintsect1: IntersectionPoint,\n\tintsect2: IntersectionPoint,\n\tlimit: bigint | null,\n\tignoreFunc: IgnoreFunction,\n\tgamefile: FullGame,\n\tfriendlyColor: Player,\n\tbrute?: boolean,\n\tstartStep: bigint = 1n,\n): void {\n\tif (limit !== null && limit < startStep) return; // Slide range ends before it even starts\n\n\t// prettier-ignore\n\tconst iterationInfo: RayIterationInfo | undefined = getRayIterationInfo(coords, step, intsect1, intsect2, limit, false, startStep);\n\tif (!iterationInfo) return; // None of the piece's slide is visible on screen, skip.\n\n\tconst { startCoords, startCoordsOffset, iterationCount } = iterationInfo;\n\n\t// Recursively adds the coords to the instance data list, shifting by the step size.\n\tconst targetCoords: Coords = startCoords; // The true coords of the square we're checking\n\tfor (let i = 0; i < iterationCount; i++) {\n\t\tlegal: if (ignoreFunc(coords, targetCoords)) {\n\t\t\t// Ignore function PASSED. (Is a prime square for huygens)\n\n\t\t\t// If we're brute force checking each move for check, do that here. (royal queen, or colinear pins)\n\t\t\tif (brute) {\n\t\t\t\tconst moveTagged: MoveTagged = { startCoords: coords, endCoords: targetCoords };\n\t\t\t\tif (checkresolver.getSimulatedCheck(gamefile, moveTagged, friendlyColor).check)\n\t\t\t\t\tbreak legal;\n\t\t\t}\n\n\t\t\tconst isPieceOnCoords = boardutil.isPieceOnCoords(\n\t\t\t\tgamefile.boardsim.pieces,\n\t\t\t\ttargetCoords,\n\t\t\t);\n\t\t\tif (isPieceOnCoords) instanceData_Capture.push(...startCoordsOffset);\n\t\t\telse instanceData_NonCapture.push(...startCoordsOffset);\n\t\t}\n\n\t\ttargetCoords[0] += step[0];\n\t\ttargetCoords[1] += step[1];\n\t\t// The mesh-offset adjusted coords we're checking\n\t\tstartCoordsOffset[0] += step[0];\n\t\tstartCoordsOffset[1] += step[1];\n\t}\n}\n\n/**\n * Calculates how many times a highlight should be repeated\n * to cover all squares a ray can reach in the render range,\n * and calculates where it should start and end.\n * @param isRay - This will also include the starting coordinate, as is not the behavior for selected pieces.\n * @param startStep - The step number of the first highlight, counting from the piece. Defaults to 1. Pass a higher value for offset segments where the legal zone doesn't start adjacent to the piece.\n */\nfunction getRayIterationInfo(\n\tcoords: Coords,\n\tstep: Vec2,\n\tintsect1: IntersectionPoint,\n\tintsect2: IntersectionPoint,\n\tlimit: bigint | null,\n\tisRay: boolean,\n\tstartStep: bigint = 1n,\n): RayIterationInfo | undefined {\n\tconst coordsBD: BDCoords = bdcoords.FromCoords(coords);\n\tconst stepBD: BDCoords = bdcoords.FromCoords(step);\n\n\tconst axis: 0 | 1 = step[0] === 0n ? 1 : 0; // Use the y axis if the x movement vector is zero\n\n\t// Determine the start coords.\n\n\tlet startCoords: Coords = [...coords];\n\tif (!isRay) {\n\t\t// The first highlight starts startStep squares off the piece coords\n\t\tstartCoords[0] += step[0] * startStep;\n\t\tstartCoords[1] += step[1] * startStep;\n\t}\n\n\t// Is the piece off screen in the opposite direction of the step?\n\tif (intsect1.positiveDotProduct) {\n\t\t// Adjust the start square to be the first square we land on after intsect1.\n\t\tconst axisDistToIntsect1: BigDecimal = bd.subtract(intsect1.coords[axis], coordsBD[axis]);\n\t\tconst distInSteps: bigint = bd.toBigInt(\n\t\t\tbd.ceil(bd.divide(axisDistToIntsect1, stepBD[axis])),\n\t\t); // Minimum number of steps to overtake the first intersection.\n\t\t// Use the larger of distInSteps and startStep to respect both the screen edge and the slide range start\n\t\tconst actualStartStep = distInSteps > startStep ? distInSteps : startStep;\n\t\tstartCoords = [\n\t\t\tcoords[0] + step[0] * actualStartStep,\n\t\t\tcoords[1] + step[1] * actualStartStep,\n\t\t];\n\t}\n\n\t// Determine the end coords.\n\n\t// How many steps could we take before we reached intsect2?\n\tconst axisDistanceToIntsect2: BigDecimal = bd.subtract(intsect2.coords[axis], coordsBD[axis]);\n\t// The maximum number of steps we can take before exceeding the screen edge\n\tconst axisStepsToReachIntsect2: bigint = bd.toBigInt(\n\t\tbd.floor(bd.divide(axisDistanceToIntsect2, stepBD[axis])),\n\t);\n\tlet endCoords: Coords = [\n\t\tcoords[0] + step[0] * axisStepsToReachIntsect2,\n\t\tcoords[1] + step[1] * axisStepsToReachIntsect2,\n\t];\n\n\tif (limit !== null) {\n\t\t// Determine if we can't even slide far enough to reach intsect2. If so, we need to shorten our endCoords\n\n\t\t// What is the farthest point we can slide to?\n\t\tconst furthestSquareWeCanSlide: Coords = [\n\t\t\tcoords[0] + step[0] * limit,\n\t\t\tcoords[1] + step[1] * limit,\n\t\t];\n\t\tconst furthestSquareWeCanSlideBD: BDCoords = bdcoords.FromCoords(furthestSquareWeCanSlide);\n\n\t\tconst vectorFromFurthestSquareTowardsIntsect = coordutil.subtractBDCoords(\n\t\t\tintsect2.coords,\n\t\t\tfurthestSquareWeCanSlideBD,\n\t\t);\n\t\tconst dotProd = vectors.dotProductBD(vectorFromFurthestSquareTowardsIntsect, stepBD);\n\t\t// A dotProd of zero would mean it can slide EXACTLY up to the end of the screen, that is okay\n\t\t// But positive means we can't slide far enough to reach intsect2. Shorten our endCoords!\n\t\tif (bd.compare(dotProd, ZERO) > 0) endCoords = furthestSquareWeCanSlide;\n\t}\n\n\t// Next, determine iterationCount and startCoordsOffset.\n\n\t// Calculate how many times we need to iteratively shift this vertex data and append it to our vertex data array\n\tconst axisDistFromStartToEnd: bigint = endCoords[axis] - startCoords[axis];\n\t// How many legal move squares/dots to render on this line\n\tconst iterationCount = Number(axisDistFromStartToEnd / step[axis]) + 1; // +1 for start & end inclusive\n\n\t// This will occur if the piece isn't able to move past intsect1, the start of the screen.\n\tif (iterationCount <= 0) return undefined;\n\n\t// Shift the vertex data of our first step to the right place\n\tconst startCoordsOffset: Coords = coordutil.subtractCoords(startCoords, model_Offset);\n\n\treturn { startCoords, startCoordsOffset, iterationCount };\n}\n\n// Rays --------------------------------------------------------------------------------------\n\n/**\n * Generates a model for rendering all rays in the provided list.\n *\n * Rays are square highlights starting from a single coord\n * and going in one direction to infinity, unobstructed.\n */\nfunction genModelForRays(rays: Ray[], color: Color): RenderableInstanced {\n\tconst vertexData = instancedshapes.getDataLegalMoveSquare(color);\n\tconst instanceData: bigint[] = [];\n\n\tfor (const ray of rays) {\n\t\tconst step = ray.vector;\n\n\t\t// eslint-disable-next-line prefer-const\n\t\tlet [intsect1Tile, intsect2Tile] = geometry.findLineBoxIntersections(\n\t\t\tray.start,\n\t\t\tray.vector,\n\t\t\tboundingBoxOfRenderRange!,\n\t\t);\n\n\t\tif (!intsect1Tile && !intsect2Tile) continue; // No intersection point (off the screen).\n\t\tif (!intsect2Tile) intsect2Tile = intsect1Tile; // If there's only one corner intersection, make the exit point the same as the entry.\n\n\t\t// prettier-ignore\n\t\tconst iterationInfo: RayIterationInfo | undefined = getRayIterationInfo(ray.start, ray.vector, intsect1Tile!, intsect2Tile!, null, true);\n\t\tif (iterationInfo === undefined) continue; // Technically should never happen for rays since they are never blocked.\n\n\t\tconst { startCoordsOffset, iterationCount } = iterationInfo;\n\n\t\tfor (let i = 0; i < iterationCount; i++) {\n\t\t\tinstanceData.push(...startCoordsOffset);\n\t\t\tstartCoordsOffset[0] += step[0];\n\t\t\tstartCoordsOffset[1] += step[1];\n\t\t}\n\t}\n\n\treturn createRenderable_Instanced_GivenInfo(\n\t\tvertexData,\n\t\tpiecemodels.castBigIntArrayToFloat32(instanceData),\n\t\tATTRIB_INFO,\n\t\t'TRIANGLES',\n\t\t'colorInstanced',\n\t);\n}\n\n// Rendering ----------------------------------------------------------------------------------------\n\n/**\n * [DEBUG] Renders an outline of the box containing all legal move highlights.\n * Will only be visible if camera debug mode is on, as this is normally outside of the screen edge.\n */\nfunction renderOutlineOfRenderBox(): void {\n\t// const color: Color = [1,0,1, 1]; // Magenta\n\t// const color: Color = [0.65, 0.15, 0, 1]; // Maroon (matches light brown wood theme)\n\tconst color: Color = [1, 1, 1, 1]; // White\n\tconst data = meshes.RectWorld(boundingBoxOfRenderRange!, color);\n\n\tcreateRenderable(data, 2, 'LINE_LOOP', 'color', true).render();\n}\n\n/**\n * [DEBUG] Renders an outline of the provided floating point bounding box.\n */\nfunction renderOutlineofFloatingBox(box: BoundingBoxBD): void {\n\tconst color: Color = [0.65, 0.15, 0, 1];\n\tconst { left, right, bottom, top } = meshes.applyWorldTransformationsToBoundingBox(box);\n\tconst data = primitives.Rect(left, bottom, right, top, color);\n\n\tcreateRenderable(data, 2, 'LINE_LOOP', 'color', true).render();\n}\n\n// Exports ------------------------------------------------------------------------------------------\n\nexport default {\n\t// Updating Render Range and Offset\n\tgetOffset,\n\tupdateRenderRange,\n\t// Generating Legal Move Buffer Models\n\tgenerateModelsForPiecesLegalMoveHighlights,\n\tgenerateModelForSlideHighlightOutlines,\n\t// Rays\n\tgenModelForRays,\n\t// Rendering\n\trenderOutlineOfRenderBox,\n\trenderOutlineofFloatingBox,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/highlights/movehints.ts",
    "content": "// src/client/scripts/esm/game/rendering/highlights/movehints.ts\n\n/**\n * This script renders individual legal move hints when the position is in check\n * and our own piece is selected:\n *\n * [Zoomed out] Green entity squares at each individual legal move location.\n * [Zoomed in]  Arrow indicators (via arrows.ts) for off-screen individual legal moves.\n */\n\nimport type { Color } from '../../../../../../shared/util/math/math.js';\nimport type { Coords } from '../../../../../../shared/chess/util/coordutil.js';\nimport type { LegalMoves } from '../../../../../../shared/chess/logic/legalmoves.js';\n\nimport vectors from '../../../../../../shared/util/math/vectors.js';\nimport coordutil from '../../../../../../shared/chess/util/coordutil.js';\nimport legalmoves from '../../../../../../shared/chess/logic/legalmoves.js';\nimport gamefileutility from '../../../../../../shared/chess/util/gamefileutility.js';\n\nimport boardpos from '../boardpos.js';\nimport gameslot from '../../chess/gameslot.js';\nimport guipause from '../../gui/guipause.js';\nimport snapping from './snapping.js';\nimport selection from '../../chess/selection.js';\nimport gameloader from '../../chess/gameloader.js';\nimport drawsquares from './annotations/drawsquares.js';\nimport preferences from '../../../components/header/preferences.js';\nimport { GameBus } from '../../GameBus.js';\nimport squarerendering from './squarerendering.js';\n\n// Variables -----------------------------------------------------------------------\n\n/** The coords of the selected piece that owns the individual moves, or undefined. */\nlet selectedPieceCoords: Coords | undefined;\n/** The individual legal moves to highlight, if conditions are met. Empty otherwise. */\nlet individualMoves: Coords[] = [];\n\n// Event Listeners ------------------------------------------------------------------\n\nGameBus.addEventListener('piece-selected', (event) => {\n\tconst { legalMoves } = event.detail;\n\tupdateIndividualMoves(legalMoves);\n});\n\nGameBus.addEventListener('piece-unselected', () => {\n\tclearIndividualMoves();\n});\n\n// Functions -----------------------------------------------------------------------\n\n/**\n * Updates the list of individual move hints based on the current selection and game state.\n * Only sets moves when our own non-premove piece is selected and the position is in check.\n */\nfunction updateIndividualMoves(legalMoves: LegalMoves): void {\n\tconst gamefile = gameslot.getGamefile()!;\n\tif (\n\t\tselection.isOpponentPieceSelected() ||\n\t\t!gameloader.isItOurTurn() ||\n\t\t!gamefileutility.isCurrentViewedPositionInCheck(gamefile.boardsim)\n\t) {\n\t\tclearIndividualMoves();\n\t\treturn;\n\t}\n\n\tconst piece = selection.getPieceSelected()!;\n\tselectedPieceCoords = piece.coords;\n\tconst moveset = legalmoves.getPieceMoveset(gamefile.boardsim, piece.type);\n\tindividualMoves = legalMoves.individual.filter((hintSquare) => {\n\t\tconst diff = coordutil.subtractCoords(hintSquare, selectedPieceCoords!);\n\t\tconst dir = vectors.absVector(vectors.normalizeVector(diff));\n\t\tconst vec2Key = vectors.getKeyFromVec2(dir);\n\t\treturn !!(moveset.sliding && moveset.sliding[vec2Key]);\n\t});\n}\n\nfunction clearIndividualMoves(): void {\n\tindividualMoves = [];\n\tselectedPieceCoords = undefined;\n}\n\n// Export for snapping.ts ---------------------------------------------------------\n\n/** Returns the coords of the selected piece that owns the individual move hints, or undefined. */\nfunction getPieceCoords(): Coords | undefined {\n\treturn selectedPieceCoords;\n}\n\n/** Returns the current list of individual legal move hint squares. */\nfunction getSquares(): Coords[] {\n\treturn individualMoves;\n}\n\n// Rendering -----------------------------------------------------------------------\n\n/** [Zoomed out] Renders the individual legal move hint squares as green entity squares. */\nfunction render(): void {\n\tif (individualMoves.length === 0 || !boardpos.areZoomedOut() || guipause.areWePaused()) return;\n\n\tconst color: Color = preferences.getLegalMoveHighlightColor({\n\t\tisOpponentPiece: false,\n\t\tisPremove: false,\n\t});\n\tconst u_size = snapping.getEntityWidthWorld();\n\tsquarerendering.genModel(individualMoves, color).render(undefined, undefined, { u_size });\n\n\t// Render hovered move hints at higher opacity\n\tconst allHovered = drawsquares.getAllSquaresHovered(individualMoves);\n\tif (allHovered.length > 0) {\n\t\tconst hoverColor: Color = [...color];\n\t\thoverColor[3] = drawsquares.HOVER_OPACITY;\n\t\tsquarerendering.genModel(allHovered, hoverColor).render(undefined, undefined, { u_size });\n\t}\n}\n\n// Exports -------------------------------------------------------------------------\n\nexport default {\n\tgetPieceCoords,\n\tgetSquares,\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/highlights/selectedpiecehighlightline.ts",
    "content": "// src/client/scripts/esm/game/rendering/highlights/selectedpiecehighlightline.ts\n\n/**\n * [Zoomed out] This script calculates and renders the highlight lines\n * of the currently selected piece's legal moves.\n */\n\nimport type { Ray } from './annotations/annotations.js';\nimport type { Line } from './highlightline.js';\n\nimport bd from '@naviary/bigdecimal';\n\nimport geometry from '../../../../../../shared/util/math/geometry.js';\nimport bdcoords from '../../../../../../shared/chess/util/bdcoords.js';\nimport vectors, { Vec2, Vec2Key } from '../../../../../../shared/util/math/vectors.js';\nimport coordutil, {\n\tBDCoords,\n\tCoords,\n\tCoordsKey,\n} from '../../../../../../shared/chess/util/coordutil.js';\n\nimport boardpos from '../boardpos.js';\nimport selection from '../../chess/selection.js';\nimport preferences from '../../../components/header/preferences.js';\nimport highlightline from './highlightline.js';\n\n/**\n * Calculates all the lines formed from the highlight\n * lines of the current selected piece's legal moves.\n */\nfunction getLines(): Line[] {\n\tconst lines: Line[] = [];\n\n\tconst pieceSelected = selection.getPieceSelected()!;\n\tif (!pieceSelected) return lines;\n\n\tconst pieceCoords = pieceSelected.coords;\n\tconst legalmoves = selection.getLegalMovesOfSelectedPiece()!; // CAREFUL not to modify!\n\n\tconst boundingBox = highlightline.getRenderRange();\n\n\tconst color_options = {\n\t\tisOpponentPiece: selection.isOpponentPieceSelected(),\n\t\tisPremove: selection.arePremoving(),\n\t};\n\tconst color = preferences.getLegalMoveHighlightColor(color_options); // Returns a copy\n\tcolor[3] = 1; // Highlight lines should be fully opaque\n\n\tfor (const [strline, limits] of Object.entries(legalmoves.sliding)) {\n\t\tconst slideKey = strline as CoordsKey;\n\t\tconst step = coordutil.getCoordsFromKey(slideKey);\n\t\tconst lineIsVertical = step[0] === 0n;\n\t\tconst cappingAxis = lineIsVertical ? 1 : 0;\n\n\t\tconst intersectionPoints = geometry\n\t\t\t.findLineBoxIntersectionsBD(bdcoords.FromCoords(pieceCoords), step, boundingBox)\n\t\t\t.map((intersection) => intersection.coords);\n\t\tif (intersectionPoints.length < 2) continue;\n\n\t\tlet start: BDCoords = intersectionPoints[0]!;\n\t\tif (limits[0] !== null) {\n\t\t\t// The left slide limit has a chance of not reaching intsect1\n\t\t\tconst leftLimit: BDCoords = bdcoords.FromCoords([\n\t\t\t\tpieceCoords[0] + step[0] * limits[0],\n\t\t\t\tpieceCoords[1] + step[1] * limits[0],\n\t\t\t]); // The first index of limits is already negative, so we don't have to negate the step.\n\t\t\tif (bd.compare(leftLimit[cappingAxis], start[cappingAxis]) > 0) start = leftLimit;\n\t\t}\n\n\t\tlet end: BDCoords = intersectionPoints[1]!;\n\t\tif (limits[1] !== null) {\n\t\t\t// The right slide limit has a chance of not reaching intsect2\n\t\t\tconst rightLimit: BDCoords = bdcoords.FromCoords([\n\t\t\t\tpieceCoords[0] + step[0] * limits[1],\n\t\t\t\tpieceCoords[1] + step[1] * limits[1],\n\t\t\t]);\n\t\t\tif (bd.compare(rightLimit[cappingAxis], end[cappingAxis]) < 0) end = rightLimit;\n\t\t}\n\n\t\t// Skip if zero length\n\t\tif (coordutil.areBDCoordsEqual(start, end)) continue;\n\n\t\tconst coefficients = vectors.getLineGeneralFormFromCoordsAndVec(pieceCoords, step);\n\n\t\tlines.push({ start, end, coefficients, color, piece: pieceSelected.type });\n\t}\n\n\treturn lines;\n}\n\n/**\n * Start and end of a line segment. PERFECT integers!\n * We don't need to precalculate the line coefficients because of that.\n */\ntype Segment = {\n\tstart: Coords;\n\tend: Coords;\n};\n\n/**\n * Converts the selected piece's legal move highlight lines into\n * their ray and line segment components, depending on which slides are infinite or not.\n *\n * Used by drawrays.ts during collapsing, so we can add additional\n * Square annotations at all the intersections of rays with components.\n */\nfunction getLineComponents(): { rays: Ray[]; segments: Segment[] } {\n\tconst rays: Ray[] = [];\n\tconst segments: Segment[] = [];\n\n\tconst pieceSelected = selection.getPieceSelected()!;\n\tif (!pieceSelected) return { rays, segments };\n\n\tconst pieceCoords = pieceSelected.coords;\n\tconst legalmoves = selection.getLegalMovesOfSelectedPiece()!; // CAREFUL not to modify!\n\n\tfor (const [strline, limits] of Object.entries(legalmoves.sliding)) {\n\t\tconst slideKey = strline as Vec2Key;\n\t\tconst step: Vec2 = vectors.getVec2FromKey(slideKey);\n\t\tconst negStep: Vec2 = vectors.negateVector(step);\n\n\t\tif (limits[0] !== null && limits[0] > 0n) {\n\t\t\t// Special case: Offset positive -> legal zone is entirely ahead in the positive step direction. Close end is limits[0] steps away.\n\t\t\tconst closeCoords: Coords = [\n\t\t\t\tpieceCoords[0] + step[0] * limits[0],\n\t\t\t\tpieceCoords[1] + step[1] * limits[0],\n\t\t\t];\n\t\t\tconst limit = limits[1] === null ? null : limits[1] - limits[0];\n\t\t\tprocessComponent(closeCoords, step, limit);\n\t\t} else if (limits[1] !== null && limits[1] < 0n) {\n\t\t\t// Special case: Offset negative -> legal zone is entirely behind the piece in the negative step direction. Close end is abs(limits[1]) steps away.\n\t\t\tconst closeCoords: Coords = [\n\t\t\t\tpieceCoords[0] + step[0] * limits[1],\n\t\t\t\tpieceCoords[1] + step[1] * limits[1],\n\t\t\t];\n\t\t\tconst limit = limits[0] === null ? null : limits[1] - limits[0];\n\t\t\tprocessComponent(closeCoords, negStep, limit);\n\t\t} else {\n\t\t\t// Normal: limits span 0 (or one side is null), render both directions from the piece.\n\t\t\tprocessComponent(coordutil.copyCoords(pieceCoords), negStep, limits[0]); // Negative slide direction\n\t\t\tprocessComponent(coordutil.copyCoords(pieceCoords), step, limits[1]); // Positive slide direction\n\t\t}\n\t}\n\n\tfunction processComponent(start: Coords, step: Vec2, limit: bigint | null): void {\n\t\tif (limit === null) {\n\t\t\t// Can slide infinitly => RAY\n\t\t\tconst coefficients = vectors.getLineGeneralFormFromCoordsAndVec(start, step);\n\t\t\trays.push({ start, vector: step, line: coefficients });\n\t\t} else {\n\t\t\t// Can't slide infinitly => SEGMENT\n\t\t\tconst end: Coords = [start[0] + step[0] * limit, start[1] + step[1] * limit];\n\t\t\tsegments.push({ start, end });\n\t\t}\n\t}\n\n\treturn { rays, segments };\n}\n\nfunction render(): void {\n\tif (!boardpos.areZoomedOut()) return; // Quit if we're not even zoomed out.\n\n\tconst lines = getLines();\n\tif (lines.length === 0) return; // No lines to draw\n\n\thighlightline.genLinesModel(lines).render();\n}\n\nexport default {\n\tgetLines,\n\tgetLineComponents,\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/highlights/snapping.ts",
    "content": "// src/client/scripts/esm/game/rendering/highlights/snapping.ts\n\n/**\n * This script initiates teleports to all mini images and square annotes clicked.\n *\n * It also manages all renderd entities when zoomed out.\n */\n\nimport type { Line } from './highlightline.js';\nimport type { Color } from '../../../../../../shared/util/math/math.js';\nimport type {\n\tBDCoords,\n\tCoords,\n\tDoubleCoords,\n} from '../../../../../../shared/chess/util/coordutil.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport jsutil from '../../../../../../shared/util/jsutil.js';\nimport geometry from '../../../../../../shared/util/math/geometry.js';\nimport bdcoords from '../../../../../../shared/chess/util/bdcoords.js';\nimport boardutil from '../../../../../../shared/chess/util/boardutil.js';\nimport coordutil from '../../../../../../shared/chess/util/coordutil.js';\nimport vectors, { Ray, Vec2 } from '../../../../../../shared/util/math/vectors.js';\n\nimport space from '../../misc/space.js';\nimport mouse from '../../../util/mouse.js';\nimport meshes from '../meshes.js';\nimport guipause from '../../gui/guipause.js';\nimport gameslot from '../../chess/gameslot.js';\nimport drawrays from './annotations/drawrays.js';\nimport boardpos from '../boardpos.js';\nimport miniimage from '../miniimage.js';\nimport { Mouse } from '../../input.js';\nimport movehints from './movehints.js';\nimport Transition from '../transitions/Transition.js';\nimport primitives from '../primitives.js';\nimport perspective from '../perspective.js';\nimport drawsquares from './annotations/drawsquares.js';\nimport annotations from './annotations/annotations.js';\nimport preferences from '../../../components/header/preferences.js';\nimport texturecache from '../../../chess/rendering/texturecache.js';\nimport selectedpiecehighlightline from './selectedpiecehighlightline.js';\nimport { Renderable, createRenderable } from '../../../webgl/Renderable.js';\n\n// Variables --------------------------------------------------------------\n\n/** Width of entities (mini images, highlights) when zoomed out, in virtual pixels. */\nconst ENTITY_WIDTH_VPIXELS = 40; // Default: 40\n\n/** The color of the line that shows you what entity your mouse is snapped to. */\nconst SNAP_LINE_COLOR = [0, 0, 1, 0.3] as const;\n\n/** Properties of the glow dot when rendering the snapped coord. */\nconst GLOW_DOT = {\n\tRADIUS_PIXELS: 8,\n\tRESOLUTION: 16,\n};\n\n/**\n * The opacity of the ghost image that's rendered when hovering over\n * the highlight line of the selected piece's legal moves.\n */\nconst GHOST_IMAGE_OPACITY = 1;\n\n/**\n * If more pieces than this are present in the game, snapping skips\n * checking if we should snap to a piece, as it's too slow.\n */\nconst THRESHOLD_TO_SNAP_PIECES = 5_000;\n\ntype Snap = {\n\t/** A snap could potentially be between squares, so we need floating precision. */\n\tcoords: BDCoords;\n\t/** The color of the line we are snapped to. Already made opaque. */\n\tcolor: Color;\n\t/** The type of piece to render at the snap point, if applicable */\n\ttype?: number;\n\t/** The source that eminated the line we are snapping to, if we are snapping. */\n\tsource?: BDCoords;\n};\n\n// Entity Hovering ---------------------------------------------------------\n\n/**\n * {@link ENTITY_WIDTH_VPIXELS}, but converted to world-space units.\n * This can change depending on the screen dimensions.\n * Scale doesn't affect entity's visible size on screen.\n */\nfunction getEntityWidthWorld(): number {\n\treturn space.convertPixelsToWorldSpace_Virtual(ENTITY_WIDTH_VPIXELS);\n}\n\nfunction getAllEntitiesWorldHovers(world: DoubleCoords): Coords[] {\n\tconst imagesHovered = miniimage.getImagesBelowWorld(world, false).images;\n\tconst allSquares: Coords[] = [...annotations.getSquares(), ...movehints.getSquares()];\n\tconst highlightsHovered = drawsquares.getSquaresBelowWorld(allSquares, world, false).squares;\n\treturn [...imagesHovered, ...highlightsHovered];\n}\n\ntype ClosestEntity = {\n\tcoords: Coords;\n\t/** The euclidean distance in coordinates from the mouse to the entity. */\n\tdist: number;\n\ttype: 'miniimage' | 'square';\n\t/** The index of the entity within its home list. */\n\tindex: number;\n};\n\n/** Calculates the closest entity (piece/square) to the given world coords. */\nfunction getClosestEntityToWorld(world: DoubleCoords): ClosestEntity | undefined {\n\tif (!isSnappingEnabledThisFrame()) return undefined;\n\n\t// Find the closest hovered entity to the pointer\n\tlet closestEntity: ClosestEntity | undefined = undefined;\n\n\tconst imagesHovered = miniimage.getImagesBelowWorld(world, true);\n\tconst allSquares: Coords[] = [...annotations.getSquares(), ...movehints.getSquares()];\n\tconst highlightsHovered = drawsquares.getSquaresBelowWorld(allSquares, world, true);\n\n\t// Pieces\n\tfor (let i = 0; i < imagesHovered.images.length; i++) {\n\t\tconst coords = imagesHovered.images[i]!;\n\t\tconst dist = imagesHovered.dists![i]!;\n\t\tif (closestEntity === undefined || dist <= closestEntity.dist)\n\t\t\tclosestEntity = { coords, dist, type: 'miniimage', index: i };\n\t}\n\n\t// Square Highlights and Individual legal move hints\n\tfor (let i = 0; i < highlightsHovered.squares.length; i++) {\n\t\tconst coords = highlightsHovered.squares[i]!;\n\t\tconst dist = highlightsHovered.dists![i]!;\n\t\tif (closestEntity === undefined || dist <= closestEntity.dist)\n\t\t\tclosestEntity = { coords, dist, type: 'square', index: i };\n\t}\n\n\treturn closestEntity;\n}\n\n/**\n * Calculates what entities are below the click location.\n * Teleports to them, claiming the click.\n */\nfunction teleportToEntitiesIfClicked(): void {\n\tif (!isSnappingEnabledThisFrame()) return;\n\n\tif (!mouse.isMouseClicked(Mouse.LEFT) && !mouse.isMouseDown(Mouse.LEFT)) return; // Only teleport if clicked\n\n\tconst mouseWorld = mouse.getMouseWorld(Mouse.LEFT);\n\tif (!mouseWorld) return; // Maybe looking into sky?\n\n\tconst allEntitiesHovered = getAllEntitiesWorldHovers(mouseWorld);\n\n\t// console.log(\"Hovered entities:\", jsutil.deepCopyObject(allEntitiesHovered));\n\n\tif (allEntitiesHovered.length === 0) return; // No images to teleport to\n\n\tif (mouse.isMouseClicked(Mouse.LEFT)) {\n\t\tmouse.claimMouseClick(Mouse.LEFT);\n\t\tTransition.singleZoomToCoordsList(allEntitiesHovered);\n\t} else if (mouse.isMouseDown(Mouse.LEFT)) {\n\t\t// Allows second finger to grab the board\n\t\tmouse.claimMouseDown(Mouse.LEFT); // Remove the mouseDown so that other navigation controls don't use it (like board-grabbing)\n\t}\n}\n\n// Snapping --------------------------------------------------------------------\n\n/** We do not snap when zoomed in. */\nfunction isSnappingEnabledThisFrame(): boolean {\n\tif (!boardpos.areZoomedOut()) return false;\n\tif (guipause.areWePaused()) return false;\n\tif (perspective.getEnabled() && !perspective.isMouseLocked()) return false;\n\n\treturn true;\n}\n\n/** Snap's the provided world coords to the nearest snappable coords. */\nfunction getWorldSnapCoords(world: DoubleCoords): Coords | undefined {\n\tif (!isSnappingEnabledThisFrame()) return undefined;\n\n\tconst snap = snapPointerWorld(world);\n\tif (snap === undefined) return undefined;\n\telse return space.roundCoords(snap.coords);\n}\n\ntype LineSnapPoint = {\n\tline: Line;\n\tsnapPoint: { coords: BDCoords; distance: BigDecimal };\n};\n\n/**\n * Reads all calculated highlights lines (selected piece legal moves, drawn Rays),\n * eminates lines in all directions from all entities and calculates where those\n * intersect any of the highlight lines, calculating where we should snap the mouse to,\n * and teleporting if clicked.\n */\nfunction snapPointerWorld(world: DoubleCoords): Snap | undefined {\n\tconst pointerCoords = space.convertWorldSpaceToCoords(world);\n\tconst { boardsim } = gameslot.getGamefile()!;\n\n\tconst drawnRays = annotations.getRays();\n\tconst presetRays = drawrays.getPresetRays();\n\t/** All rays / selected piece legal move lines converted to SEGMENTS. */\n\tconst allLines: Line[] = getAllLinesSegmented(drawnRays, presetRays);\n\tif (allLines.length === 0) return; // No lines to have snap\n\n\tconst snapDistVPixels = ENTITY_WIDTH_VPIXELS * 0.5;\n\t/** THe minimum distance from a snap point, in world space, for our mouse to snap to it. */\n\tconst snapDistWorld: BigDecimal = bd.fromNumber(\n\t\tspace.convertPixelsToWorldSpace_Virtual(snapDistVPixels),\n\t);\n\t/** The mimimum distance from a snap point, in squares, for our mouse to snap to it. */\n\tconst snapDistSquares: BigDecimal = bd.divideFloating(snapDistWorld, boardpos.getBoardScale());\n\n\t// First see if the pointer is even CLOSE to any of these lines,\n\t// as otherwise we can't snap to anything anyway.\n\tconst linesSnapPoints: LineSnapPoint[] = allLines.map((line) => {\n\t\tconst snapPoint = geometry.closestPointOnLineSegment(\n\t\t\tline.coefficients,\n\t\t\tline.start,\n\t\t\tline.end,\n\t\t\tpointerCoords,\n\t\t);\n\t\treturn { line, snapPoint };\n\t});\n\n\tlet closestSnap: LineSnapPoint = linesSnapPoints[0]!;\n\tfor (const lineSnapPoint of linesSnapPoints) {\n\t\tif (bd.compare(lineSnapPoint.snapPoint.distance, closestSnap.snapPoint.distance) < 0)\n\t\t\tclosestSnap = lineSnapPoint;\n\t}\n\n\tif (bd.compare(closestSnap.snapPoint.distance, snapDistSquares) > 0) {\n\t\t// console.log(\"pointer no close snap\");\n\t\treturn; // No line close enough for the pointer to snap to anything\n\t}\n\n\t// At this point we know we WILL be snapping to something.\n\n\t// Filter out lines which the mouse is too far away from\n\tconst closeLines = linesSnapPoints.filter(\n\t\t(lsp) => bd.compare(lsp.snapPoint.distance, snapDistSquares) <= 0,\n\t);\n\n\t/**\n\t * Next, calculate all intersection points of all highlight lines (drawn rays, preset rays, and legal moves),\n\t * and see if the mouse is close enough to snap to them.\n\t *\n\t * If so, those take priority.\n\t */\n\n\ttype Intersection = {\n\t\tcoords: BDCoords;\n\t\tline1: Line;\n\t\tline2: Line;\n\t};\n\n\tconst line_intersections: Intersection[] = [];\n\tfor (let a = 0; a < closeLines.length - 1; a++) {\n\t\tconst line1 = closeLines[a]!;\n\t\tfor (let b = a + 1; b < closeLines.length; b++) {\n\t\t\tconst line2 = closeLines[b]!;\n\t\t\t// Calculate where they intersect\n\t\t\t// prettier-ignore\n\t\t\tconst intsect = geometry.intersectLineSegments(line1.line.coefficients, line1.line.start, line1.line.end, line2.line.coefficients, line2.line.start, line2.line.end);\n\t\t\tif (intsect === undefined) continue; // Don't intersect\n\t\t\t// Push it to the intersections, preventing duplicates\n\t\t\tif (!line_intersections.some((i) => coordutil.areBDCoordsEqual(i.coords, intsect)))\n\t\t\t\tline_intersections.push({\n\t\t\t\t\tcoords: intsect,\n\t\t\t\t\tline1: line1.line,\n\t\t\t\t\tline2: line2.line,\n\t\t\t\t});\n\t\t}\n\t}\n\n\t// Calculate closest one to the pointer\n\n\tlet closestIntsect: { intersection: Intersection; dist: BigDecimal } | undefined;\n\tfor (const i of line_intersections) {\n\t\t// Calculate distance to mouse\n\t\tconst dist = vectors.euclideanDistanceBD(i.coords, pointerCoords);\n\t\tif (closestIntsect === undefined || bd.compare(dist, closestIntsect.dist) < 0)\n\t\t\tclosestIntsect = { intersection: i, dist };\n\t}\n\n\tif (closestIntsect !== undefined && bd.compare(closestIntsect.dist, snapDistSquares) <= 0) {\n\t\t// SNAP to this line intersection, and exit! It takes priority\n\n\t\t// If one of the lines `piece` is defined, set the snap's type to that piece.\n\t\tconst type =\n\t\t\tclosestIntsect.intersection.line1.piece ?? closestIntsect.intersection.line2.piece;\n\n\t\t// Blend the colors of the two lines\n\t\tconst color1 = closestIntsect.intersection.line1.color;\n\t\tconst color2 = closestIntsect.intersection.line2.color;\n\t\tconst color: Color = [\n\t\t\t(color1[0] + color2[0]) / 2,\n\t\t\t(color1[1] + color2[1]) / 2,\n\t\t\t(color1[2] + color2[2]) / 2,\n\t\t\t(color1[3] + color2[3]) / 2,\n\t\t];\n\n\t\treturn { coords: closestIntsect.intersection.coords, color, type };\n\t}\n\n\t/**\n\t * At this point, there is no intersections of lines to snap to.\n\t *\n\t * Next, eminate lines in all directions from each entity, seeing where they cross\n\t * existing lines, calculating what we should snap to.\n\t */\n\n\t// Allows snapping to all hippogonals, even the ones in 4D variants.\n\t// const allPrimitiveSlidesInGame = boardsim.pieces.slides.filter((vector: Vec2) => math.GCD(vector[0], vector[1]) === 1); // Filters out colinears, and thus potential repeats.\n\t// Minimal snapping vectors\n\t// prettier-ignore\n\tconst searchVectors = boardsim.pieces.hippogonalsPresent ? [\n\t\t\t\t...vectors.VECTORS_ORTHOGONAL,\n\t\t\t\t...vectors.VECTORS_DIAGONAL,\n\t\t...vectors.VECTORS_HIPPOGONAL\n\t] : [\n\t\t...vectors.VECTORS_ORTHOGONAL,\n\t\t...vectors.VECTORS_DIAGONAL\n\t];\n\n\t// 1. Square Annotes & Intersections of Rays & Ray starts (same priority) ==================\n\n\tconst annoteSnapPoints = getAnnoteSnapPoints(false);\n\tconst closestAnnoteSnap = findClosestEntityOfGroup(\n\t\tannoteSnapPoints,\n\t\tcloseLines,\n\t\tpointerCoords,\n\t\tsearchVectors,\n\t);\n\tif (closestAnnoteSnap !== undefined) {\n\t\t// Is the snap within snapping distance of the mouse?\n\t\tif (bd.compare(closestAnnoteSnap.dist, snapDistSquares) < 0) return closestAnnoteSnap.snap;\n\t}\n\n\t// 2. Pieces ========================================\n\n\t// Only snap to these if there isn't too many pieces (slow)\n\tif (boardutil.getPieceCountOfGame(boardsim.pieces) < THRESHOLD_TO_SNAP_PIECES) {\n\t\tconst pieces: BDCoords[] = boardutil\n\t\t\t.getCoordsOfAllPieces(boardsim.pieces)\n\t\t\t.map((c) => bdcoords.FromCoords(c)); // Convert to BDCoords\n\t\tconst closestPieceSnap = findClosestEntityOfGroup(\n\t\t\tpieces,\n\t\t\tcloseLines,\n\t\t\tpointerCoords,\n\t\t\tsearchVectors,\n\t\t);\n\t\tif (closestPieceSnap !== undefined) {\n\t\t\t// Is the snap within snapping distance of the mouse?\n\t\t\tif (bd.compare(closestPieceSnap.dist, snapDistSquares) < 0)\n\t\t\t\treturn closestPieceSnap.snap;\n\t\t}\n\t}\n\n\t// 3. Origin (Center of Play) ==============================\n\n\t// DISABLED for now. I don't really like it\n\t// const startingBox = gamefileutility.getStartingAreaBox(boardsim);\n\t// const startingBoxBD = bounds.castBoundingBoxToBigDecimal(startingBox);\n\t// const origin: BDCoords = bounds.calcCenterOfBoundingBox(startingBoxBD);\n\t// const closestOriginSnap = findClosestEntityOfGroup([origin], closeLines, pointerCoords, searchVectors);\n\t// if (closestOriginSnap !== undefined) {\n\t// \t// Is the snap within snapping distance of the mouse?\n\t// \tif (bd.compare(closestOriginSnap.dist, snapDistSquares) < 0) return closestOriginSnap.snap;\n\t// }\n\n\t// No snap found! ===========================================\n\n\t// Instead, set the snap to the closest point on the line.\n\treturn {\n\t\tcoords: closestSnap.snapPoint.coords,\n\t\tcolor: closestSnap.line.color,\n\t\ttype: closestSnap.line.piece,\n\t};\n}\n\nfunction teleportToSnapIfClicked(): void {\n\tif (!isSnappingEnabledThisFrame()) return;\n\n\tif (mouse.isMouseClicked(Mouse.LEFT) || mouse.isMouseDown(Mouse.LEFT)) {\n\t\tconst world = mouse.getMouseWorld(Mouse.LEFT);\n\t\tif (!world) return; // Maybe looking into sky?\n\t\tconst snap = snapPointerWorld(world);\n\t\tif (snap === undefined) return; // No snap to teleport to\n\t\tif (mouse.isMouseClicked(Mouse.LEFT)) {\n\t\t\tmouse.claimMouseClick(Mouse.LEFT);\n\t\t\tTransition.singleZoomToBDCoords(snap.coords);\n\t\t} else if (mouse.isMouseDown(Mouse.LEFT)) {\n\t\t\tmouse.claimMouseDown(Mouse.LEFT); // Remove the mouseDown so that other navigation controls don't use it (like board-grabbing)\n\t\t}\n\t}\n}\n\n/**\n * Finds the entity which snapping point to a line near the mouse is closest to the mouse.\n * Eminates lines from each entity in all directions, and checks if they intersect any of the lines close to the mouse.\n */\nfunction findClosestEntityOfGroup(\n\tentities: BDCoords[],\n\tcloseLines: LineSnapPoint[],\n\tmouseCoords: BDCoords,\n\tsearchVectors: Vec2[],\n): { snap: Snap; dist: BigDecimal } | undefined {\n\tlet closestEntitySnap: { snap: Snap; dist: BigDecimal } | undefined;\n\n\tfor (const entityCoords of entities) {\n\t\t// Eminate lines in all directions from the entity coords\n\t\tconst eminatingLines = searchVectors.map((l) =>\n\t\t\tvectors.getLineGeneralFormFromCoordsAndVecBD(entityCoords, l),\n\t\t);\n\n\t\t// Calculate their intersections with each individual line close to the mouse\n\t\tfor (const eminatedLine of eminatingLines) {\n\t\t\tfor (const highlightLine of closeLines) {\n\t\t\t\t// Do they intersect?\n\t\t\t\tconst intersection = geometry.intersectLineAndSegment(\n\t\t\t\t\teminatedLine,\n\t\t\t\t\thighlightLine.line.coefficients,\n\t\t\t\t\thighlightLine.line.start,\n\t\t\t\t\thighlightLine.line.end,\n\t\t\t\t);\n\t\t\t\tif (intersection === undefined) continue;\n\t\t\t\t// They DO intersect.\n\t\t\t\t// 25% fps boost: The (faster to calculate) chebyshev distance can never be larger than the euclidean distance.\n\t\t\t\t// So, we know we only have to calculate the euclidean distance if the chebyshev distance is closer than the previous closest snap.\n\t\t\t\tconst chebyDist = vectors.chebyshevDistanceBD(intersection, mouseCoords);\n\t\t\t\tif (\n\t\t\t\t\tclosestEntitySnap !== undefined &&\n\t\t\t\t\tbd.compare(chebyDist, closestEntitySnap.dist) >= 0\n\t\t\t\t)\n\t\t\t\t\tcontinue; // Chebyshev distance isn't even within the threshold, the euclidean distance won't be either.\n\t\t\t\tconst euclidDist = vectors.euclideanDistanceBD(intersection, mouseCoords);\n\t\t\t\t// Is the intersection point closer to the mouse than the previous closest snap?\n\t\t\t\t// const intersectionWorld = space.convertCoordToWorldSpace(intersection);\n\t\t\t\tif (\n\t\t\t\t\tclosestEntitySnap === undefined ||\n\t\t\t\t\tbd.compare(euclidDist, closestEntitySnap.dist) < 0\n\t\t\t\t) {\n\t\t\t\t\tconst snap = {\n\t\t\t\t\t\tcoords: intersection,\n\t\t\t\t\t\tcolor: highlightLine.line.color,\n\t\t\t\t\t\ttype: highlightLine.line.piece,\n\t\t\t\t\t\tsource: jsutil.deepCopyObject(entityCoords),\n\t\t\t\t\t};\n\t\t\t\t\tclosestEntitySnap = { snap, dist: euclidDist };\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn closestEntitySnap;\n}\n\n/** All rays / selected piece legal move lines converted to SEGMENTS. */\nfunction getAllLinesSegmented(drawnRays: Ray[], presetRays: Ray[]): Line[] {\n\t// Drawn rays\n\tconst rayColor = preferences.getAnnoteSquareColor();\n\trayColor[3] = 1; // Highlightlines are fully opaque\n\tconst rayLines = drawrays.getLines(drawnRays, rayColor);\n\n\t// Preset rays\n\tconst presetRayColor: Color = [...drawrays.PRESET_RAY_COLOR];\n\tpresetRayColor[3] = 1; // Highlightlines are fully opaque\n\tconst presetRayLines = drawrays.getLines(presetRays, presetRayColor);\n\n\t// Selected piece legal move line\n\tconst selectedPieceLegalMovesLines = selectedpiecehighlightline.getLines();\n\n\treturn [...rayLines, ...presetRayLines, ...selectedPieceLegalMovesLines];\n}\n\n/**\n * Returns a list of coords of all the highest priority snap points.\n * That is all Square annotations, Ray starts, and intersections of rays\n * (which may include legal move ray intersections).\n * @param trimDecimals - Whether to ignore points that don't end up at an integer square.\n */\nfunction getAnnoteSnapPoints(trimDecimals: boolean): BDCoords[] {\n\treturn [\n\t\t...annotations.getSquares().map((s) => bdcoords.FromCoords(s)), // Cast square annotations to BDCoords\n\t\t...drawrays.collapseRays(annotations.getRays(), trimDecimals),\n\t];\n}\n\n// Rendering --------------------------------------------------------------\n\n/**\n * Snapping is in charge of rendering either a glow dot on the snap point,\n * or a mini image of a piece on the legal move line.\n */\nfunction render(): void {\n\tif (!isSnappingEnabledThisFrame()) return;\n\n\tconst relevantListener = mouse.getRelevantListener();\n\tconst allPhysicalPointerIds = relevantListener.getAllPhysicalPointers();\n\tconst allSnaps: Snap[] = [];\n\tfor (const physicalPointerId of allPhysicalPointerIds) {\n\t\tif (\n\t\t\tdrawrays.areDrawing() &&\n\t\t\trelevantListener.doesPointerBelongToPhysicalPointer(\n\t\t\t\tdrawrays.getPointerId(),\n\t\t\t\tphysicalPointerId,\n\t\t\t)\n\t\t)\n\t\t\tcontinue; // Don't snap the physical pointer that is currently drawing a ray\n\t\tconst pointerWorld = mouse.getPhysicalPointerWorld(physicalPointerId);\n\t\tif (!pointerWorld) continue; // This pointer may be in the sky?\n\t\tif (getAllEntitiesWorldHovers(pointerWorld).length > 0) continue; // Don't snap if this pointer is hovering over an entity\n\t\tconst snap = snapPointerWorld(pointerWorld);\n\t\tif (snap !== undefined) allSnaps.push(snap);\n\t}\n\n\tif (allSnaps.length === 0) return; // No snaps to render\n\n\tfor (const snap of allSnaps) {\n\t\t// Render a single line between the snap point and its source\n\n\t\tif (snap.source !== undefined) {\n\t\t\tconst [r, g, b, a] = SNAP_LINE_COLOR;\n\t\t\tconst start = space.convertCoordToWorldSpace(snap.source);\n\t\t\tconst end = space.convertCoordToWorldSpace(snap.coords);\n\t\t\t// prettier-ignore\n\t\t\tconst data = [\n\t\t\t\t//   Vertex              Color\n\t\t\t\tstart[0], start[1],   r, g, b, a,\n\t\t\t\tend[0], end[1],       r, g, b, a\n\t\t\t];\n\t\t\tcreateRenderable(data, 2, 'LINES', 'color', true).render();\n\t\t}\n\n\t\t// Next we render either the glow dot or the mini image of the piece.\n\n\t\tconst coordsWorld = space.convertCoordToWorldSpace_IgnoreSquareCenter(snap.coords);\n\n\t\tif (snap.type === undefined) {\n\t\t\t// Render glow dot\n\t\t\tconst color = snap.color;\n\t\t\tconst colorTransparent = jsutil.deepCopyObject(color);\n\t\t\tcolorTransparent[3] = 0;\n\n\t\t\tconst radius = space.convertPixelsToWorldSpace_Virtual(GLOW_DOT.RADIUS_PIXELS);\n\t\t\t// prettier-ignore\n\t\t\tconst data: number[] = primitives.GlowDot(...coordsWorld, radius, GLOW_DOT.RESOLUTION, color, colorTransparent);\n\t\t\tcreateRenderable(data, 2, 'TRIANGLE_FAN', 'color', true).render();\n\t\t} else {\n\t\t\t// Render mini image of piece\n\t\t\tconst model = generateGhostImageModel(snap.type, coordsWorld);\n\t\t\tmodel.render();\n\t\t}\n\t}\n}\n\nfunction generateGhostImageModel(type: number, coords: DoubleCoords): Renderable {\n\tconst dataGhost: number[] = [];\n\n\tconst { texleft, texbottom, texright, textop } = meshes.getPieceTexCoords();\n\n\tconst entityWorldWidth = getEntityWidthWorld();\n\tconst halfWidth = entityWorldWidth / 2;\n\n\tconst startX = coords[0] - halfWidth;\n\tconst startY = coords[1] - halfWidth;\n\tconst endX = startX + entityWorldWidth;\n\tconst endY = startY + entityWorldWidth;\n\n\t// prettier-ignore\n\tconst data = primitives.Quad_ColorTexture(startX, startY, endX, endY, texleft, texbottom, texright, textop, 1, 1, 1, GHOST_IMAGE_OPACITY);\n\n\tdataGhost.push(...data);\n\n\treturn createRenderable(\n\t\tdataGhost,\n\t\t2,\n\t\t'TRIANGLES',\n\t\t'colorTexture',\n\t\ttrue,\n\t\ttexturecache.getTexture(type),\n\t);\n}\n\n// Exports --------------------------------------------------------------\n\nexport default {\n\tgetEntityWidthWorld,\n\n\tgetClosestEntityToWorld,\n\tteleportToEntitiesIfClicked,\n\tgetAnnoteSnapPoints,\n\n\tgetWorldSnapCoords,\n\tteleportToSnapIfClicked,\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/highlights/specialrighthighlights.ts",
    "content": "// src/client/scripts/esm/game/rendering/highlights/specialrighthighlights.ts\n\n/**\n * This is a DEBUGGING script for rendering special right and enpassant highlights.\n *\n * Enable by pressing `7`.\n */\n\nimport type { Color } from '../../../../../../shared/util/math/math.js';\nimport type { Coords } from '../../../../../../shared/chess/util/coordutil.js';\n\nimport coordutil from '../../../../../../shared/chess/util/coordutil.js';\n\nimport toast from '../../gui/toast.js';\nimport meshes from '../meshes.js';\nimport gameslot from '../../chess/gameslot.js';\nimport boardpos from '../boardpos.js';\nimport piecemodels from '../piecemodels.js';\nimport { GameBus } from '../../GameBus.js';\nimport frametracker from '../frametracker.js';\nimport legalmovemodel from './legalmovemodel.js';\nimport legalmoveshapes from '../instancedshapes.js';\nimport squarerendering from './squarerendering.js';\nimport { RenderableInstanced, createRenderable_Instanced } from '../../../webgl/Renderable.js';\n\n// Variables -------------------------------------------------------------------------------------\n\n/** The color of the special rights indicator. */\nconst SPECIAL_RIGHTS_COLOR: Color = [0, 1, 0.5, 0.3];\n/* The color of the enpassant indicator. */\nconst ENPASSANT_COLOR: Color = [0.5, 0, 1, 0.3];\n\n/** Whether to render special right and enpassant highlights */\nlet enabled = false;\nlet model: RenderableInstanced | undefined;\n\n// Events ----------------------------------------------------------------------------------------\n\nGameBus.addEventListener('game-loaded', () => {\n\tregenModel();\n});\nGameBus.addEventListener('game-unloaded', () => {\n\t// Erase the model so it doesn't carry over to next loaded game\n\tmodel = undefined;\n});\nGameBus.addEventListener('physical-move', () => {\n\tregenModel();\n});\n\n// Functions -------------------------------------------------------------------------------------\n\nfunction enable(): void {\n\tenabled = true;\n\tregenModel();\n\tframetracker.onVisualChange();\n}\n\nfunction disable(): void {\n\tenabled = false;\n\tframetracker.onVisualChange();\n}\n\nfunction toggle(): void {\n\tenabled = !enabled;\n\ttoast.show(`Toggled special rights highlights: ${enabled}`, { durationMultiplier: 0.5 });\n\tregenModel();\n\tframetracker.onVisualChange();\n}\n\nfunction render(): void {\n\tif (!enabled) return; // Not enabled\n\n\trenderSpecialRights();\n\trenderEnPassant();\n}\n\nfunction regenModel(): void {\n\tif (!enabled) return; // Not enabled\n\n\t// console.log('Regenerating specialrights model');\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst model_Offset: Coords = legalmovemodel.getOffset();\n\t// Instance data\n\tconst squaresToHighlight: bigint[] = [];\n\tfor (const key of gamefile.boardsim.state.global.specialRights) {\n\t\tconst coords = coordutil.getCoordsFromKey(key);\n\t\tconst offsetCoord = coordutil.subtractCoords(coords, model_Offset);\n\t\tsquaresToHighlight.push(...offsetCoord);\n\t}\n\t// const vertexData: number[] = legalmoveshapes.getDataLegalMoveCornerTris(SPECIAL_RIGHTS_COLOR);\n\t// const vertexData: number[] = legalmoveshapes.getDataLegalMoveSquare(SPECIAL_RIGHTS_COLOR);\n\tconst vertexData: number[] = legalmoveshapes.getDataPlusSign(SPECIAL_RIGHTS_COLOR);\n\tmodel = createRenderable_Instanced(\n\t\tvertexData,\n\t\tpiecemodels.castBigIntArrayToFloat32(squaresToHighlight),\n\t\t'TRIANGLES',\n\t\t'colorInstanced',\n\t\ttrue,\n\t);\n}\n\nfunction renderSpecialRights(): void {\n\tif (!model) throw Error('Specialrights model not initialized');\n\n\tconst { position, scale } = meshes.getBoardRenderTransform(legalmovemodel.getOffset());\n\n\tmodel.render(position, scale);\n}\n\nfunction renderEnPassant(): void {\n\tconst gamefile = gameslot.getGamefile()!;\n\tif (!gamefile.boardsim.state.global.enpassant) return; // No enpassant gamefile property\n\n\tconst u_size = boardpos.getBoardScaleAsNumber();\n\tsquarerendering\n\t\t.genModel([gamefile.boardsim.state.global.enpassant.square], ENPASSANT_COLOR)\n\t\t.render(undefined, undefined, { u_size });\n}\n\n// Exports -----------------------------------------------------------------------\n\nexport default {\n\tenable,\n\tdisable,\n\ttoggle,\n\tregenModel,\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/highlights/squarerendering.ts",
    "content": "// src/client/scripts/esm/game/rendering/highlights/squarerendering.ts\n\n/**\n * This script knows how to generate buffer\n * models for rendering square highlights, such as:\n *\n * * Last move highlight\n * * Square annotations\n * * Premove highlights\n */\n\nimport type { Color } from '../../../../../../shared/util/math/math.js';\nimport type { Coords } from '../../../../../../shared/chess/util/coordutil.js';\n\nimport bdcoords from '../../../../../../shared/chess/util/bdcoords.js';\n\nimport space from '../../misc/space.js';\nimport instancedshapes from '../instancedshapes.js';\nimport { RenderableInstanced, createRenderable_Instanced } from '../../../webgl/Renderable.js';\n\n/**\n * Generates a renderable buffer model for square highlights from given coordinates.\n * Doesn't require any position or scale tranformations before rendering, you can just call\n * `.render(undefined, undefined, { u_size: boardpos.getBoardScaleAsNumber() });` on the returned model.\n *\n * This type of model requires regeneration every single frame, so don't use it\n * if you have an arbitrary number of squares to render.\n */\nfunction genModel(highlights: Coords[], color: Color): RenderableInstanced {\n\tconst vertexData: number[] = instancedshapes.getDataLegalMoveSquare(color);\n\tconst instanceData: number[] = [];\n\n\thighlights.forEach((coords) => {\n\t\t// const worldLoc = space.convertCoordToWorldSpace_IgnoreSquareCenter(bd.FromCoords(coords));\n\t\tconst worldLoc = space.convertCoordToWorldSpace(bdcoords.FromCoords(coords));\n\t\tinstanceData.push(...worldLoc);\n\t});\n\n\treturn createRenderable_Instanced(vertexData, instanceData, 'TRIANGLES', 'highlights', true);\n}\n\nexport default {\n\tgenModel,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/instancedshapes.ts",
    "content": "// src/client/scripts/esm/game/rendering/instancedshapes.ts\n\n/**\n * This script calculates the vertex data of a single instance\n * of several different kinds of shapes.\n *\n * Many are used for rendering legal moves, like the square, dot, or corner triangles.\n * The plus sign is used for special rights highlighting.\n *\n * The vertex data returned from any shape in this script\n * ALWAYS has a stride length of 6 (x,y, r,g,b,a)\n */\n\nimport type { Color } from '../../../../../shared/util/math/math.js';\nimport type { DoubleCoords } from '../../../../../shared/chess/util/coordutil.js';\n\nimport board from './boardtiles.js';\nimport meshes from './meshes.js';\nimport primitives from './primitives.js';\nimport preferences from '../../components/header/preferences.js';\n\n// Variables ------------------------------------------------------------------------------\n\n/**\n * Properties for the dots that are rendered on legal squares without an occupying piece.\n */\nconst DOTS = {\n\t/** The radius of the dots, where 1 equals the width of one square. */\n\tRADIUS: 0.16,\n\t/** How many points the edge of the dots have. */\n\tRESOLUTION: 32,\n\t/**\n\t * This will be added to the theme's legal move color's opacity,\n\t * as dots are a little less noticeable than big squares,\n\t * so increasing their opacity helps.\n\t */\n\tOPACITY_OFFSET: 0.2,\n};\n\n/**\n * Properties for the corner triangles that are rendered on legal squares with an occupied piece,\n * they typically signify legal captures.\n */\nconst CORNER_TRIS = {\n\t/** The radius of the corner triangles, where 1 equals the width of one square. */\n\tTRI_WIDTH: 0.5,\n\t/**\n\t * This will be added to the theme's legal move color's opacity,\n\t * as the triangles are a little less noticeable than big squares,\n\t * so increasing their opacity helps.\n\t */\n\tOPACITY_OFFSET: 0.2,\n};\n\n/** Properties for the box outline that is rendered over the hovered square during dragging. */\nconst BOX_OUTLINE = {\n\t/** The width of the outline border, where 1 equals the width of one square. */\n\tEDGE_WIDTH: 0.07,\n};\n\n/**\n * Properties for the plus sign that is rendered when the special rights highlighing\n * debug mode is enabled, next to each piece that has its special rights.\n */\nconst PLUS_SIGN = {\n\t/** Default position of the plus sign center within a square ([0,0] is square center, [0.5,0.5] is top-right corner) */\n\tPOSITION: [0.3, 0.3] as DoubleCoords, // Default: [0.3, 0.3]\n\t/** Length of both arms (horizontal and vertical) where 1.0 spans full square */\n\tARM_LENGTH: 0.4, // Default: 0.4\n\t/** Width of the plus sign arms */\n\tEDGE_WIDTH: 0.12, // Default: 0.12\n\t/** Added to color alpha for better visibility */\n\tOPACITY_OFFSET: 0.2, // Default: 0.2\n};\n\n// Functions ------------------------------------------------------------------------------\n\n/**\n * Generates the legal move square instance mesh, centered on [0,0]\n * @param color - The color [r, g, b, a]. This should MATCH the current theme's legal move color!\n * @returns The vertex data for the legal move square.\n */\nfunction getDataLegalMoveSquare(color: Color): number[] {\n\tconst coords: DoubleCoords = [0, 0]; // The instance is going to be at [0,0]\n\n\t// Generate and return the vertex data for the legal move square.\n\treturn meshes.QuadModel_Color(coords, color);\n}\n\n/**\n * Generates the legal move dot instance mesh, centered on [0,0]\n * @param color - The color [r, g, b, a]. This should MATCH the current theme's legal move color! An offset will be applied to its opacity.\n * @returns The vertex data for the \"legal move dot\" (circle).\n */\nfunction getDataLegalMoveDot(color: Color): number[] {\n\tconst colorCopy: Color = [...color]; // Don't mutate the original\n\tcolorCopy[3] += DOTS.OPACITY_OFFSET; // Add the offset\n\tcolorCopy[3] = Math.min(colorCopy[3], 1); // Cap it\n\n\tconst coords: DoubleCoords = [0, 0]; // The instance is going to be at [0,0]\n\t// The calculated dot's x & y have to be the VISUAL-CENTER of the square, not exactly at [0,0]\n\tconst x = coords[0] + (1 - board.getSquareCenterAsNumber()) - 0.5;\n\tconst y = coords[1] + (1 - board.getSquareCenterAsNumber()) - 0.5;\n\n\t// Generate and return the vertex data for the legal move dot (circle)\n\treturn primitives.Circle(x, y, DOTS.RADIUS, DOTS.RESOLUTION, colorCopy);\n}\n\n/**\n * Generates vertex data for four corner triangles used for legal move indicators,\n * with opacity adjustment and proper visual centering.\n * @param color - Color [r, g, b, a] from theme (opacity offset will be applied)\n * @returns Vertex data for four corner triangles\n */\nfunction getDataLegalMoveCornerTris(color: [number, number, number, number]): number[] {\n\t// Adjust opacity\n\t// eslint-disable-next-line prefer-const\n\tlet [r, g, b, a] = color;\n\ta = Math.min(a + CORNER_TRIS.OPACITY_OFFSET, 1);\n\n\t// Calculate visual center position (original [0,0] instance adjusted for board centering)\n\tconst boardCenterAdjust = 1 - board.getSquareCenterAsNumber() - 0.5;\n\tconst centerX = boardCenterAdjust;\n\tconst centerY = boardCenterAdjust;\n\n\tconst vertices: number[] = [];\n\tconst squareHalfSize = 0.5;\n\tconst triHalfWidth = CORNER_TRIS.TRI_WIDTH / 2;\n\n\t// Helper to add a single corner triangle\n\tconst addTriangle = (cornerX: number, cornerY: number, dx: number, dy: number): void => {\n\t\t// prettier-ignore\n\t\tvertices.push(\n\t\t\tcornerX, cornerY, r, g, b, a,\n\t\t\tcornerX + dx, cornerY, r, g, b, a,\n\t\t\tcornerX, cornerY + dy, r, g, b, a\n\t\t);\n\t};\n\n\t// Generate all four corners\n\taddTriangle(centerX - squareHalfSize, centerY + squareHalfSize, triHalfWidth, -triHalfWidth); // Top-left\n\taddTriangle(centerX + squareHalfSize, centerY + squareHalfSize, -triHalfWidth, -triHalfWidth); // Top-right\n\taddTriangle(centerX - squareHalfSize, centerY - squareHalfSize, triHalfWidth, triHalfWidth); // Bottom-left\n\taddTriangle(centerX + squareHalfSize, centerY - squareHalfSize, -triHalfWidth, triHalfWidth); // Bottom-right\n\n\treturn vertices;\n}\n\n/**\n * Generates vertex data for a plus sign using 5 non-overlapping rectangles\n */\nfunction getDataPlusSign(color: Color): number[] {\n\t// eslint-disable-next-line prefer-const\n\tlet [r, g, b, a] = color;\n\ta = Math.min(a + PLUS_SIGN.OPACITY_OFFSET, 1);\n\n\tconst halfEdge = PLUS_SIGN.EDGE_WIDTH / 2;\n\tconst armLength = PLUS_SIGN.ARM_LENGTH;\n\tconst [posX, posY] = PLUS_SIGN.POSITION;\n\n\tconst vertices: number[] = [];\n\n\t// Helper to add quad vertices (2 triangles)\n\t// prettier-ignore\n\tconst addQuad = (x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number): void => {\n\t\t// Triangle 1\n\t\tvertices.push(x1, y1, r, g, b, a);\n\t\tvertices.push(x2, y2, r, g, b, a);\n\t\tvertices.push(x3, y3, r, g, b, a);\n\t\t// Triangle 2\n\t\tvertices.push(x3, y3, r, g, b, a);\n\t\tvertices.push(x4, y4, r, g, b, a);\n\t\tvertices.push(x1, y1, r, g, b, a);\n\t};\n\n\t// Vertical arm (top segment)\n\taddQuad(\n\t\tposX - halfEdge,\n\t\tposY + armLength / 2, // top-left\n\t\tposX + halfEdge,\n\t\tposY + armLength / 2, // top-right\n\t\tposX + halfEdge,\n\t\tposY + halfEdge, // bottom-right\n\t\tposX - halfEdge,\n\t\tposY + halfEdge, // bottom-left\n\t);\n\t// Vertical arm (bottom segment)\n\taddQuad(\n\t\tposX - halfEdge,\n\t\tposY - halfEdge, // top-left\n\t\tposX + halfEdge,\n\t\tposY - halfEdge, // top-right\n\t\tposX + halfEdge,\n\t\tposY - armLength / 2, // bottom-right\n\t\tposX - halfEdge,\n\t\tposY - armLength / 2, // bottom-left\n\t);\n\t// Horizontal arm (left segment)\n\taddQuad(\n\t\tposX - armLength / 2,\n\t\tposY + halfEdge, // top-left\n\t\tposX - halfEdge,\n\t\tposY + halfEdge, // top-right\n\t\tposX - halfEdge,\n\t\tposY - halfEdge, // bottom-right\n\t\tposX - armLength / 2,\n\t\tposY - halfEdge, // bottom-left\n\t);\n\t// Horizontal arm (right segment)\n\taddQuad(\n\t\tposX + halfEdge,\n\t\tposY + halfEdge, // top-left\n\t\tposX + armLength / 2,\n\t\tposY + halfEdge, // top-right\n\t\tposX + armLength / 2,\n\t\tposY - halfEdge, // bottom-right\n\t\tposX + halfEdge,\n\t\tposY - halfEdge, // bottom-left\n\t);\n\t// Center square\n\taddQuad(\n\t\tposX - halfEdge,\n\t\tposY + halfEdge, // top-left\n\t\tposX + halfEdge,\n\t\tposY + halfEdge, // top-right\n\t\tposX + halfEdge,\n\t\tposY - halfEdge, // bottom-right\n\t\tposX - halfEdge,\n\t\tposY - halfEdge, // bottom-left\n\t);\n\n\treturn vertices;\n}\n\n/**\n * Generates the vertex data for a box outline (frame) indicating a hovered square, centered on [0,0].\n * The outline wraps exactly around the full square tile. The color is taken from the current theme.\n * @returns The vertex data for the box outline.\n */\nfunction getDataBoxOutline(): number[] {\n\tconst [r, g, b, a] = preferences.getBoxOutlineColor();\n\n\tconst squareCenter = board.getSquareCenterAsNumber();\n\tconst centerX = 0.5 - squareCenter;\n\tconst centerY = 0.5 - squareCenter;\n\n\tconst halfBox = 0.5;\n\tconst outerLeft = centerX - halfBox;\n\tconst outerRight = centerX + halfBox;\n\tconst outerTop = centerY + halfBox;\n\tconst outerBottom = centerY - halfBox;\n\n\tconst edgeWidth = BOX_OUTLINE.EDGE_WIDTH;\n\tconst innerLeft = outerLeft + edgeWidth;\n\tconst innerRight = outerRight - edgeWidth;\n\tconst innerTop = outerTop - edgeWidth;\n\tconst innerBottom = outerBottom + edgeWidth;\n\n\tconst vertices: number[] = [];\n\n\t// Helper to add a rectangle (two triangles)\n\t// prettier-ignore\n\tfunction addRectangle(x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number): void {\n\t\tvertices.push(\n\t\t\tx1, y1,   r, g, b, a, // Triangle 1, Vertex 1\n\t\t\tx2, y2,   r, g, b, a, // Triangle 1, Vertex 2\n\t\t\tx3, y3,   r, g, b, a, // Triangle 1, Vertex 3\n\t\t\tx3, y3,   r, g, b, a, // Triangle 2, Vertex 1\n\t\t\tx4, y4,   r, g, b, a, // Triangle 2, Vertex 2\n\t\t\tx1, y1,   r, g, b, a  // Triangle 2, Vertex 3\n\t\t);\n\t}\n\n\t// Top edge\n\taddRectangle(\n\t\touterLeft,\n\t\touterTop, // Outer top-left\n\t\touterRight,\n\t\touterTop, // Outer top-right\n\t\tinnerRight,\n\t\tinnerTop, // Inner top-right\n\t\tinnerLeft,\n\t\tinnerTop, // Inner top-left\n\t);\n\n\t// Bottom edge\n\taddRectangle(\n\t\touterLeft,\n\t\touterBottom, // Outer bottom-left\n\t\tinnerLeft,\n\t\tinnerBottom, // Inner bottom-left\n\t\tinnerRight,\n\t\tinnerBottom, // Inner bottom-right\n\t\touterRight,\n\t\touterBottom, // Outer bottom-right\n\t);\n\n\t// Left edge\n\taddRectangle(\n\t\touterLeft,\n\t\touterTop, // Outer top-left\n\t\tinnerLeft,\n\t\tinnerTop, // Inner top-left\n\t\tinnerLeft,\n\t\tinnerBottom, // Inner bottom-left\n\t\touterLeft,\n\t\touterBottom, // Outer bottom-left\n\t);\n\n\t// Right edge\n\taddRectangle(\n\t\touterRight,\n\t\touterTop, // Outer top-right\n\t\touterRight,\n\t\touterBottom, // Outer bottom-right\n\t\tinnerRight,\n\t\tinnerBottom, // Inner bottom-right\n\t\tinnerRight,\n\t\tinnerTop, // Inner top-right\n\t);\n\n\treturn vertices;\n}\n\n/**\n * Generates the vertex data for a single square draw with a texture, centered on [0,0]\n * @param inverted - Whether to invert the position data. Should be true if we're viewing black's perspective.\n */\nfunction getDataTexture(inverted: boolean): number[] {\n\tlet { left, right, bottom, top } = meshes.getCoordBoxModel([0, 0]);\n\tif (inverted) {\n\t\t[left, right] = [right, left]; // Swap left and right\n\t\t[bottom, top] = [top, bottom]; // Swap bottom and top\n\t}\n\treturn primitives.Quad_Texture(left, bottom, right, top, 0, 0, 1, 1);\n}\n\n/**\n * Generates the vertex data for a single square draw with a colored texture, centered on [0,0]\n * @param inverted - Whether to invert the position data. Should be true if we're viewing black's perspective.\n */\nfunction getDataColoredTexture(color: Color, inverted: boolean): number[] {\n\tlet left = -0.5;\n\tlet right = 0.5;\n\tlet bottom = -0.5;\n\tlet top = 0.5;\n\tif (inverted) {\n\t\t[left, right] = [right, left]; // Swap left and right\n\t\t[bottom, top] = [top, bottom]; // Swap bottom and top\n\t}\n\treturn primitives.Quad_ColorTexture(left, bottom, right, top, 0, 0, 1, 1, ...color);\n}\n\nexport default {\n\tgetDataLegalMoveSquare,\n\tgetDataLegalMoveDot,\n\tgetDataLegalMoveCornerTris,\n\tgetDataPlusSign,\n\tgetDataBoxOutline,\n\tgetDataTexture,\n\tgetDataColoredTexture,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/meshes.ts",
    "content": "// src/client/scripts/esm/game/rendering/meshes.ts\n\n/**\n * This script can generate mesh vertex data for common shapes,\n * given game info such as coordinates, color, and textures.\n *\n * [Model Space] - REQUIRES position and scale transformations when rendering.\n * [World Space] - DOES NOT require positional or scale transformations when rendering.\n */\n\nimport type { Color } from '../../../../../shared/util/math/math.js';\nimport type {\n\tBoundingBox,\n\tBoundingBoxBD,\n\tDoubleBoundingBox,\n} from '../../../../../shared/util/math/bounds.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport bounds from '../../../../../shared/util/math/bounds.js';\nimport bdcoords from '../../../../../shared/chess/util/bdcoords.js';\nimport { Vec3 } from '../../../../../shared/util/math/vectors.js';\nimport coordutil, {\n\tBDCoords,\n\tCoords,\n\tDoubleCoords,\n} from '../../../../../shared/chess/util/coordutil.js';\n\nimport boardpos from './boardpos.js';\nimport boardtiles from './boardtiles.js';\nimport primitives from './primitives.js';\nimport perspective from './perspective.js';\n\n// Constants -------------------------------------------------------------------------\n\nconst ONE = bd.fromBigInt(1n);\n\n// Square Bounds ---------------------------------------------------------------------------\n\n/**\n * [Model Space] Returns a bounding box of a square.\n * @param coords - Must be within double bounds because it should only be for model vertice data.\n */\nfunction getCoordBoxModel(coords: DoubleCoords): DoubleBoundingBox {\n\tconst squareCenter = boardtiles.getSquareCenterAsNumber();\n\tconst left = coords[0] - squareCenter;\n\tconst bottom = coords[1] - squareCenter;\n\tconst right = left + 1;\n\tconst top = bottom + 1;\n\treturn { left, right, bottom, top };\n}\n\n/**\n * [World Space] Returns a bounding box of a square.\n */\nfunction getCoordBoxWorld(coords: Coords): DoubleBoundingBox {\n\tconst boardPos = boardpos.getBoardPos();\n\tconst boardScale = boardpos.getBoardScaleAsNumber();\n\n\tconst squareCenterScaled = boardtiles.getSquareCenterAsNumber() * boardScale;\n\n\tconst coordsBD = bdcoords.FromCoords(coords);\n\n\tconst relativeCoords: DoubleCoords = bdcoords.coordsToDoubles(\n\t\tcoordutil.subtractBDCoords(coordsBD, boardPos),\n\t);\n\n\tconst scaledCoords: DoubleCoords = [\n\t\trelativeCoords[0] * boardScale,\n\t\trelativeCoords[1] * boardScale,\n\t];\n\n\tconst left = scaledCoords[0] - squareCenterScaled;\n\tconst right = left + boardScale;\n\tconst bottom = scaledCoords[1] - squareCenterScaled;\n\tconst top = bottom + boardScale;\n\n\treturn { left, right, bottom, top };\n}\n\n/**\n * [Model Space] If you have say a bounding box from coordinate [1,1] to [9,9],\n * this will round that outwards from [0.5,0.5] to [9.5,9.5].\n *\n * Expands the edges of the box, which should contain integer squares for values,\n * to encapsulate the whole of the squares on their edges.\n * Turns it into a floating point edge.\n */\nfunction expandTileBoundingBoxToEncompassWholeSquare(boundingBox: BoundingBox): BoundingBoxBD {\n\tconst boxBD = bounds.castBoundingBoxToBigDecimal(boundingBox);\n\treturn expandTileBoundingBoxToEncompassWholeSquareBD(boxBD);\n}\n\n/**\n * {@link expandTileBoundingBoxToEncompassWholeSquare}, but use this if you already have a BigDecimal bounding box.\n */\nfunction expandTileBoundingBoxToEncompassWholeSquareBD(boundingBox: BoundingBoxBD): BoundingBoxBD {\n\tconst squareCenter = boardtiles.getSquareCenter();\n\tconst inverseSquareCenter = bd.subtract(ONE, squareCenter);\n\n\tconst left = bd.subtract(boundingBox.left, squareCenter);\n\tconst right = bd.add(boundingBox.right, inverseSquareCenter);\n\tconst bottom = bd.subtract(boundingBox.bottom, squareCenter);\n\tconst top = bd.add(boundingBox.top, inverseSquareCenter);\n\n\treturn { left, bottom, right, top };\n}\n\n/**\n * [World Space] Applies our board position and scale transformations to a floating bounding box\n * so it can be rendered exactly where it is without requiring uniform translations.\n *\n * Since its floating, we don't bother to subtract squareCenter.\n */\nfunction applyWorldTransformationsToBoundingBox(boundingBox: BoundingBoxBD): DoubleBoundingBox {\n\tconst boardPos = boardpos.getBoardPos();\n\tconst boardScale = boardpos.getBoardScaleAsNumber();\n\n\tconst left: number = bd.toNumber(bd.subtract(boundingBox.left, boardPos[0])) * boardScale;\n\tconst right: number = bd.toNumber(bd.subtract(boundingBox.right, boardPos[0])) * boardScale;\n\tconst bottom: number = bd.toNumber(bd.subtract(boundingBox.bottom, boardPos[1])) * boardScale;\n\tconst top: number = bd.toNumber(bd.subtract(boundingBox.top, boardPos[1])) * boardScale;\n\n\treturn { left, bottom, right, top };\n}\n\n// Mesh Data ---------------------------------------------------------------------------------\n\n/**\n * [Model Space] Generates the vertex data of a square highlight, given the coords and color.\n */\nfunction QuadModel_Color(coords: DoubleCoords, color: Color): number[] {\n\tconst { left, bottom, right, top } = getCoordBoxModel(coords);\n\treturn primitives.Quad_Color(left, bottom, right, top, color);\n}\n\n/**\n * [World Space] Generates the vertex data of a square highlight, given the coords and color.\n */\nfunction QuadWorld_Color(coords: Coords, color: Color): number[] {\n\tconst { left, bottom, right, top } = getCoordBoxWorld(coords);\n\treturn primitives.Quad_Color(left, bottom, right, top, color);\n}\n\n/**\n * [World Space] Generates the vertex data of a colored texture.\n */\nfunction QuadWorld_ColorTexture(coords: Coords, color: Color): number[] {\n\tconst { texleft, texbottom, texright, textop } = getPieceTexCoords();\n\tconst { left, right, bottom, top } = getCoordBoxWorld(coords);\n\tconst [r, g, b, a] = color;\n\n\t// prettier-ignore\n\treturn primitives.Quad_ColorTexture(left, bottom, right, top, texleft, texbottom, texright, textop, r, g, b, a);\n}\n\n/**\n * Returns the texture coordinates for a full-texture piece quad (UV range 0–1),\n * flipped when viewing from black's perspective.\n */\nfunction getPieceTexCoords(): {\n\ttexleft: number;\n\ttexbottom: number;\n\ttexright: number;\n\ttextop: number;\n} {\n\tconst isBlack = perspective.getIsViewingBlackPerspective();\n\treturn {\n\t\ttexleft: isBlack ? 1 : 0,\n\t\ttexbottom: isBlack ? 1 : 0,\n\t\ttexright: isBlack ? 0 : 1,\n\t\ttextop: isBlack ? 0 : 1,\n\t};\n}\n\n/**\n * [World Space, LINE_LOOP] Generates the vertex data of a rectangle outline.\n */\nfunction RectWorld(boundingBox: BoundingBox, color: Color): number[] {\n\tconst boundingBoxBD = expandTileBoundingBoxToEncompassWholeSquare(boundingBox);\n\tconst { left, right, bottom, top } = applyWorldTransformationsToBoundingBox(boundingBoxBD);\n\treturn primitives.Rect(left, bottom, right, top, color);\n}\n\n// /**\n//  * [World Space, TRIANGLES] Generates the vertex data of a filled rectangle.\n//  */\n// function RectWorld_Filled(boundingBox: BoundingBox, color: Color): number[] {\n// \tconst boundingBoxBD = expandTileBoundingBoxToEncompassWholeSquare(boundingBox);\n// \tconst { left, right, bottom, top } = applyWorldTransformationsToBoundingBox(boundingBoxBD);\n// \treturn primitives.Quad_Color(left, bottom, right, top, color);\n// }\n\n// Transforming Vertices ---------------------------------------------------------------\n\n/** Applies a rotational & translational transformation to an array of points. */\n// function applyTransformToPoints(points: DoubleCoords[], rotation: number, translation: DoubleCoords): DoubleCoords[] {\n// \t// convert rotation angle to radians\n// \tconst cos = Math.cos(rotation);\n// \tconst sin = Math.sin(rotation);\n\n// \t// apply rotation matrix and translation vector to each point\n// \tconst transformedPoints = points.map(point => {\n// \t\tconst xRot = point[0] * cos - point[1] * sin;\n// \t\tconst yRot = point[0] * sin + point[1] * cos;\n// \t\tconst xTrans = xRot + translation[0];\n// \t\tconst yTrans = yRot + translation[1];\n// \t\treturn [xTrans, yTrans] as DoubleCoords;\n// \t});\n\n// \t// return transformed points as an array of length-2 arrays\n// \treturn transformedPoints;\n// }\n\n// Other Generic Rendering Methods -------------------------------------------------------\n\n/** Returns the position and uniform scale needed to render a board-space model. */\nfunction getBoardRenderTransform(offset: Coords, z: number = 0): { position: Vec3; scale: Vec3 } {\n\tconst boardPos = boardpos.getBoardPos();\n\tconst position = getModelPosition(boardPos, offset, z);\n\tconst boardScale = boardpos.getBoardScaleAsNumber();\n\tconst scale: Vec3 = [boardScale, boardScale, 1];\n\treturn { position, scale };\n}\n\n/**\n * Returns a model's transformed position that should be used when rendering its buffer model.\n *\n * Any model that has a bigint offset, should be able to subtract that offset\n * from our board position to obtain a number small emough for the gpu to render.\n *\n * Typically this will always include numbers smaller than 10,000\n */\nfunction getModelPosition(boardPos: BDCoords, modelOffset: Coords, z: number = 0): Vec3 {\n\tfunction getAxis(position: BigDecimal, offset: bigint): number {\n\t\tconst offsetBD = bd.fromBigInt(offset);\n\t\treturn bd.toNumber(bd.subtract(offsetBD, position));\n\t}\n\n\treturn [\n\t\t// offset - boardPos\n\t\tgetAxis(boardPos[0], modelOffset[0]),\n\t\tgetAxis(boardPos[1], modelOffset[1]),\n\t\tz,\n\t];\n}\n\n// Exports -----------------------------------------------------------------------\n\nexport default {\n\t// Square Bounds\n\tgetCoordBoxModel,\n\tgetCoordBoxWorld,\n\texpandTileBoundingBoxToEncompassWholeSquare,\n\texpandTileBoundingBoxToEncompassWholeSquareBD,\n\tapplyWorldTransformationsToBoundingBox,\n\t// Mesh Data\n\tQuadModel_Color,\n\tQuadWorld_Color,\n\tQuadWorld_ColorTexture,\n\tgetPieceTexCoords,\n\tRectWorld,\n\t// RectWorld_Filled,\n\t// Other Generic Rendering Methods\n\tgetBoardRenderTransform,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/miniimage.ts",
    "content": "// src/client/scripts/esm/game/rendering/miniimage.ts\n\n/**\n * This script handles the rendering of the mini images of our pieces when we're zoomed out\n */\n\nimport type {\n\tBDCoords,\n\tCoords,\n\tCoordsKey,\n\tDoubleCoords,\n} from '../../../../../shared/chess/util/coordutil.js';\n\nimport bd from '@naviary/bigdecimal';\n\nimport jsutil from '../../../../../shared/util/jsutil.js';\nimport vectors from '../../../../../shared/util/math/vectors.js';\nimport typeutil from '../../../../../shared/chess/util/typeutil.js';\nimport bdcoords from '../../../../../shared/chess/util/bdcoords.js';\nimport coordutil from '../../../../../shared/chess/util/coordutil.js';\nimport { Color } from '../../../../../shared/util/math/math.js';\nimport boardutil, { Piece } from '../../../../../shared/chess/util/boardutil.js';\nimport { players as p, TypeGroup } from '../../../../../shared/chess/util/typeutil.js';\n\nimport toast from '../gui/toast.js';\nimport webgl from './webgl.js';\nimport space from '../misc/space.js';\nimport mouse from '../../util/mouse.js';\nimport gameslot from '../chess/gameslot.js';\nimport boardpos from './boardpos.js';\nimport snapping from './highlights/snapping.js';\nimport premoves from '../chess/premoves.js';\nimport animation from './animation.js';\nimport selection from '../chess/selection.js';\nimport boardtiles from './boardtiles.js';\nimport perspective from './perspective.js';\nimport { GameBus } from '../GameBus.js';\nimport frametracker from './frametracker.js';\nimport texturecache from '../../chess/rendering/texturecache.js';\nimport instancedshapes from './instancedshapes.js';\nimport {\n\tRenderableInstanced,\n\tAttributeInfoInstanced,\n\tcreateRenderable_Instanced_GivenInfo,\n} from '../../webgl/Renderable.js';\n\n// Variables --------------------------------------------------------------\n\n/**\n * The maximum numbers of pieces in a game before we disable mini image rendering\n * for all pieces that aren't underneath a square annotation, ray intersection, being animated, or selected, for performance.\n */\nconst pieceCountToDisableMiniImages = 40_000;\n\nconst MINI_IMAGE_OPACITY: number = 0.6;\n/** The maximum distance in virtual pixels an animated mini image can travel before teleporting mid-animation near the end of its destination, so it doesn't move too rapidly on-screen. */\nconst MAX_ANIM_DIST_VPIXELS = bd.fromBigInt(2300n);\n\n/** The attribute info for all mini image vertex & attribute data. */\nconst attribInfo: AttributeInfoInstanced = {\n\tvertexDataAttribInfo: [\n\t\t{ name: 'a_position', numComponents: 2 },\n\t\t{ name: 'a_texturecoord', numComponents: 2 },\n\t\t{ name: 'a_color', numComponents: 4 },\n\t],\n\tinstanceDataAttribInfo: [{ name: 'a_instanceposition', numComponents: 2 }],\n};\n\n/** True if we're disabled and not rendering mini images, such as when there's too many pieces. */\nlet disabled: boolean = false; // Disabled when there's too many pieces\n\n// Events ---------------------------------------------------------------------\n\nGameBus.addEventListener('game-unloaded', () => {\n\t// Re-enable them if the previous game turned them off due to too many pieces.\n\tenable();\n});\n\n// Toggling --------------------------------------------------------------\n\nfunction isDisabled(): boolean {\n\treturn disabled;\n}\n\nfunction enable(): void {\n\tdisabled = false;\n}\n\nfunction disable(): void {\n\tdisabled = true;\n}\n\nfunction toggle(): void {\n\tdisabled = !disabled;\n\tframetracker.onVisualChange();\n\n\tif (disabled) toast.show(translations.rendering.icon_rendering_off);\n\telse toast.show(translations.rendering.icon_rendering_on);\n}\n\n// Updating --------------------------------------------------------------------------\n\n/** Iterate over every renderable piece (static and animated) and invoke the callback with its board coords and type. */\nfunction forEachRenderablePiece(callback: (_coords: BDCoords, _type: number) => void): void {\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst pieces = gamefile.boardsim.pieces;\n\n\t// Animated pieces\n\tconst maxDistB4Teleport = bd.divideFloating(\n\t\tMAX_ANIM_DIST_VPIXELS,\n\t\tboardtiles.gtileWidth_Pixels(),\n\t);\n\t/** Pieces temporarily being hidden via transparent squares on their destination square. */\n\tconst activeHides: Set<CoordsKey> = new Set();\n\tfor (const a of animation.animations) {\n\t\tconst segmentInfo = animation.getCurrentSegment(a, maxDistB4Teleport);\n\t\tconst currentAnimationPosition = animation.getCurrentAnimationPosition(\n\t\t\ta.segments,\n\t\t\tsegmentInfo,\n\t\t);\n\t\tcallback(currentAnimationPosition, a.type);\n\t\tanimation.forEachActiveKeyframe(a.showKeyframes, segmentInfo.segmentNum, (pieces) =>\n\t\t\tpieces.forEach((p) => {\n\t\t\t\tconst pieceBDCoords = bdcoords.FromCoords(p.coords);\n\t\t\t\tcallback(pieceBDCoords, p.type);\n\t\t\t}),\n\t\t);\n\t\t// Construct the hidden pieces for below\n\t\tanimation.forEachActiveKeyframe(a.hideKeyframes, segmentInfo.segmentNum, (pieces) =>\n\t\t\tpieces.map(coordutil.getKeyFromCoords).forEach((c) => activeHides.add(c)),\n\t\t);\n\t}\n\n\t// Static pieces\n\tgamefile.boardsim.existingTypes.forEach((type: number) => {\n\t\tif (typeutil.SVGLESS_TYPES.has(typeutil.getRawType(type))) return; // Skip voids\n\n\t\tconst range = pieces.typeRanges.get(type)!;\n\t\t// Skip types with no pieces\n\t\tif (boardutil.getPieceCountOfTypeRange(range) === 0) return;\n\n\t\tboardutil.iteratePiecesInTypeRange(pieces, type, (idx) => {\n\t\t\tconst coords = boardutil.getCoordsFromIdx(pieces, idx);\n\t\t\tconst coordsKey = coordutil.getKeyFromCoords(coords);\n\t\t\tif (activeHides.has(coordsKey)) return; // Skip pieces that are being hidden due to animations\n\t\t\tconst coordsBD = bdcoords.FromCoords(coords);\n\t\t\tcallback(coordsBD, type);\n\t\t});\n\t});\n}\n\n/** Generates the instance data for the miniimages of the pieces this frame. */\nfunction getImageInstanceData(): {\n\tinstanceData: TypeGroup<number[]>;\n\tinstanceData_hovered: TypeGroup<number[]>;\n} {\n\tconst instanceData: TypeGroup<number[]> = {};\n\tconst instanceData_hovered: TypeGroup<number[]> = {};\n\n\tconst pointerWorlds = mouse.getAllPointerWorlds();\n\n\tconst boardsim = gameslot.getGamefile()!.boardsim;\n\n\tconst halfWorldWidth: number = snapping.getEntityWidthWorld() / 2;\n\tconst areWatchingMousePosition: boolean =\n\t\t!perspective.getEnabled() || perspective.isMouseLocked();\n\n\t// Prepare empty arrays by type\n\tboardsim.existingTypes.forEach((type: number) => {\n\t\tif (typeutil.SVGLESS_TYPES.has(typeutil.getRawType(type))) return; // Skip voids\n\n\t\tinstanceData[type] = [];\n\t\tinstanceData_hovered[type] = [];\n\t});\n\n\tif (!disabled) {\n\t\t// Enabled => normal behavior\n\t\tforEachRenderablePiece(processPiece); // Process each renderable piece\n\t} else {\n\t\t// Disabled (too many pieces) => Only process pieces on highlights or being animated\n\t\tconst piecesToRender = getAllPiecesBelowAnnotePoints();\n\t\tpiecesToRender.forEach((p) => {\n\t\t\tconst coordsBD = bdcoords.FromCoords(p.coords);\n\t\t\tprocessPiece(coordsBD, p.type);\n\t\t}); // Calculate their instance data\n\t}\n\n\t/** Calculates and appends the instance data of the piece */\n\tfunction processPiece(coords: BDCoords, type: number): void {\n\t\tconst coordsWorld = space.convertCoordToWorldSpace(coords);\n\t\tinstanceData[type]!.push(...coordsWorld);\n\n\t\t// Are we hovering over? If so, add the same data to instanceData_hovered\n\t\tif (areWatchingMousePosition) {\n\t\t\tfor (const pointerWorld of pointerWorlds) {\n\t\t\t\tif (vectors.chebyshevDistanceDoubles(coordsWorld, pointerWorld) < halfWorldWidth)\n\t\t\t\t\tinstanceData_hovered[type]!.push(...coordsWorld);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn { instanceData, instanceData_hovered };\n}\n\n/** Returns a list of mini image coordinates that are all being hovered over by the provided world coords. */\nfunction getImagesBelowWorld(\n\tworld: DoubleCoords,\n\ttrackDists: boolean,\n): { images: Coords[]; dists?: number[] } {\n\tconst imagesHovered: Coords[] = [];\n\tconst dists: number[] = [];\n\n\tconst halfWorldWidth: number = snapping.getEntityWidthWorld() / 2;\n\n\tif (!disabled) {\n\t\t// Enabled => normal behavior\n\t\t// Check static and animated pieces for hover\n\t\tforEachRenderablePiece(processPiece);\n\t} else {\n\t\t// Disabled (too many pieces) => Only process pieces on highlights or being animated\n\t\tconst piecesToConsider = getAllPiecesBelowAnnotePoints();\n\t\tpiecesToConsider.forEach((p) => {\n\t\t\tconst coordsBD = bdcoords.FromCoords(p.coords);\n\t\t\tprocessPiece(coordsBD);\n\t\t}); // Calculate if their underneath the world coords\n\t}\n\n\tfunction processPiece(coords: BDCoords): void {\n\t\tconst coordsWorld = space.convertCoordToWorldSpace(coords);\n\t\tif (vectors.chebyshevDistanceDoubles(coordsWorld, world) < halfWorldWidth) {\n\t\t\tconst integerCoords = bdcoords.coordsToBigInt(coords);\n\t\t\timagesHovered.push(integerCoords);\n\t\t\t// Upgrade the distance to euclidean\n\t\t\tif (trackDists) dists.push(vectors.euclideanDistanceDoubles(coordsWorld, world));\n\t\t}\n\t}\n\n\treturn trackDists ? { images: imagesHovered, dists } : { images: imagesHovered };\n}\n\n/**\n * Returns a list of all pieces that should be rendered when mini-images are disabled.\n * This includes pieces below an annotation snap point, the selected piece, all animated pieces,\n * and the pieces involved in the last and next moves.\n */\nfunction getAllPiecesBelowAnnotePoints(): Piece[] {\n\t/** Running list of all pieces to render. */\n\tconst piecesToRender: Piece[] = [];\n\n\tfunction pushPieceNoDuplicatesOrVoids(piece: Piece): void {\n\t\tif (typeutil.SVGLESS_TYPES.has(typeutil.getRawType(piece.type))) return; // Skip voids\n\t\tif (!piecesToRender.some((p) => coordutil.areCoordsEqual(p.coords, piece.coords))) {\n\t\t\tpiecesToRender.push(piece);\n\t\t}\n\t}\n\n\tconst gamefile = gameslot.getGamefile()!;\n\tconst boardsim = gamefile.boardsim;\n\tconst pieces = boardsim.pieces;\n\tconst mesh = gameslot.getMesh();\n\n\t// 1. Process all animations and add pieces relevant to the current move\n\tconst maxDistB4Teleport = bd.divideFloating(\n\t\tMAX_ANIM_DIST_VPIXELS,\n\t\tboardtiles.gtileWidth_Pixels(),\n\t);\n\t/** Pieces temporarily being hidden via transparent squares on their destination square. */\n\tconst activeHides: Set<CoordsKey> = new Set();\n\tfor (const a of animation.animations) {\n\t\tconst segmentInfo = animation.getCurrentSegment(a, maxDistB4Teleport);\n\t\tconst currentAnimationPosition = animation.getCurrentAnimationPosition(\n\t\t\ta.segments,\n\t\t\tsegmentInfo,\n\t\t);\n\t\t// Add the main animated piece\n\t\tpushPieceNoDuplicatesOrVoids({\n\t\t\tcoords: bdcoords.coordsToBigInt(currentAnimationPosition),\n\t\t\ttype: a.type,\n\t\t\tindex: -1,\n\t\t});\n\t\t// Add the captured pieces being shown\n\t\tanimation.forEachActiveKeyframe(a.showKeyframes, segmentInfo.segmentNum, (pieces) =>\n\t\t\tpieces.forEach((p) => pushPieceNoDuplicatesOrVoids(p)),\n\t\t);\n\t\t// Construct the hidden pieces for below\n\t\tanimation.forEachActiveKeyframe(a.hideKeyframes, segmentInfo.segmentNum, (pieces) =>\n\t\t\tpieces.map(coordutil.getKeyFromCoords).forEach((c) => activeHides.add(c)),\n\t\t);\n\t}\n\n\t// Queued premoves must be rewound BEFORE reading the pieces, so they are in the expected locations as the last and next move!\n\tpremoves.rewindPremoves(gamefile, mesh);\n\n\t// 2. Get pieces on top of highlights (ray starts, intersections, etc.)\n\tconst annotePoints: Coords[] = snapping\n\t\t.getAnnoteSnapPoints(true)\n\t\t.map((p) => bdcoords.coordsToBigInt(p));\n\tannotePoints.forEach((ap) => {\n\t\tconst piece = boardutil.getPieceFromCoords(pieces, ap);\n\t\tif (!piece) return; // No piece beneath this highlight\n\t\tconst coordsKey = coordutil.getKeyFromCoords(ap);\n\t\tif (activeHides.has(coordsKey)) return; // Skip pieces that are being hidden due to animations\n\t\tpushPieceNoDuplicatesOrVoids(piece);\n\t});\n\n\t// 3. Add the selected piece, if any\n\tconst pieceSelected = selection.getPieceSelected();\n\tif (pieceSelected) pushPieceNoDuplicatesOrVoids(jsutil.deepCopyObject(pieceSelected));\n\n\t// 4. Add pieces from the last and next moves\n\tconst moveIndex = boardsim.state.local.moveIndex;\n\t// Last move's destination piece\n\tconst lastMove = boardsim.moves[moveIndex];\n\tif (\n\t\tlastMove &&\n\t\t!animation.animations.some((a) =>\n\t\t\tcoordutil.areCoordsEqual(lastMove.endCoords, a.path[a.path.length - 1]!),\n\t\t)\n\t) {\n\t\t// SKIP PIECES that are currently being animated to this location!!! Those are already rendered.\n\t\tconst lastMovedPiece = boardutil.getPieceFromCoords(pieces, lastMove.endCoords)!;\n\t\tif (!lastMovedPiece)\n\t\t\tthrow new Error(\n\t\t\t\t'Could not find last moved piece at its destination coords: ' + lastMove.endCoords,\n\t\t\t);\n\t\tpushPieceNoDuplicatesOrVoids(lastMovedPiece);\n\t}\n\t// Next move's starting piece\n\tconst nextMove = boardsim.moves[moveIndex + 1];\n\tif (\n\t\tnextMove &&\n\t\t!animation.animations.some((a) =>\n\t\t\tcoordutil.areCoordsEqual(nextMove.startCoords, a.path[a.path.length - 1]!),\n\t\t)\n\t) {\n\t\t// SKIP PIECES that are currently being animated to this location!!! Those are already rendered.\n\t\tconst nextToMovePiece = boardutil.getPieceFromCoords(pieces, nextMove.startCoords)!;\n\t\tif (!nextToMovePiece)\n\t\t\tthrow new Error(\n\t\t\t\t'Could not find next to move piece at its starting coords: ' + nextMove.startCoords,\n\t\t\t);\n\t\tpushPieceNoDuplicatesOrVoids(nextToMovePiece);\n\t}\n\n\tpremoves.applyPremoves(gamefile, mesh);\n\n\treturn piecesToRender;\n}\n\n// Rendering ---------------------------------------------------------------\n\nfunction render(): void {\n\tif (!boardpos.areZoomedOut()) return;\n\n\tconst boardsim = gameslot.getGamefile()!.boardsim;\n\tconst inverted = perspective.getIsViewingBlackPerspective();\n\n\tconst { instanceData, instanceData_hovered } = getImageInstanceData();\n\n\tconst models: TypeGroup<RenderableInstanced> = {};\n\tconst models_hovered: TypeGroup<RenderableInstanced> = {};\n\n\t// Create the models\n\tfor (const [typeStr, thisInstanceData] of Object.entries(instanceData)) {\n\t\tif (thisInstanceData.length === 0) continue; // No pieces of this type visible\n\n\t\tconst color = [1, 1, 1, MINI_IMAGE_OPACITY] as Color;\n\t\tconst vertexData: number[] = instancedshapes.getDataColoredTexture(color, inverted);\n\n\t\tconst type = Number(typeStr);\n\t\tconst texture: WebGLTexture = texturecache.getTexture(type);\n\t\tmodels[type] = createRenderable_Instanced_GivenInfo(\n\t\t\tvertexData,\n\t\t\tnew Float32Array(thisInstanceData),\n\t\t\tattribInfo,\n\t\t\t'TRIANGLES',\n\t\t\t'miniImages',\n\t\t\t[{ texture, uniformName: 'u_sampler' }],\n\t\t);\n\t\t// Create the hovered model if it's non empty\n\t\tif (instanceData_hovered[type]!.length > 0) {\n\t\t\tconst color_hovered = [1, 1, 1, 1] as Color; // Hovered mini images are fully opaque\n\t\t\tconst vertexData_hovered: number[] = instancedshapes.getDataColoredTexture(\n\t\t\t\tcolor_hovered,\n\t\t\t\tinverted,\n\t\t\t);\n\t\t\tmodels_hovered[type] = createRenderable_Instanced_GivenInfo(\n\t\t\t\tvertexData_hovered,\n\t\t\t\tnew Float32Array(instanceData_hovered[type]!),\n\t\t\t\tattribInfo,\n\t\t\t\t'TRIANGLES',\n\t\t\t\t'miniImages',\n\t\t\t\t[{ texture, uniformName: 'u_sampler' }],\n\t\t\t);\n\t\t}\n\t}\n\n\t// Sort the types in descending order, so that lower player number pieces are rendered on top, and kings are rendered on top.\n\tconst sortedNeutrals = boardsim.existingTypes\n\t\t.filter((t: number) => typeutil.getColorFromType(t) === p.NEUTRAL)\n\t\t.sort((a: number, b: number) => b - a);\n\tconst sortedColors = boardsim.existingTypes\n\t\t.filter((t: number) => typeutil.getColorFromType(t) !== p.NEUTRAL)\n\t\t.sort((a: number, b: number) => b - a);\n\n\tconst u_size = snapping.getEntityWidthWorld();\n\n\twebgl.executeWithDepthFunc_ALWAYS(() => {\n\t\tfor (const neut of sortedNeutrals) {\n\t\t\tmodels[neut]?.render(undefined, undefined, { u_size });\n\t\t\tmodels_hovered[neut]?.render(undefined, undefined, { u_size });\n\t\t}\n\t\tfor (const col of sortedColors) {\n\t\t\tmodels[col]?.render(undefined, undefined, { u_size });\n\t\t\tmodels_hovered[col]?.render(undefined, undefined, { u_size });\n\t\t}\n\t});\n}\n\n// Exports ---------------------------------------------------------------------------------\n\nexport default {\n\tpieceCountToDisableMiniImages,\n\n\tisDisabled,\n\tdisable,\n\ttoggle,\n\n\tgetImagesBelowWorld,\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/perspective.ts",
    "content": "// src/client/scripts/esm/game/rendering/perspective.ts\n\n/**\n * This script handles our perspective mode!\n * Also rendering our crosshair\n */\n\nimport type { Color } from '../../../../../shared/util/math/math.js';\n\nimport mat4 from './gl-matrix.js';\nimport toast from '../gui/toast.js';\nimport webgl from './webgl.js';\nimport config from '../config.js';\nimport docutil from '../../util/docutil.js';\nimport guipause from '../gui/guipause.js';\nimport gameslot from '../chess/gameslot.js';\nimport selection from '../chess/selection.js';\nimport { Mouse } from '../input.js';\nimport preferences from '../../components/header/preferences.js';\nimport frametracker from './frametracker.js';\nimport camera, { Mat4 } from './camera.js';\nimport { Renderable, createRenderable } from '../../webgl/Renderable.js';\nimport { listener_document, listener_overlay } from '../chess/game.js';\n\n/** Whether perspective mode is enabled. */\nlet enabled = false;\n\nlet rotX = 0; // Positive x looks down. Min 0\nlet rotZ = 0; // Positive z looks right\n// rotY = 0, // Y is tilt, we will not be using this\n\nlet isViewingBlackPerspective = false;\n\nconst mouseSensitivityMultiplier = 0.13; // 0.13 Default   This is Multiplied by our perspective_sensitivity in the preferences.\n\n// How far to render the board into the distance\nconst distToRenderBoard = 1500; // Default 1500. When changing this, also change  camera.getZFar()\n\n// Crosshair\nconst crosshairThickness = 2.5; // Default: 2.5\nconst crosshairColor: Color = [1, 1, 1, 1]; // RGBA. It will invert the colors in the buffer. This is what color BLACKS will be dyed! Whites will appear black.\n/** The buffer model of the mouse crosshair when in perspective mode. */\nlet crosshairModel: Renderable;\n\n// Getters\nfunction getEnabled(): boolean {\n\treturn enabled;\n}\nfunction getRotX(): number {\n\treturn rotX;\n}\nfunction getRotZ(): number {\n\treturn rotZ;\n}\nfunction getIsViewingBlackPerspective(): boolean {\n\treturn isViewingBlackPerspective;\n}\n\nfunction toggle(): void {\n\tif (!docutil.isMouseSupported())\n\t\treturn toast.show(translations.rendering.perspective_mode_on_desktop);\n\n\tif (!enabled) enable();\n\telse disable();\n}\n\nfunction enable(): void {\n\tif (enabled)\n\t\treturn console.error('Should not be enabling perspective when it is already enabled.');\n\tenabled = true;\n\n\tguipause.getelement_perspective().textContent = `${translations.rendering.perspective}: ${translations.rendering.on}`;\n\n\tguipause.callback_Resume();\n\n\tlockMouse();\n\n\tinitCrosshairModel();\n\n\ttoast.show(translations.rendering.movement_tutorial);\n}\n\nfunction disable(): void {\n\tif (!enabled) return;\n\tframetracker.onVisualChange();\n\n\tenabled = false;\n\t// document.exitPointerLock()\n\tguipause.callback_Resume();\n\n\tguipause.getelement_perspective().textContent = `${translations.rendering.perspective}: ${translations.rendering.off}`;\n\n\tconst viewWhitePerspective = gameslot.areInGame()\n\t\t? gameslot.isLoadedGameViewingWhitePerspective()\n\t\t: true;\n\tresetRotations(viewWhitePerspective);\n}\n\n// Sets rotations to orthographic view. Sensitive to if we're white or black.\nfunction resetRotations(viewWhitePerspective = true): void {\n\trotX = 0;\n\trotZ = viewWhitePerspective ? 0 : 180;\n\n\tupdateIsViewingBlackPerspective();\n\n\tcamera.onPositionChange();\n}\n\n// Called when the mouse re-clicks the screen after ALREADY in perspective.\nfunction relockMouse(): void {\n\tif (!enabled) return;\n\tif (isMouseLocked()) return;\n\tif (guipause.areWePaused()) return;\n\tif (selection.getSquarePawnIsCurrentlyPromotingOn()) return;\n\n\tlockMouse();\n}\n\nfunction lockMouse(): void {\n\tcamera.canvas.requestPointerLock();\n\t// Disables OS-level mouse acceleration. This does NOT solve safari being more sensitive.\n\t// camera.canvas.requestPointerLock({ unadjustedMovement: true });\n}\n\nfunction update(): void {\n\tif (!enabled) return;\n\t// If they pushed escape, the mouse will no longer be locked\n\t// If the mouse is unlocked, don't rotate view.\n\tif (!isMouseLocked()) {\n\t\t// Check if needs to relock\n\t\tif (listener_overlay.isMouseClicked(Mouse.LEFT)) {\n\t\t\tlistener_overlay.claimMouseClick(Mouse.LEFT);\n\t\t\trelockMouse();\n\t\t} else if (listener_overlay.isMouseDown(Mouse.LEFT))\n\t\t\tlistener_overlay.claimMouseDown(Mouse.LEFT); // Prevents piece drag start from claiming this mouse down.\n\t\treturn;\n\t}\n\n\tconst mouseChange = listener_document.getPhysicalPointerDelta('mouse');\n\tif (!mouseChange) throw Error('Mouse pointer not present!');\n\n\tconst thisSensitivity =\n\t\tmouseSensitivityMultiplier * (preferences.getPerspectiveSensitivity() / 100); // Divide by 100 to bring it to the range 0.25-2\n\n\t// Change rotations based on mouse motion\n\trotX += mouseChange[1] * thisSensitivity;\n\trotZ += mouseChange[0] * thisSensitivity;\n\tcapRotations();\n\tupdateIsViewingBlackPerspective();\n\n\tcamera.onPositionChange(); // Calculate new viewMatrix\n}\n\n// Applies perspective rotation to default camera viewMatrix\nfunction applyRotations(viewMatrix: Mat4): void {\n\tif (haveZeroRotation()) return; // No perspective rotation\n\n\tconst cameraPos = camera.getPosition(); // devMode-sensitive\n\n\t// Shift the origin before rotating plane\n\tmat4.translate(viewMatrix, viewMatrix, cameraPos);\n\n\tif (rotX < 0) {\n\t\t// Looking up somewhat\n\t\tconst rotXRad = rotX * (Math.PI / 180);\n\t\tmat4.rotate(viewMatrix, viewMatrix, rotXRad, [1, 0, 0]);\n\t}\n\t// const rotYRad = rotY * (Math.PI / 180);\n\t// mat4.rotate(viewMatrix, viewMatrix, rotYRad, [0,1,0])\n\tconst rotZRad = rotZ * (Math.PI / 180);\n\tmat4.rotate(viewMatrix, viewMatrix, rotZRad, [0, 0, 1]);\n\n\t// Shift the origin back where it was\n\tconst negativeCameraPos = [-cameraPos[0], -cameraPos[1], -cameraPos[2]];\n\tmat4.translate(viewMatrix, viewMatrix, negativeCameraPos);\n}\n\n/** Returns true if we have no perspective rotation */\nfunction haveZeroRotation(): boolean {\n\treturn rotX === 0 && rotZ === 0;\n}\n\n/** Returns *true* if we're looking above the horizon. */\nfunction isLookingUp(): boolean {\n\treturn enabled && rotX <= -90;\n}\n\n// Makes sure we don't go upside-down\nfunction capRotations(): void {\n\tif (rotX > 0) rotX = 0;\n\telse if (rotX < -180) rotX = -180;\n\tif (rotZ < 0) rotZ += 360;\n\telse if (rotZ > 360) rotZ -= 360;\n}\n\nfunction isMouseLocked(): boolean {\n\treturn document.pointerLockElement === camera.canvas;\n}\n\n// Buffer model of crosshair. Called whenever perspective is enabled, screen is resized, or devMode is toggled.\nfunction initCrosshairModel(): void {\n\tif (!enabled) return;\n\n\tconst screenHeight = camera.getScreenHeightWorld();\n\n\tconst innerSide =\n\t\t((crosshairThickness / 2) * screenHeight) / camera.getCanvasHeightVirtualPixels();\n\n\tconst [r, g, b, a] = crosshairColor;\n\n\t// prettier-ignore\n\tconst data = new Float32Array([\n\t\t//       Vertex         Color\n            -innerSide, -innerSide,       r, g, b, a,\n            -innerSide,  innerSide,       r, g, b, a,\n            innerSide,  innerSide,        r, g, b, a,\n\n            innerSide,  innerSide,        r, g, b, a,\n            innerSide,  -innerSide,       r, g, b, a,\n            -innerSide,  -innerSide,      r, g, b, a,\n\t]);\n\tcrosshairModel = createRenderable(data, 2, 'TRIANGLES', 'color', true);\n}\n\nfunction renderCrosshair(): void {\n\tif (!enabled) return;\n\tif (config.VIDEO_MODE) return; // Don't render while recording\n\n\trenderWithoutPerspectiveRotations(() => {\n\t\twebgl.executeWithInverseBlending(() => {\n\t\t\tcrosshairModel.render();\n\t\t});\n\t});\n}\n\n/**\n * Renders (performs) whatever function is passed to it,\n * as if our camera was looking straight at the board from\n * white's perspective. ZERO perspective rotations!\n * Works both in 3D perspective mode and in 2D black's-perspective mode.\n */\nfunction renderWithoutPerspectiveRotations(func: Function): void {\n\tif (haveZeroRotation()) return func();\n\n\tconst perspectiveViewMatrixCopy = camera.getViewMatrix();\n\tcamera.initViewMatrix(true); // Init view while ignoring perspective rotations\n\n\tfunc();\n\n\tcamera.setViewMatrix(perspectiveViewMatrixCopy); // Re-put back the perspective rotation\n}\n\n// Used when the promotion UI opens\nfunction unlockMouse(): void {\n\tif (!enabled) return;\n\tdocument.exitPointerLock();\n}\n\nfunction updateIsViewingBlackPerspective(): void {\n\tisViewingBlackPerspective = rotZ > 90 && rotZ < 270;\n}\n\n// Exports -----------------------------------------------------------------------\n\nexport default {\n\tgetEnabled,\n\tgetRotX,\n\tgetRotZ,\n\tdistToRenderBoard,\n\tgetIsViewingBlackPerspective,\n\ttoggle,\n\tdisable,\n\tresetRotations,\n\trelockMouse,\n\tupdate,\n\tapplyRotations,\n\tisMouseLocked,\n\trenderCrosshair,\n\trenderWithoutPerspectiveRotations,\n\tunlockMouse,\n\tisLookingUp,\n\tinitCrosshairModel,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/piecemodels.ts",
    "content": "// src/client/scripts/esm/game/rendering/piecemodels.ts\n\n/**\n * This generates and renders the meshes of each individual piece type in the game.\n */\n\nimport type { Piece } from '../../../../../shared/chess/util/boardutil.js';\nimport type { Board } from '../../../../../shared/chess/logic/gamefile.js';\nimport type { Coords } from '../../../../../shared/chess/util/coordutil.js';\nimport type { TypeGroup } from '../../../../../shared/chess/util/typeutil.js';\n\nimport vectors from '../../../../../shared/util/math/vectors.js';\nimport typeutil from '../../../../../shared/chess/util/typeutil.js';\nimport geometry from '../../../../../shared/util/math/geometry.js';\nimport bdcoords from '../../../../../shared/chess/util/bdcoords.js';\nimport coordutil from '../../../../../shared/chess/util/coordutil.js';\nimport boardutil from '../../../../../shared/chess/util/boardutil.js';\nimport { rawTypes as r } from '../../../../../shared/chess/util/typeutil.js';\n\nimport meshes from './meshes.js';\nimport { gl } from './webgl.js';\nimport boardpos from './boardpos.js';\nimport miniimage from './miniimage.js';\nimport perspective from './perspective.js';\nimport frametracker from './frametracker.js';\nimport texturecache from '../../chess/rendering/texturecache.js';\nimport instancedshapes from './instancedshapes.js';\nimport {\n\tAttributeInfoInstanced,\n\tRenderableInstanced,\n\tcreateRenderable_Instanced,\n\tcreateRenderable_Instanced_GivenInfo,\n} from '../../webgl/Renderable.js';\n\n// Types --------------------------------------------------------------------------------------------\n\n/**\n * Piece Mesh Instance Data.\n * HIGH RESOLUTION bigint values.\n * null === undefined placeholder\n */\ntype InstanceData = (bigint | null)[];\n\n/** Mesh data of a single piece type in mesh.types */\ninterface MeshData {\n\t/** Infinite precision BIGINT instance data for performing arithmetic. */\n\tinstanceData: InstanceData;\n\t/** Buffer model for rendering. (This automatically stores the instanceData32 array going into the gpu) */\n\tmodel: RenderableInstanced;\n}\n\n/** An object that contains the buffer models to render the pieces in a game. */\ninterface Mesh {\n\t/** The amount the mesh data has been linearly shifted to make it closer to the origin, in coordinates `[x,y]`.\n\t * This helps require less severe uniform translations upon rendering when traveling massive distances.\n\t * The amount it is shifted depends on the nearest `REGEN_RANGE`. */\n\toffset: Coords;\n\t/** Whether the position data of each piece mesh is inverted. This will be true if we're viewing black's perspective. */\n\tinverted: boolean;\n\t/** An object containing the mesh data for each type of piece in the game. One for every type in `pieces` */\n\ttypes: TypeGroup<MeshData>;\n}\n\n// Variables ----------------------------------------------------------------------------------------\n\n/**\n * A tiny z offset, to prevent the pieces from tearing with highlights while in perspective.\n *\n * We can't solve that problem by using blending mode ALWAYS because we need animations\n * to be able to mask (block out) the currently-animated piece by rendering a transparent square\n * on the animated piece's destination that is higher in the depth buffer.\n */\nconst Z: number = 0.005;\n\n/**\n * The interval at which to modify the mesh's linear offset once you travel this distance.\n * 10,000 was arbitrarily chosen because once you reach uniform translations much bigger\n * than that, the rendering of the pieces start to get somewhat gittery.\n */\nconst REGEN_RANGE = 10_000n;\n\n// /**\n//  * The distance of which panning will noticably distort the pieces mesh.\n//  * If we ever shift the piece models by more than this, we should regenerate them instead.\n//  */\n// const DISTANCE_AT_WHICH_MESH_GLITCHES = Number.MAX_SAFE_INTEGER; // ~9 Quadrillion\n\n/** The instance data array stride, per piece. */\nconst STRIDE_PER_PIECE = 2; // instanceposition: (x,y)\n\n/** The attribute info of each of the piece type models, excluding voids. */\nconst ATTRIBUTE_INFO: AttributeInfoInstanced = {\n\tvertexDataAttribInfo: [\n\t\t{ name: 'a_position', numComponents: 2 },\n\t\t{ name: 'a_texturecoord', numComponents: 2 },\n\t],\n\tinstanceDataAttribInfo: [{ name: 'a_instanceposition', numComponents: 2 }],\n};\n\n// Generating Meshes ------------------------------------------------------------------------\n\n/**\n * Regenerates every single piece mesh in the gamefile.\n * Call when first loading a game.\n *\n * SLOWEST. Minimize calling.\n */\nfunction regenAll(boardsim: Board, mesh: Mesh | undefined): void {\n\tif (!mesh) return;\n\tconsole.log('Regenerating all piece type meshes.');\n\n\t// Update the offset\n\tmesh.offset = geometry.roundPointToNearestGridpoint(boardpos.getBoardPos(), REGEN_RANGE);\n\t// Calculate whether the textures should be inverted or not, based on whether we're viewing black's perspective.\n\tmesh.inverted = perspective.getIsViewingBlackPerspective();\n\n\t// For each piece type in the game, generate its mesh\n\tfor (const type of boardsim.existingTypes) {\n\t\t// [43] pawn(white)\n\t\tif (typeutil.getRawType(type) === r.VOID)\n\t\t\tmesh.types[type] = genVoidModel(boardsim, mesh, type); // Custom mesh generation logic for voids\n\t\telse mesh.types[type] = genTypeModel(boardsim, mesh, type); // Normal generation logic for all pieces with a texture\n\t}\n\n\tframetracker.onVisualChange();\n\n\tdelete boardsim.pieces.newlyRegenerated; // Delete this flag now. It was to let us know the piece models needed to be regen'd.\n}\n\n/**\n * MIGHT BE UNUSED, SOON??\n *\n * Regenerates the single model of the provided type.\n * Call externally after adding more undefined placeholders to a type list.\n * @param boardsim\n * @param mesh\n * @param type - The type of piece to regen the model for (e.g. 'pawnsW')\n */\nfunction regenType(boardsim: Board, mesh: Mesh, type: number): void {\n\tconsole.log(`Regenerating mesh of type ${type}.`);\n\n\tif (typeutil.getRawType(type) === r.VOID)\n\t\tmesh.types[type] = genVoidModel(boardsim, mesh, type); // Custom mesh generation logic for voids\n\telse mesh.types[type] = genTypeModel(boardsim, mesh, type); // Normal generation logic for all pieces with a texture\n\n\tframetracker.onVisualChange();\n}\n\n/**\n * Generates the mesh data for a specific piece type in the gamefile that has a texture. (not compatible with voids)\n * Must be called whenever we add more undefineds placeholders to the this piece list.\n *\n * SLOWEST. Minimize calling.\n * @param boardsim\n * @param mesh\n * @param type - The type of piece of which to generate the model for (e.g. \"pawnsW\")\n */\nfunction genTypeModel(boardsim: Board, mesh: Mesh, type: number): MeshData {\n\tconst vertexData = instancedshapes.getDataTexture(mesh.inverted);\n\tconst instanceData: InstanceData = getInstanceDataForTypeRange(boardsim, mesh, type);\n\n\tconst texture = texturecache.getTexture(type);\n\treturn {\n\t\tinstanceData,\n\t\tmodel: createRenderable_Instanced_GivenInfo(\n\t\t\tvertexData,\n\t\t\tcastInstanceDataToFloat32(instanceData),\n\t\t\tATTRIBUTE_INFO,\n\t\t\t'TRIANGLES',\n\t\t\t'textureInstanced',\n\t\t\t[{ texture, uniformName: 'u_sampler' }],\n\t\t),\n\t};\n}\n\n/**\n * Generates the model of the voids in the game.\n * Must be called whenever we add more undefineds placeholders to the voids piece list.\n *\n * SLOWEST. Minimize calling.\n */\nfunction genVoidModel(boardsim: Board, mesh: Mesh, type: number): MeshData {\n\t// const voidColor = preferences.getTintColorOfType(type); // Black, from the pieceTheme\n\tconst voidColor = gl.getParameter(gl.COLOR_CLEAR_VALUE); // Same color as the sky / void space star field. DOESN'T EVEN MATTER SINCE IT'S A MASK!\n\tconst vertexData: number[] = instancedshapes.getDataLegalMoveSquare(voidColor);\n\tconst instanceData: InstanceData = getInstanceDataForTypeRange(boardsim, mesh, type);\n\n\treturn {\n\t\tinstanceData,\n\t\tmodel: createRenderable_Instanced(\n\t\t\tvertexData,\n\t\t\tcastInstanceDataToFloat32(instanceData),\n\t\t\t'TRIANGLES',\n\t\t\t'colorInstanced',\n\t\t\ttrue,\n\t\t),\n\t};\n}\n\n/**\n * Calculates the instance data of a piece list that will go into its mesh constructor.\n * The instance data contains only the offset of each piece instance, with a stride of 2.\n * Thus, this works will all types of pieces, even those without a texture, such as voids.\n */\nfunction getInstanceDataForTypeRange(boardsim: Board, mesh: Mesh, type: number): InstanceData {\n\t// const range = boardsim.pieces.typeRanges.get(type)!;\n\t// const instanceData64: Float64Array = new Float64Array((range.end - range.start) * STRIDE_PER_PIECE); // Initialize with all 0's\n\tconst instanceData: InstanceData = []; // Initialize empty\n\n\tlet currIndex: number = 0;\n\tboardutil.iteratePiecesInTypeRange_IncludeUndefineds(\n\t\tboardsim.pieces,\n\t\ttype,\n\t\t(idx: number, isUndefined: boolean) => {\n\t\t\tif (isUndefined) {\n\t\t\t\t// Undefined placeholder, this one should not be visible. If we leave it at 0, then there would be a visible void at [0,0]\n\t\t\t\tinstanceData[currIndex] = null;\n\t\t\t\tinstanceData[currIndex + 1] = null;\n\t\t\t} else {\n\t\t\t\t// NOT undefined\n\t\t\t\tconst coords = boardutil.getCoordsFromIdx(boardsim.pieces, idx);\n\t\t\t\t// Apply the piece mesh offset to the coordinates\n\t\t\t\tinstanceData[currIndex] = coords[0] - mesh.offset[0];\n\t\t\t\tinstanceData[currIndex + 1] = coords[1] - mesh.offset[1];\n\t\t\t}\n\t\t\tcurrIndex += STRIDE_PER_PIECE;\n\t\t},\n\t);\n\n\treturn instanceData;\n}\n\n/**\n * Converts a (bigint | null) array containing into a `Float32Array`.\n * Which should then be used to pass into a buffer model constructor.\n */\nfunction castInstanceDataToFloat32(instanceData: InstanceData): Float32Array {\n\t// Pre-allocate the Float32Array to the final size. Critical for performance.\n\tconst result: Float32Array = new Float32Array(instanceData.length);\n\n\t// Iterate through the source array once and place the converted value directly into the result array.\n\t// This single-pass approach is much faster than methods like .map(), which create a temporary intermediate array.\n\tfor (let i: number = 0; i < instanceData.length; i++) {\n\t\tconst value: bigint | null = instanceData[i]!;\n\n\t\tif (value === null) {\n\t\t\t// Convert null to NaN. When used as a vertex position, NaN values are typically\n\t\t\t// discarded by the GPU's rasterizer, effectively making the vertex invisible.\n\t\t\tresult[i] = NaN; // Alternative would be Infinity\n\t\t} else {\n\t\t\t// value === bigint\n\t\t\t// Convert the bigint to a number. The Float32Array will store it as a 32-bit float.\n\t\t\t// Naturally, precision loss occurs.\n\t\t\tresult[i] = Number(value);\n\t\t}\n\t}\n\n\treturn result;\n}\n\n/**\n * Converts a bigint instance data array into a `Float32Array`.\n * Which should then be used to pass into a buffer model constructor.\n */\nfunction castBigIntArrayToFloat32(instanceData: bigint[]): Float32Array {\n\t// Pre-allocate the Float32Array to the final size. This is critical for performance.\n\tconst result: Float32Array = new Float32Array(instanceData.length);\n\n\t// Iterate through the source array once and place the converted value directly into the result array.\n\t// This single-pass approach is much faster than methods like .map(), which create a temporary intermediate array.\n\tfor (let i: number = 0; i < instanceData.length; i++) {\n\t\t// Convert the bigint to a number. The Float32Array will store it as a 32-bit float.\n\t\t// Be aware of potential precision loss for very large BigInts.\n\t\tresult[i] = Number(instanceData[i]);\n\t}\n\n\treturn result;\n}\n\n// Shifting Meshes ------------------------------------------------------------------------\n\n/**\n * Shifts the instance data of each piece mesh in the game to require less severe\n * uniform translations upon rendering, and reinits them on the gpu.\n * Faster than {@link regenAll}.\n */\nfunction shiftAll(boardsim: Board, mesh: Mesh): void {\n\tconsole.log('Shifting all piece meshes.');\n\n\tconst newOffset = geometry.roundPointToNearestGridpoint(boardpos.getBoardPos(), REGEN_RANGE);\n\n\tconst diffXOffset = mesh.offset[0] - newOffset[0];\n\tconst diffYOffset = mesh.offset[1] - newOffset[1];\n\n\t// const chebyshevDistance = vectors.chebyshevDistance(mesh.offset, newOffset);\n\t// if (chebyshevDistance > DISTANCE_AT_WHICH_MESH_GLITCHES) {\n\t// \tconsole.log(`REGENERATING the piece models instead of shifting them. They were shifted by ${chebyshevDistance} tiles!`);\n\t// \tregenAll(boardsim, mesh);\n\t// \treturn;\n\t// }\n\n\tmesh.offset = newOffset;\n\n\t// Go ahead and shift each model\n\tfor (const meshData of Object.values(mesh.types)) {\n\t\tshiftModel(meshData, diffXOffset, diffYOffset);\n\t}\n}\n\n/**\n * Shifts the vertex data of the piece model and reinits it on the gpu.\n * Faster than {@link regenType} or {@link genTypeModel}.\n * @param meshData - An object containing the infinite resolution bigint instanceData, and the actual model.\n * @param diffXOffset - The x-amount to shift the model's vertex data.\n * @param diffYOffset - The y-amount to shift the model's vertex data.\n */\nfunction shiftModel(meshData: MeshData, diffXOffset: bigint, diffYOffset: bigint): void {\n\tconst instanceData = meshData.instanceData; // High precision floats for performing calculations\n\tconst instanceData32 = meshData.model.instanceData; // Low precision floats for sending to the gpu\n\tfor (let i = 0; i < instanceData32.length; i += STRIDE_PER_PIECE) {\n\t\tif (instanceData[i] === null) continue; // Skip undefined placeholders\n\n\t\tinstanceData[i]! += diffXOffset;\n\t\tinstanceData[i + 1]! += diffYOffset;\n\t\t// Copy the float32 values from the bigint array so as to retain the most precision\n\t\tinstanceData32[i]! = Number(instanceData[i]!);\n\t\tinstanceData32[i + 1]! = Number(instanceData[i + 1]!);\n\t}\n\n\t// Update the buffer on the gpu!\n\tmeshData.model.updateBufferIndices_InstanceBuffer(0, instanceData.length); // Update every index\n}\n\n// Rotating Models ------------------------------------------------------------------------------\n\n/**\n * Rotates each piece model (except voids) by updating its vertex data of\n * a single instance with the updated rotation, then reinits them on the gpu.\n *\n * FAST, as this only needs to modify the vertex data of a single instance per piece type.\n */\nfunction rotateAll(mesh: Mesh, newInverted: boolean): void {\n\t// console.log(\"Rotating position data of all type meshes!\");\n\n\tmesh.inverted = newInverted;\n\tconst newVertexData = instancedshapes.getDataTexture(mesh.inverted);\n\n\tfor (const [stringType, meshData] of Object.entries(mesh.types)) {\n\t\tconst rawType = typeutil.getRawType(Number(stringType));\n\t\tif (typeutil.SVGLESS_TYPES.has(rawType)) continue; // Skip voids and other non-textured pieces, currently they are symmetrical\n\t\t// Not a void, which means its guaranteed to be a piece with a texture...\n\t\tconst vertexData = meshData.model.vertexData;\n\t\tif (vertexData.length !== newVertexData.length)\n\t\t\tthrow Error(\n\t\t\t\t'New vertex data must be the same length as the existing! Cannot update buffer indices.',\n\t\t\t); // Safety net\n\t\tvertexData.set(newVertexData); // Copies the values over without changing the memory location\n\t\tmeshData.model.updateBufferIndices_VertexBuffer(0, vertexData.length); // Send those changes off to the gpu\n\t}\n}\n\n// Modifying Mesh Data --------------------------------------------------------------------------\n\n/**\n * Overwrites the instance data of the specified piece within its\n * piece type mesh with the new coordinates of the instance.\n * Then sends that change off to the gpu.\n *\n * FAST, much faster than regenerating the entire mesh\n * whenever a piece moves or something is captured/generated!\n */\nfunction overwritebufferdata(mesh: Mesh, piece: Piece): void {\n\tconst meshData = mesh.types[piece.type]!;\n\n\tconst i = piece.index * STRIDE_PER_PIECE;\n\n\tconst offsetCoord = coordutil.subtractCoords(piece.coords, mesh.offset);\n\n\tmeshData.instanceData[i] = offsetCoord[0];\n\tmeshData.instanceData[i + 1] = offsetCoord[1];\n\tmeshData.model.instanceData[i] = Number(offsetCoord[0]);\n\tmeshData.model.instanceData[i + 1] = Number(offsetCoord[1]);\n\n\t// Update the buffer on the gpu!\n\tmeshData.model.updateBufferIndices_InstanceBuffer(i, STRIDE_PER_PIECE); // Update only the indices the piece is at\n}\n\n/**\n * Deletes the instance data of the specified piece within its piece type mesh\n * by overwriting it with Infinity's, then sends that change off to the gpu.\n *\n * FAST, much faster than regenerating the entire mesh\n * whenever a piece moves or something is captured/generated!\n */\nfunction deletebufferdata(mesh: Mesh, piece: Piece): void {\n\tconst meshData = mesh.types[piece.type]!;\n\n\tconst i = piece.index * STRIDE_PER_PIECE;\n\n\t// Unfortunately we can't set them to 0 to hide it, as an actual piece instance would be visible at [0,0]\n\tmeshData.instanceData[i] = null;\n\tmeshData.instanceData[i + 1] = null;\n\tmeshData.model.instanceData[i] = NaN;\n\tmeshData.model.instanceData[i + 1] = NaN;\n\n\t// Update the buffer on the gpu!\n\tmeshData.model.updateBufferIndices_InstanceBuffer(i, STRIDE_PER_PIECE); // Update only the indices the piece was at\n}\n\n// Rendering ----------------------------------------------------------------------------------------\n\n/**\n * Renders ever piece type mesh of the game, EXCLUDING voids,\n * translating and scaling them into position.\n */\nfunction renderAll(boardsim: Board, mesh: Mesh | undefined): void {\n\tif (!mesh) return; // Mesh hasn't been generated yet\n\n\tconst { position, scale } = meshes.getBoardRenderTransform(mesh.offset, Z);\n\n\tif (boardpos.areZoomedOut() && !miniimage.isDisabled()) {\n\t\t// Only render voids\n\t\t// NOT ANYMORE SINCE ADDING STAR FIELD ANIMATION (voids are rendered separately)\n\t\t// mesh.types[r.VOID]?.model.render(position, scale);\n\t\treturn;\n\t}\n\n\t// We can render everything...\n\n\t// Do we need to shift the instance data of the piece models? Are we out of bounds of our REGEN_RANGE?\n\tif (!boardpos.areZoomedOut() && isOffsetOutOfRangeOfRegenRange(mesh.offset))\n\t\tshiftAll(boardsim, mesh);\n\n\t// Test if the rotation has changed\n\tconst correctInverted = perspective.getIsViewingBlackPerspective();\n\tif (mesh.inverted !== correctInverted) rotateAll(mesh, correctInverted);\n\n\tfor (const [typeStr, meshData] of Object.entries(mesh.types)) {\n\t\tconst type = Number(typeStr);\n\t\tif (type === r.VOID) continue; // Skip voids, they should be rendered separately\n\t\tmeshData.model.render(position, scale);\n\t}\n}\n\n/** Renders the voids mesh. */\nfunction renderVoids(mesh: Mesh | undefined): void {\n\tif (!mesh) return; // Mesh hasn't been generated yet\n\n\tconst { position, scale } = meshes.getBoardRenderTransform(mesh.offset, Z);\n\n\tmesh.types[r.VOID]?.model.render(position, scale);\n}\n\n/**\n * Tests if the board position is at least REGEN_RANGE-distance away from the current offset.\n * If so, each piece mesh data should be shifted to require less severe uniform translations when rendering.\n */\nfunction isOffsetOutOfRangeOfRegenRange(offset: Coords): boolean {\n\t// offset: [x,y]\n\tconst boardPosRounded: Coords = bdcoords.coordsToBigInt(boardpos.getBoardPos());\n\tconst chebyshevDist = vectors.chebyshevDistance(boardPosRounded, offset);\n\treturn chebyshevDist > REGEN_RANGE;\n}\n\n// Exports --------------------------------------------------------------------------------------------\n\nexport default {\n\tATTRIBUTE_INFO,\n\n\tregenAll,\n\tregenType,\n\tcastBigIntArrayToFloat32,\n\toverwritebufferdata,\n\tdeletebufferdata,\n\trenderAll,\n\trenderVoids,\n};\n\nexport type { Mesh };\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/pieces.ts",
    "content": "// src/client/scripts/esm/game/rendering/pieces.ts\n\n/**\n * This script renders all of our pieces on the board,\n * including voids, and mini images.\n */\n\nimport type { Mesh } from './piecemodels.js';\nimport type { Board } from '../../../../../shared/chess/logic/gamefile.js';\nimport type { Coords } from '../../../../../shared/chess/util/coordutil.js';\n\nimport meshes from './meshes.js';\nimport miniimage from './miniimage.js';\nimport piecemodels from './piecemodels.js';\nimport texturecache from '../../chess/rendering/texturecache.js';\nimport { createRenderable } from '../../webgl/Renderable.js';\n\n// Variables ---------------------------------------------------------------------\n\n/** Opacity of ghost piece over legal move highlights. Default: 0.4 */\nconst ghostOpacity: number = 0.4;\n\n// Functions -----------------------------------------------------------------------\n\n/**\n * Renders all of our pieces on the board,\n * including voids, and mini images, if visible.\n */\nfunction renderPiecesInGame(boardsim: Board, mesh: Mesh | undefined): void {\n\tpiecemodels.renderAll(boardsim, mesh);\n\tminiimage.render();\n}\n\n/** Renders a semi-transparent piece at the specified coordinates. */\nfunction renderGhostPiece(type: number, coords: Coords): void {\n\tconst data = meshes.QuadWorld_ColorTexture(coords, [1, 1, 1, ghostOpacity]);\n\tconst model = createRenderable(\n\t\tdata,\n\t\t2,\n\t\t'TRIANGLES',\n\t\t'colorTexture',\n\t\ttrue,\n\t\ttexturecache.getTexture(type),\n\t);\n\tmodel.render();\n}\n\n// ------------------------------------------------------------------------------\n\nexport default {\n\trenderPiecesInGame,\n\trenderGhostPiece,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/primitives.ts",
    "content": "// src/client/scripts/esm/game/rendering/primitives.ts\n\n/**\n * This script contains methods for obtaining the vertex array data\n * of many common shapes, when their dimensions and position are known.\n *\n * This vertex data can then be used to pass into a buffer model for rendering.\n */\n\nimport type { Color } from '../../../../../shared/util/math/math.js';\n\n// =========================================== Quads ==================================================\n\n/** [TRIANGLES] Generates vertex data for a 2D quad with NO COLOR DATA. */\nfunction Quad(left: number, bottom: number, right: number, top: number): number[] {\n\t// prettier-ignore\n\treturn [\n\t\t//     Position\n        left,  bottom,\n        left,  top,\n        right, bottom,\n        right, bottom,\n        left,  top,\n        right, top,\n\t];\n}\n\n/** [TRIANGLES] Generates vertex data for a solid-colored 2D quad. */\n// prettier-ignore\nfunction Quad_Color(left: number, bottom: number, right: number, top: number, [r,g,b,a]: Color): number[] {\n\treturn [\n\t\t//      Position           Color\n        left,  bottom,      r, g, b, a,\n        left,  top,         r, g, b, a,\n        right, bottom,      r, g, b, a,\n\n        right, bottom,      r, g, b, a,\n        left,  top,         r, g, b, a,\n        right, top,         r, g, b, a,\n\t];\n}\n\n/** [TRIANGLES] Generates vertex data for a solid-colored 3D quad. */\n// prettier-ignore\nfunction Quad_Color3D(left: number, bottom: number, right: number, top: number, z: number, [r,g,b,a]: Color): number[] {\n\treturn [\n\t\t//      Position              Color\n        left,  bottom, z,      r, g, b, a,\n        left,  top,    z,      r, g, b, a,\n        right, bottom, z,      r, g, b, a,\n\n        right, bottom, z,      r, g, b, a,\n        left,  top,    z,      r, g, b, a,\n        right, top,    z,      r, g, b, a,\n\t];\n}\n\n/** [TRIANGLES] Generates vertex and texture coordinate data for a textured 2D quad. */\n// prettier-ignore\nfunction Quad_Texture(left: number, bottom: number, right: number, top: number, texleft: number, texbottom: number, texright: number, textop: number): number[] {\n\treturn [\n\t\t//     Position          Texture Coord\n        left,  bottom,    texleft,  texbottom,\n        left,  top,       texleft,  textop,\n        right, bottom,    texright, texbottom,\n\n        right, bottom,    texright, texbottom,\n        left,  top,       texleft,  textop,\n        right, top,       texright, textop,\n\t];\n}\n\n/** [TRIANGLES] Generates vertex, texture coordinate, and color data for a tinted textured 2D quad. */\n// prettier-ignore\nfunction Quad_ColorTexture(left: number, bottom: number, right: number, top: number, texleft: number, texbottom: number, texright: number, textop: number, r: number, g: number, b: number, a: number): number[] {\n\treturn [\n\t\t//     Position          Texture Coord           Color\n        left,  bottom,    texleft,  texbottom,    r, g, b, a,\n        left,  top,       texleft,  textop,       r, g, b, a,\n        right, bottom,    texright, texbottom,    r, g, b, a,\n\n        right, bottom,    texright, texbottom,    r, g, b, a,\n        left,  top,       texleft,  textop,       r, g, b, a,\n        right, top,       texright, textop,       r, g, b, a,\n\t];\n}\n\n/** [TRIANGLES] Generates vertex, texture coordinate, and color data for a tinted textured 3D quad. */\n// prettier-ignore\nfunction Quad_ColorTexture3D(left: number, bottom: number, right: number, top: number, z: number, texleft: number, texbottom: number, texright: number, textop: number, r: number, g: number, b: number, a: number): number[] {\n\treturn [\n\t\t//       Position            Texture Coord           Color\n        left,  bottom, z,     texleft,  texbottom,    r, g, b, a,\n        left,  top,    z,     texleft,  textop,       r, g, b, a,\n        right, bottom, z,     texright, texbottom,    r, g, b, a,\n\n        right, bottom, z,     texright, texbottom,    r, g, b, a,\n        left,  top,    z,     texleft,  textop,       r, g, b, a,\n        right, top,    z,     texright, textop,       r, g, b, a,\n\t];\n}\n\n/** [LINE_LOOP] Generates vertex data for the outline of a 2D rectangle. */\n// prettier-ignore\nfunction Rect(left: number, bottom: number, right: number, top: number, [r,g,b,a]: Color): number[] {\n\treturn [\n\t\t//    x     y            color\n        left,  bottom,    r, g, b, a,\n        left,  top,       r, g, b, a,\n        right, top,       r, g, b, a,\n        right, bottom,    r, g, b, a,\n\t];\n}\n\n/** [TRIANGLES] Generates vertex data for the outline of a 2D DASHED rectangle. */\n// prettier-ignore\nfunction DashedRect(left: number, bottom: number, right: number, top: number, thickness: number, dashLength: number, gapLength: number, [r,g,b,a]: Color): number[] {\n\tconst data: number[] = [];\n\tconst cycleLength = dashLength + gapLength;\n\tconst halfThick = thickness / 2;\n\n\t// Return empty array for invalid parameters to avoid infinite loops or drawing garbage.\n\tif (dashLength <= 0 || thickness <= 0 || cycleLength <= 0) return [];\n\n\tconst pushQuad = (left: number, bottom: number, right: number, top: number): void => {\n\t\tdata.push(\n\t\t\t// Position     \t  Color\n\t\t\tleft,  bottom,      r, g, b, a,\n\t\t\tleft,  top,         r, g, b, a,\n\t\t\tright, bottom,      r, g, b, a,\n\t\t\tright, bottom,      r, g, b, a,\n\t\t\tleft,  top,         r, g, b, a,\n\t\t\tright, top,         r, g, b, a\n\t\t);\n\t};\n\n\t// Horizontal dashes (bottom and top edges)\n\tfor (let x = left; x < right; x += cycleLength) {\n\t\tconst dashEnd = Math.min(x + dashLength, right);\n\t\tif (dashEnd > x) {\n\t\t\t// Bottom\n\t\t\tpushQuad(x, bottom - halfThick, dashEnd, bottom + halfThick);\n\t\t\t// Top\n\t\t\tpushQuad(x, top - halfThick, dashEnd, top + halfThick);\n\t\t}\n\t}\n\n\t// Vertical dashes (left and right edges)\n\tfor (let y = bottom; y < top; y += cycleLength) {\n\t\tconst dashEnd = Math.min(y + dashLength, top);\n\t\tif (dashEnd > y) {\n\t\t\t// Left\n\t\t\tpushQuad(left - halfThick, y, left + halfThick, dashEnd);\n\t\t\t// Right\n\t\t\tpushQuad(right - halfThick, y, right + halfThick, dashEnd);\n\t\t}\n\t}\n\n\treturn data;\n}\n\n// =========================================== Circles ================================================\n\n/** [LINE_LOOP] Generates vertex data for the outline of a hollow circle. */\n// function Circle_LINES(x: number, y: number, radius: number, r: number, g: number, b: number, a: number, resolution: number): number[] { // res is resolution\n// \tif (resolution < 3) throw Error(\"Resolution must be 3+ to get data of a circle.\");\n\n// \tconst data: number[] = [];\n\n// \tfor (let i = 0; i < resolution; i++) {\n// \t\tconst theta = (i / resolution) * 2 * Math.PI;\n\n// \t\tconst thisX = x + radius * Math.cos(theta);\n// \t\tconst thisY = y + radius * Math.sin(theta);\n\n// \t\t// Points around the circle\n// \t\tdata.push(thisX, thisY, r, g, b, a);\n// \t}\n\n// \treturn data;\n// }\n\n/** [TRIANGLES] Generates vertex data for a solid-colored circle composed of triangles. */\n// prettier-ignore\nfunction Circle(x: number, y: number, radius: number, resolution: number, [r,g,b,a]: Color): number[] {\n\tif (resolution < 3) throw Error(\"Resolution must be 3+ to get data of a circle.\");\n\n\tconst data: number[] = [];\n\n\tfor (let i = 0; i < resolution; i++) {\n\t\t// Current and next angle positions\n\t\tconst theta = (i / resolution) * 2 * Math.PI;\n\t\tconst nextTheta = ((i + 1) / resolution) * 2 * Math.PI;\n\n\t\t// Position of current and next points on the circumference\n\t\tconst x1 = x + radius * Math.cos(theta);\n\t\tconst y1 = y + radius * Math.sin(theta);\n\t\tconst x2 = x + radius * Math.cos(nextTheta);\n\t\tconst y2 = y + radius * Math.sin(nextTheta);\n\n\t\t// Center point\n\t\tdata.push(x,  y,    r, g, b, a);\n\t\t// Points around the circle\n\t\tdata.push(x1, y1,   r, g, b, a);\n\t\tdata.push(x2, y2,   r, g, b, a);\n\t}\n\n\treturn data;\n}\n\n/** [TRIANGLE_FAN] Generates vertex data for a circle with a color gradient from the center to the edge. */\n// prettier-ignore\nfunction GlowDot(x: number, y: number, radius: number, resolution: number, [r1,g1,b1,a1]: Color, [r2,g2,b2,a2]: Color): number[] { \n\tif (resolution < 3) throw Error(\"Resolution must be 3+ to get data of a fuzz ball.\");\n\n\tconst data: number[] = [x, y, r1, g1, b1, a1]; // Mid point\n\n\tfor (let i = 0; i <= resolution; i++) {\n\t\t// Add all outer points\n\t\tconst theta = (i / resolution) * 2 * Math.PI;\n\t\tconst thisX = x + radius * Math.cos(theta);\n\t\tconst thisY = y + radius * Math.sin(theta);\n\t\tdata.push(...[thisX, thisY,   r2, g2, b2, a2]);\n\t}\n\n\treturn data;\n}\n\n/** [TRIANGLES] Generates vertex data for a solid-colored ring. */\n// function RingSolid(x: number, y: number, inRad: number, outRad: number, resolution: number, [r,g,b,a]: Color): number[] {\n// \tif (resolution < 3) throw Error(\"Resolution must be 3+ to get data of a ring.\");\n\n// \tconst data: number[] = [];\n\n// \tfor (let i = 0; i < resolution; i++) {\n// \t\tconst theta = (i / resolution) * 2 * Math.PI;\n// \t\tconst nextTheta = ((i + 1) / resolution) * 2 * Math.PI;\n\n// \t\tconst innerX = x + inRad * Math.cos(theta);\n// \t\tconst innerY = y + inRad * Math.sin(theta);\n// \t\tconst outerX = x + outRad * Math.cos(theta);\n// \t\tconst outerY = y + outRad * Math.sin(theta);\n\n// \t\tconst innerXNext = x + inRad * Math.cos(nextTheta);\n// \t\tconst innerYNext = y + inRad * Math.sin(nextTheta);\n// \t\tconst outerXNext = x + outRad * Math.cos(nextTheta);\n// \t\tconst outerYNext = y + outRad * Math.sin(nextTheta);\n\n// \t\t// Add triangles for the current and next segments\n// \t\tdata.push(\n// \t\t\tinnerX, innerY, r, g, b, a,\n// \t\t\touterX, outerY, r, g, b, a,\n// \t\t\tinnerXNext, innerYNext, r, g, b, a,\n\n// \t\t\touterX, outerY, r, g, b, a,\n// \t\t\touterXNext, outerYNext, r, g, b, a,\n// \t\t\tinnerXNext, innerYNext, r, g, b, a\n// \t\t);\n// \t}\n\n// \treturn data;\n// }\n\n/** [TRIANGLES] Generates vertex data for a ring with color gradients between the inner and outer edges. */\n// prettier-ignore\nfunction Ring(x: number, y: number, inRad: number, outRad: number, resolution: number, [r1,g1,b1,a1]: Color, [r2,g2,b2,a2]: Color): number[] {\n\tif (resolution < 3) throw Error(\"Resolution must be 3+ to get data of a ring.\");\n\n\tconst data: number[] = [];\n\n\tfor (let i = 0; i < resolution; i++) {\n\t\tconst theta = (i / resolution) * 2 * Math.PI;\n\t\tconst nextTheta = ((i + 1) / resolution) * 2 * Math.PI;\n\n\t\tconst innerX = x + inRad * Math.cos(theta);\n\t\tconst innerY = y + inRad * Math.sin(theta);\n\t\tconst outerX = x + outRad * Math.cos(theta);\n\t\tconst outerY = y + outRad * Math.sin(theta);\n\n\t\tconst innerXNext = x + inRad * Math.cos(nextTheta);\n\t\tconst innerYNext = y + inRad * Math.sin(nextTheta);\n\t\tconst outerXNext = x + outRad * Math.cos(nextTheta);\n\t\tconst outerYNext = y + outRad * Math.sin(nextTheta);\n\n\t\t// Add triangles for the current and next segments\n\t\tdata.push(\n\t\t\tinnerX,     innerY,          r1, g1, b1, a1,\n\t\t\touterX,     outerY,          r2, g2, b2, a2,\n\t\t\tinnerXNext, innerYNext,      r1, g1, b1, a1,\n\n\t\t\touterX,     outerY,          r2, g2, b2, a2,\n\t\t\touterXNext, outerYNext,      r2, g2, b2, a2,\n\t\t\tinnerXNext, innerYNext,      r1, g1, b1, a1\n\t\t);\n\t}\n\n\treturn data;\n}\n\n/**\n * [TRIANGLES] Generates vertex data for a radial gradient centered at (x, y).\n * Colors repeat outward with the given spacing (same units as x/y) and phase offset.\n */\n// prettier-ignore\nfunction RadialGradient(x: number, y: number, radius: number, colors: Color[], spacing: number, phase: number, resolution: number): number[] {\n\tif (colors.length === 0 || spacing <= 0 || radius <= 0) return [];\n\n\tconst n = colors.length;\n\n\tfunction colorAtRadius(r: number): Color {\n\t\tconst t = (r + phase) / spacing;\n\t\tconst lower = Math.floor(t);\n\t\tconst frac = t - lower;\n\t\tconst c1 = colors[((lower % n) + n) % n]!;\n\t\tconst c2 = colors[(((lower + 1) % n) + n) % n]!;\n\t\treturn [\n\t\t\tc1[0] + (c2[0] - c1[0]) * frac,\n\t\t\tc1[1] + (c2[1] - c1[1]) * frac,\n\t\t\tc1[2] + (c2[2] - c1[2]) * frac,\n\t\t\tc1[3] + (c2[3] - c1[3]) * frac,\n\t\t];\n\t}\n\n\t// Build ring boundaries: radii where (r + phase) is an exact multiple of spacing.\n\tconst phasemod = ((phase % spacing) + spacing) % spacing;\n\tconst firstBoundary = phasemod === 0 ? 0 : spacing - phasemod;\n\n\tconst boundaries: number[] = [0];\n\tlet r = firstBoundary > 0 ? firstBoundary : spacing;\n\twhile (r < radius) {\n\t\tboundaries.push(r);\n\t\tr += spacing;\n\t}\n\tboundaries.push(radius);\n\n\tconst data: number[] = [];\n\n\tfor (let i = 0; i < boundaries.length - 1; i++) {\n\t\tconst innerR = boundaries[i]!;\n\t\tconst outerR = boundaries[i + 1]!;\n\t\tconst [r1, g1, b1, a1] = colorAtRadius(innerR);\n\t\tconst [r2, g2, b2, a2] = colorAtRadius(outerR);\n\n\t\tfor (let j = 0; j < resolution; j++) {\n\t\t\tconst theta     = (j     / resolution) * 2 * Math.PI;\n\t\t\tconst nextTheta = ((j + 1) / resolution) * 2 * Math.PI;\n\n\t\t\tconst outerX     = x + outerR * Math.cos(theta);\n\t\t\tconst outerY     = y + outerR * Math.sin(theta);\n\t\t\tconst outerXNext = x + outerR * Math.cos(nextTheta);\n\t\t\tconst outerYNext = y + outerR * Math.sin(nextTheta);\n\n\t\t\tif (innerR === 0) {\n\t\t\t\tdata.push(\n\t\t\t\t\tx,          y,              r1, g1, b1, a1,\n\t\t\t\t\touterX,     outerY,         r2, g2, b2, a2,\n\t\t\t\t\touterXNext, outerYNext,     r2, g2, b2, a2,\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\tconst innerX     = x + innerR * Math.cos(theta);\n\t\t\t\tconst innerY     = y + innerR * Math.sin(theta);\n\t\t\t\tconst innerXNext = x + innerR * Math.cos(nextTheta);\n\t\t\t\tconst innerYNext = y + innerR * Math.sin(nextTheta);\n\n\t\t\t\tdata.push(\n\t\t\t\t\tinnerX,     innerY,         r1, g1, b1, a1,\n\t\t\t\t\touterX,     outerY,         r2, g2, b2, a2,\n\t\t\t\t\tinnerXNext, innerYNext,     r1, g1, b1, a1,\n\n\t\t\t\t\touterX,     outerY,         r2, g2, b2, a2,\n\t\t\t\t\touterXNext, outerYNext,     r2, g2, b2, a2,\n\t\t\t\t\tinnerXNext, innerYNext,     r1, g1, b1, a1,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn data;\n}\n\n// =========================================== Other Shapes ================================================\n\n/** [TRIANGLES] Generates vertex data for a four-sided, hollow rectangular prism. */\n// prettier-ignore\nfunction BoxTunnel(left: number, bottom: number, startZ: number, right: number, top: number, endZ: number, r: number, g: number, b: number, a: number): number[] {\n\treturn [\n\t\t//     Vertex                   Color\n        left,  bottom, startZ,      r, g, b, a,\n        left,  bottom, endZ,        r, g, b, a,\n        right, bottom, startZ,      r, g, b, a,\n        right, bottom, startZ,      r, g, b, a,\n        left,  bottom, endZ,        r, g, b, a,\n        right, bottom, endZ,        r, g, b, a,\n\n        right, bottom, startZ,      r, g, b, a,\n        right, bottom, endZ,        r, g, b, a,\n        right, top,    startZ,      r, g, b, a,\n        right, top,    startZ,      r, g, b, a,\n        right, bottom, endZ,        r, g, b, a,\n        right, top,    endZ,        r, g, b, a,\n\n        right, top,    startZ,      r, g, b, a,\n        right, top,    endZ,        r, g, b, a,\n        left,  top,    startZ,      r, g, b, a,\n        left,  top,    startZ,      r, g, b, a,\n        right, top,    endZ,        r, g, b, a,\n        left,  top,    endZ,        r, g, b, a,\n\n        left,  top,    startZ,      r, g, b, a,\n        left,  top,    endZ,        r, g, b, a,\n        left,  bottom, startZ,      r, g, b, a,\n        left,  bottom, startZ,      r, g, b, a,\n        left,  top,    endZ,        r, g, b, a,\n        left,  bottom, endZ,        r, g, b, a,\n\t];\n}\n\n// =========================================== Exports ================================================\n\nexport default {\n\t// Quads\n\tQuad,\n\tQuad_Color,\n\tQuad_Color3D,\n\tQuad_Texture,\n\tQuad_ColorTexture,\n\tQuad_ColorTexture3D,\n\tRect,\n\tDashedRect,\n\t// Circles\n\tCircle,\n\tGlowDot,\n\tRing,\n\tRadialGradient,\n\t// Other Shapes\n\tBoxTunnel,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/promotionlines.ts",
    "content": "// src/client/scripts/esm/game/rendering/promotionlines.ts\n\n/**\n * This script handles the rendering of our promotion lines.\n */\n\nimport type { Color } from '../../../../../shared/util/math/math.js';\n\nimport bd from '@naviary/bigdecimal';\n\nimport { players as p } from '../../../../../shared/chess/util/typeutil.js';\n\nimport camera from './camera.js';\nimport meshes from './meshes.js';\nimport gameslot from '../chess/gameslot.js';\nimport boardpos from './boardpos.js';\nimport boardtiles from './boardtiles.js';\nimport primitives from './primitives.js';\nimport { createRenderable } from '../../webgl/Renderable.js';\n\n// ===================================== Constants =====================================\n\n/** How many tiles on both ends the promotion lines should extend past the farthest piece */\nconst EXTRA_LENGTH = 2;\n/** Vertical thickness of the promotion lines. */\nconst THICKNESS = 0.01;\n\n// ===================================== Functions =====================================\n\nfunction render(): void {\n\tconst gamefile = gameslot.getGamefile()!;\n\tif (gamefile.basegame.gameRules.promotionRanks === undefined) return; // No promotion ranks in this game\n\n\t// Generate the vertex data\n\n\tconst position = boardpos.getBoardPos();\n\tconst scale = boardpos.getBoardScaleAsNumber();\n\n\tlet left: number;\n\tlet right: number;\n\n\tif (gamefile.boardsim.editor) {\n\t\t// In editor mode, the promotion lines extend to the edges of the screen\n\t\t({ left, right } = camera.getRespectiveScreenBox());\n\t} else {\n\t\t// Round the start position box away to encapsulate the entirity of all squares\n\t\tconst floatingBox = meshes.expandTileBoundingBoxToEncompassWholeSquare(\n\t\t\tgamefile.boardsim.startSnapshot.box,\n\t\t);\n\t\tleft = (bd.toNumber(bd.subtract(floatingBox.left, position[0])) - EXTRA_LENGTH) * scale;\n\t\tright = (bd.toNumber(bd.subtract(floatingBox.right, position[0])) + EXTRA_LENGTH) * scale;\n\t}\n\n\tconst squareCenterNum = boardtiles.getSquareCenterAsNumber();\n\tconst color: Color = [0, 0, 0, 1];\n\tconst vertexData: number[] = [];\n\n\taddDataForSide(gamefile.basegame.gameRules.promotionRanks[p.WHITE], 1);\n\taddDataForSide(gamefile.basegame.gameRules.promotionRanks[p.BLACK], 0);\n\n\tfunction addDataForSide(ranks: bigint[] | undefined, yShift: 1 | 0): void {\n\t\tif (!ranks) return;\n\t\tranks.forEach((rank) => {\n\t\t\tconst rankBD = bd.fromBigInt(rank);\n\t\t\tconst relativeRank: number = bd.toNumber(bd.subtract(rankBD, position[1])); // Subtract our board position\n\n\t\t\tconst bottom = (relativeRank - squareCenterNum + yShift - THICKNESS) * scale;\n\t\t\tconst top = (relativeRank - squareCenterNum + yShift + THICKNESS) * scale;\n\t\t\tvertexData.push(...primitives.Quad_Color(left, bottom, right, top, color));\n\t\t});\n\t}\n\n\t// Create and Render the model\n\n\tcreateRenderable(vertexData, 2, 'TRIANGLES', 'color', true).render();\n}\n\n// ===================================== Exports =====================================\n\nexport default {\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/screenshake.ts",
    "content": "// src/client/scripts/esm/game/rendering/screenshake.ts\n\n/**\n * This module can apply a screen shake effect to the camera when requested.\n */\n\nimport type { Mat4 } from './camera';\n\nimport mat4 from './gl-matrix.js';\nimport camera from './camera';\nimport { GameBus } from '../GameBus.js';\nimport loadbalancer from '../misc/loadbalancer.js';\nimport frametracker from './frametracker.js';\n\n// Constants -----------------------------------------------------------------------\n\n// Shake Parameters\n\n/** Maximum rotation in any direction (in degrees). */\nconst MAX_ROTATION_DEGREES = 1.7; // Default: 2.1\n/** Maximum translation in any direction (in world units). */\nconst MAX_TRANSLATION = 0.23; // Default: 0.28\n\n/** How quickly trauma fades. Higher is faster. */\nconst TRAUMA_DECAY = 1.2;\n\n// State ---------------------------------------------------------------------------\n\nlet trauma = 0.0; // Current shake intensity, 0.0 to 1.0\n\n// Events --------------------------------------------------------------------------\n\nGameBus.addEventListener('game-unloaded', () => {\n\tclear();\n});\n\n// Functions -----------------------------------------------------------------------\n\n/**\n * Adds trauma to the camera, triggering or intensifying the shake.\n * @param amount The amount of trauma to add (usually between 0.1 and 1.0).\n */\nfunction trigger(amount: number): void {\n\t// console.log(\"Shake trauma added: \" + amount);\n\ttrauma = Math.min(trauma + amount, 1.0);\n\tframetracker.onVisualChange(); // Request an animation frame\n\tcamera.onPositionChange(); // Camera will update its view matrix\n}\n\n/** Clears all trauma, stopping any shake immediately. */\nfunction clear(): void {\n\ttrauma = 0.0;\n\tframetracker.onVisualChange();\n\tcamera.onPositionChange(); // Camera will update its view matrix\n}\n\n/**\n * Updates the trauma level. Called once per frame.\n */\nfunction update(): void {\n\tif (trauma === 0) return;\n\t// Decrease trauma over time\n\tconst deltaTimeSecs = loadbalancer.getDeltaTime();\n\ttrauma = Math.max(trauma - deltaTimeSecs * TRAUMA_DECAY, 0);\n\tframetracker.onVisualChange(); // Request an animation frame\n\tcamera.onPositionChange(); // Camera will update its view matrix\n}\n\n/**\n * Calculates and returns a 4x4 transformation matrix representing the current shake offset.\n * If there is no trauma, it returns an identity matrix (no shake).\n */\nfunction getShakeMatrix(): Mat4 {\n\tif (trauma <= 0) return mat4.create(); // Returns an identity matrix\n\n\t// The intensity of the shake is proportional to the square of the trauma.\n\t// This makes small amounts of trauma barely noticeable, and large amounts very dramatic.\n\tconst shakePower = trauma;\n\n\t/** Generates a random value in a [-1, 1] range. */\n\tconst getRandomNoise = (): number => (Math.random() - 0.5) * 2;\n\n\t// Calculate Rotation\n\tconst yaw = MAX_ROTATION_DEGREES * shakePower * getRandomNoise();\n\tconst pitch = MAX_ROTATION_DEGREES * shakePower * getRandomNoise();\n\tconst roll = MAX_ROTATION_DEGREES * shakePower * getRandomNoise();\n\n\t// Convert degrees to radians for gl-matrix\n\tconst yawRad = (yaw * Math.PI) / 180;\n\tconst pitchRad = (pitch * Math.PI) / 180;\n\tconst rollRad = (roll * Math.PI) / 180;\n\n\t// Calculate Translation\n\tconst offsetX = MAX_TRANSLATION * shakePower * getRandomNoise();\n\tconst offsetY = MAX_TRANSLATION * shakePower * getRandomNoise();\n\tconst offsetZ = MAX_TRANSLATION * shakePower * getRandomNoise();\n\n\t// Create the Transformation Matrix\n\tconst shakeMatrix = mat4.create();\n\n\t// Apply translation\n\tmat4.translate(shakeMatrix, shakeMatrix, [offsetX, offsetY, offsetZ]);\n\n\t// Apply rotations (order can matter, Z then X then Y is common)\n\tmat4.rotateZ(shakeMatrix, shakeMatrix, rollRad);\n\tmat4.rotateX(shakeMatrix, shakeMatrix, pitchRad);\n\tmat4.rotateY(shakeMatrix, shakeMatrix, yawRad);\n\n\treturn shakeMatrix;\n}\n\n// Exports -------------------------------------------------------------------------\n\nexport default {\n\ttrigger,\n\tupdate,\n\tgetShakeMatrix,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/starfield.ts",
    "content": "// src/client/scripts/esm/game/rendering/starfield.ts\n\n/**\n * Renders a starfield background inside voids and the world border\n */\n\nimport type { Color } from '../../../../../shared/util/math/math.js';\nimport type { DoubleCoords } from '../../../../../shared/chess/util/coordutil.js';\n\nimport bounds from '../../../../../shared/util/math/bounds.js';\nimport boardutil from '../../../../../shared/chess/util/boardutil.js';\nimport { rawTypes as r } from '../../../../../shared/chess/util/typeutil.js';\n\nimport camera from './camera.js';\nimport docutil from '../../util/docutil.js';\nimport gameslot from '../chess/gameslot.js';\nimport primitives from './primitives.js';\nimport boardtiles from './boardtiles.js';\nimport gameloader from '../chess/gameloader.js';\nimport preferences from '../../components/header/preferences.js';\nimport perspective from './perspective.js';\nimport { GameBus } from '../GameBus.js';\nimport loadbalancer from '../misc/loadbalancer.js';\nimport frametracker from './frametracker.js';\nimport {\n\tAttributeInfoInstanced,\n\tcreateRenderable_Instanced_GivenInfo,\n} from '../../webgl/Renderable.js';\n\n/** A sigle star particle. */\ntype Star = {\n\t/** Determines if the star should use the light or dark tile color theme. */\n\tisLight: boolean;\n\t/** Lifespan in milliseconds */\n\tlifespan: number;\n\tposition: DoubleCoords;\n\tvelocity: DoubleCoords;\n\tsize: number;\n\t/** The maximum size offset for this star's pulse (the amplitude). */\n\tpulseSize: number;\n\t/** The speed of this star's pulse in radians per second. */\n\tpulseSpeed: number;\n\t/** The timestamp when the star was created. */\n\tcreatedAt: number;\n};\n\n/** The attribute info of our instanced models' vertex data. */\nconst ATTRIB_INFO: AttributeInfoInstanced = {\n\tvertexDataAttribInfo: [{ name: 'a_position', numComponents: 2 }],\n\tinstanceDataAttribInfo: [\n\t\t{ name: 'a_instanceposition', numComponents: 2 },\n\t\t{ name: 'a_instancecolor', numComponents: 4 },\n\t\t{ name: 'a_instancesize', numComponents: 1 },\n\t],\n};\n\n/** Configuration variables for Star Field appearance. */\nconst CONFIG = {\n\t/** The DENSITY of stars, measured in stars per square unit of world space. */\n\tstarDensity: 0.07, // Default: 0.07\n\t// starDensity: 1,\n\n\t/** How many additional units of world space beyond the edges of the screen to spawn stars. */\n\tscreenPadding: 3, // Units of world space\n\n\t/** Maximum opacity of a star. */\n\topacity: 0.3, // Default: 0.3\n\n\t// --- Lifespan ---\n\t/** Average lifespan in seconds. */\n\tbaseLifespan: 25.0,\n\t// baseLifespan: 5.0,\n\t/** How much the lifespan can vary from the base. */\n\tlifespanVariance: 5.0,\n\t// lifespanVariance: 0.0,\n\n\t// --- Size ---\n\t/** The average width of a star in world units. */\n\tbaseWidth: 0.5,\n\t/** How much the width can vary from the base. */\n\twidthVariance: 0.2,\n\n\t// --- Motion ---\n\t/** Average speed in world units per second. */\n\tbaseSpeed: 0.2,\n\t/** How much the speed can vary from the base. */\n\tspeedVariance: 0.1,\n\n\t// --- Pulse Animation ---\n\t/**\n\t * The average maximum amount a star's size will increase from its base due to the pulse.\n\t * DOES NOT decrease the size below baseWidth, only pulses it LARGER.\n\t */\n\tbasePulseSize: 0.13,\n\t/** How much the pulse amplitude can vary. */\n\tpulseSizeVariance: 0.04,\n\t/** The average speed of the pulse in radians per second. Higher is faster. */\n\tbasePulseSpeed: 1.5,\n\t/** How much the pulse speed can vary. */\n\tpulseSpeedVariance: 0.4,\n\n\t// --- Fading  ---\n\t/** The duration of the fade-in/out at the start/end of a star's life, in seconds. */\n\tfadeDuration: 3.0,\n\t// fadeDuration: 0.0,\n} as const;\n\n// Module State ------------------------------------------------------------\n\n/** All star objects. The entire star field. */\nconst stars: Star[] = [];\n/**\n * Whether the star field has been initialized or not.\n * It will never be initialized if they are disabled.\n */\nlet isInitialized: boolean = false;\n/**\n * This frame's desired number of stars.\n * This varies based on your screen area.\n */\nlet desiredNumStars: number = 0;\n\n// Initialization -----------------------------------------------------------------------\n\n/** Event listener for when we toggle Starfield in the settings dropdown. */\ndocument.addEventListener('starfield-toggle', (e) => {\n\tif (!gameloader.areInAGame()) return; // Not in a game => Starfield should not be initiated or terminated.\n\tconst enabled: boolean = e.detail;\n\tif (enabled) init();\n\telse terminate();\n});\n\nGameBus.addEventListener('game-unloaded', () => {\n\t// Terminate starfield on game unload (can't be in gameloader since that doesn't unload its stuff on a pasted game)\n\tterminate();\n});\n\n/**\n * Initializes the starfield system, creating all the star objects.\n * This must be called once before `update`.\n */\nfunction init(): void {\n\tif (isInitialized) throw Error('Starfield is already initialized.');\n\tif (!couldStarfieldEverBeVisible()) {\n\t\t// console.log(\"Starfield cannot ever be visible in this game, not initializing.\");\n\t\treturn; // Starfield cannot be visible in this game\n\t}\n\n\t// First, calculate the initial desired number of stars.\n\tdesiredNumStars = getDesiredNumStars();\n\n\t// Now populate the field.\n\tfor (let i = 0; i < desiredNumStars; i++) {\n\t\tconst star: Star = createStar(true);\n\t\tstars.push(star);\n\t}\n\n\tisInitialized = true;\n}\n\n/** Closes the starfield system, resetting its state. */\nfunction terminate(): void {\n\tdesiredNumStars = 0;\n\t// Clear any existing stars\n\tstars.length = 0;\n\tisInitialized = false;\n}\n\n/**\n * Creates a brand new star with random properties.\n * @param randomizeAge - If true, the star's age will be randomized to a value between 0 and its lifespan.\n * This is useful for initial population of stars, so they don't all fade in/out near the same time.\n */\nfunction createStar(randomizeAge: boolean): Star {\n\t// Position\n\tconst screenBox = camera.getScreenBoundingBox(false);\n\t// Apply padding\n\tscreenBox.left -= CONFIG.screenPadding;\n\tscreenBox.right += CONFIG.screenPadding;\n\tscreenBox.bottom -= CONFIG.screenPadding;\n\tscreenBox.top += CONFIG.screenPadding;\n\tconst width = screenBox.right - screenBox.left;\n\tconst height = screenBox.top - screenBox.bottom;\n\tconst position: DoubleCoords = [\n\t\tMath.random() * width + screenBox.left,\n\t\tMath.random() * height + screenBox.bottom,\n\t];\n\n\t// Velocity\n\tconst speed: number = applyVariance(CONFIG.baseSpeed, CONFIG.speedVariance);\n\tconst angle: number = Math.random() * 2 * Math.PI;\n\tconst velocity: DoubleCoords = [Math.cos(angle) * speed, Math.sin(angle) * speed];\n\n\t// Lifespan\n\tlet newLifespan = applyVariance(CONFIG.baseLifespan, CONFIG.lifespanVariance) * 1000; // Convert to milliseconds\n\tif (randomizeAge) newLifespan = Math.random() * newLifespan;\n\n\treturn {\n\t\tisLight: Math.random() < 0.5,\n\t\tlifespan: newLifespan,\n\t\tposition,\n\t\tvelocity,\n\t\tsize: Math.max(0.1, applyVariance(CONFIG.baseWidth, CONFIG.widthVariance)),\n\t\tpulseSize: applyVariance(CONFIG.basePulseSize, CONFIG.pulseSizeVariance),\n\t\tpulseSpeed: applyVariance(CONFIG.basePulseSpeed, CONFIG.pulseSpeedVariance),\n\t\tcreatedAt: performance.now(),\n\t};\n}\n\n/** Calculate's this frames desired number of stars, dependant on your screen area. */\nfunction getDesiredNumStars(): number {\n\tconst screenBox = camera.getScreenBoundingBox(false);\n\tconst paddedWidth = screenBox.right - screenBox.left + CONFIG.screenPadding * 2;\n\tconst paddedHeight = screenBox.top - screenBox.bottom + CONFIG.screenPadding * 2;\n\tconst area = paddedWidth * paddedHeight;\n\treturn Math.round(area * CONFIG.starDensity);\n}\n\n/**\n * A helper function to apply random variance to a base value.\n * @param base The central value.\n * @param variance The maximum amount the value can deviate from the base.\n * @returns A randomized value.\n */\nfunction applyVariance(base: number, variance: number): number {\n\treturn base + (Math.random() - 0.5) * 2 * variance;\n}\n\n// Updating ----------------------------------------------------------------------\n\n/** Updates all stars motion, opacity, pulsing, birth, and death! */\nfunction update(): void {\n\tif (!isInitialized) return;\n\n\t// Call for a render this frame if the starfield is visible\n\tif (isStarfieldVisible()) {\n\t\tframetracker.onVisualChange();\n\t\t// console.log(\"Starfield visible, requesting render.\");\n\t}\n\n\t// Update the desired number of stars for this frame ---\n\tdesiredNumStars = getDesiredNumStars();\n\n\tconst deltaTimeSecs = loadbalancer.getDeltaTime();\n\tconst now = performance.now(); // Get the current time once.\n\n\t// 1. Update existing stars and handle deaths\n\tfor (let i = stars.length - 1; i >= 0; i--) {\n\t\tconst star = stars[i]!;\n\n\t\t// Update position and size\n\t\tstar.position[0] += star.velocity[0] * deltaTimeSecs;\n\t\tstar.position[1] += star.velocity[1] * deltaTimeSecs;\n\n\t\t// Check for death based on actual elapsed time.\n\t\tconst starAge = now - star.createdAt;\n\t\tif (starAge >= star.lifespan) {\n\t\t\t// A star has died. Check if we should replace it.\n\t\t\tif (stars.length > desiredNumStars) {\n\t\t\t\t// We have too many stars right now, so just remove this one.\n\t\t\t\t// This can happen if the user shrinks their window.\n\t\t\t\tstars.splice(i, 1);\n\t\t\t} else {\n\t\t\t\t// We need to keep the population up, so replace it with a new one.\n\t\t\t\tstars[i] = createStar(false);\n\t\t\t}\n\t\t}\n\t}\n\n\t// 2. Add new stars if we are below the desired count ---\n\t// This can happen if the user enlarges their window.```\n\twhile (stars.length < desiredNumStars) {\n\t\t// Randomize the age (since we may be creating a lot at once)\n\t\tstars.push(createStar(true));\n\t}\n}\n\n/**\n * Returns whether the starfield could at ANY point be visible during the current game.\n * Doesn't care whether Starfield mode could be turned on later, as that is handled by the event listener.\n */\nfunction couldStarfieldEverBeVisible(): boolean {\n\t// If starfield is disabled, it will never be visible.\n\t// (The fact it could be toggled on later is handled by the event listener)\n\tif (!preferences.getStarfieldMode()) return false;\n\n\t// If we're on desktop, perspective mode can be toggled, so the starfield could be visible.\n\tif (docutil.isMouseSupported()) return true;\n\n\t// On mobile...\n\n\t// If voids can be present in the game, the starfield could be visible.\n\tconst gamefile = gameslot.getGamefile()!; // Will be present since starfield is only initialized when we're in a game\n\tif (gamefile.boardsim.existingRawTypes.includes(r.VOID)) return true; // Voids are PRESENT (or can be added in the editor)\n\n\t// If there is a world border, the starfield could be visible.\n\tif (gamefile.basegame.gameRules.worldBorder !== undefined) return true;\n\n\treturn false;\n}\n\n/** Returns whether there's a good chance the starfield is visible RIGHT NOW. Assuming it's initialized. */\nfunction isStarfieldVisible(): boolean {\n\t// If we're in perspective mode, there's a good chance we can\n\t// see the sky, which the starfield is visible in.\n\tif (perspective.getEnabled()) return true;\n\n\t// 2D Mode...\n\n\t// If voids are present in the game, there's also a good chance\n\t// we can see the starfield underneath them.\n\t// It would take too much effort to determine if the void mesh\n\t// overlaps with the screen, so just assume the're visible.\n\tconst gamefile = gameslot.getGamefile()!; // Will be present since starfield is only initialized when we're in a game\n\tif (boardutil.getPieceCountOfType(gamefile.boardsim.pieces, r.VOID) > 0) return true; // Voids are PRESENT\n\n\t// At this point, if there isn't a world border, we know starfield is NOT visible.\n\tif (gamefile.basegame.gameRules.worldBorder === undefined) return false;\n\n\t// There IS a world border...\n\n\t// Last check is whether our screen is entirely contained within the worldBorder box.\n\t// If so, the starfield is NOT visible.\n\tconst screenBox = boardtiles.gboundingBox(false);\n\treturn !bounds.boxContainsBox(gamefile.basegame.gameRules.worldBorder, screenBox);\n}\n\n// Rendering ----------------------------------------------------------------------\n\n/** Renders the star field. */\nfunction render(): void {\n\tconst vertexData: number[] = primitives.Quad(-0.5, -0.5, 0.5, 0.5);\n\tconst instanceData: number[] = []; // Per instance data: Position (2), Color (4), Size (1)\n\n\tconst lightTileColor = preferences.getColorOfLightTiles();\n\tconst darkTileColor = preferences.getColorOfDarkTiles();\n\n\t// Convert the fade duration from seconds to milliseconds.\n\tconst fadeMillis = CONFIG.fadeDuration * 1000;\n\n\tconst now = performance.now(); // Get current time once for this frame.\n\n\tstars.forEach((star) => {\n\t\tconst age = now - star.createdAt;\n\t\tconst timeUntilDeath = star.lifespan - age;\n\n\t\t// Sinusoidal Pulsing Size Calculation\n\t\tconst pulsingCycleSecs = timeUntilDeath / 1000;\n\t\t// Oscillates between 0 and 1 (only increasing size)\n\t\tconst sinWave = -0.5 * Math.cos(pulsingCycleSecs * star.pulseSpeed) + 0.5;\n\t\t// The final size is the base size plus the scaled sine wave\n\t\tconst currentSize = star.size + sinWave * star.pulseSize;\n\n\t\t// Fade In/Out Alpha Calculation\n\t\tlet fadeInAlpha = CONFIG.opacity;\n\t\tif (age < fadeMillis) fadeInAlpha = (age / fadeMillis) * CONFIG.opacity;\n\n\t\tlet fadeOutAlpha = CONFIG.opacity;\n\t\tif (timeUntilDeath < fadeMillis)\n\t\t\tfadeOutAlpha = (timeUntilDeath / fadeMillis) * CONFIG.opacity;\n\n\t\t// Use the minimum of the two alphas.\n\t\t// If a star's lifespan is shorter than 2x fadeDuration,\n\t\t// this will prevent it from reaching full opacity.\n\t\tconst currentAlpha = Math.max(0.0, Math.min(fadeInAlpha, fadeOutAlpha));\n\n\t\t// Select Color & Combine With Alpha\n\t\tconst baseColor = star.isLight ? lightTileColor : darkTileColor;\n\t\tconst currentColor: Color = [baseColor[0], baseColor[1], baseColor[2], currentAlpha];\n\n\t\t// Push instance data\n\t\tinstanceData.push(...star.position, ...currentColor, currentSize);\n\t});\n\n\tperspective.renderWithoutPerspectiveRotations(() => {\n\t\tcreateRenderable_Instanced_GivenInfo(\n\t\t\tvertexData,\n\t\t\tinstanceData,\n\t\t\tATTRIB_INFO,\n\t\t\t'TRIANGLES',\n\t\t\t'starfield',\n\t\t).render();\n\t});\n}\n\n// Exports -----------------------------------------------------------------------\n\nexport default {\n\tinit,\n\tupdate,\n\trender,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/text/glyphatlas.ts",
    "content": "// src/client/scripts/esm/game/rendering/text/glyphatlas.ts\n\n/**\n * This script generates and manages a runtime glyph atlas texture for text rendering.\n *\n * The atlas supports all printable ASCII characters (U+0020–U+007E) plus the Unicode\n * replacement character U+FFFD (displayed when an unsupported character is requested).\n *\n * Glyphs are packed into a multi-row atlas with variable column widths so that the\n * texture remains roughly square (at most 512 × 512 px).\n *\n * Each glyph cell is CELL_HEIGHT pixels tall and as wide as the character's measured\n * advance width (rounded up, with some specified padding on each side to prevent UV bleeding).\n */\n\nimport { gl } from '../webgl.js';\n\n// Types -------------------------------------------------------------------------\n\n/**\n * UV coordinates and advance-width ratio for a single glyph in the atlas.\n * All UV values are in [0, 1] where (0, 0) is the bottom-left corner of the texture\n * (after UNPACK_FLIP_Y_WEBGL is applied during upload).\n */\ninterface GlyphMetrics {\n\t/** Left UV edge of the glyph cell. */\n\tu0: number;\n\t/** Bottom UV edge of the glyph cell (after Y-flip). */\n\tv0: number;\n\t/** Right UV edge of the glyph cell. */\n\tu1: number;\n\t/** Top UV edge of the glyph cell (after Y-flip). */\n\tv1: number;\n\t/**\n\t * Advance width relative to CELL_HEIGHT. Multiply by the desired world-space\n\t * character height (`size`) to get the world-space quad width for this glyph.\n\t */\n\tadvanceWidth: number;\n}\n\n// Constants -------------------------------------------------------------------------\n\n/**\n * Height of every glyph cell in the atlas in pixels.\n * The font is rendered at {@link FONT_SIZE} px inside this cell.\n */\nconst CELL_HEIGHT = 64;\n\n/** Font size used when rendering glyphs onto the atlas canvas. */\nconst FONT_SIZE = Math.round(CELL_HEIGHT * 0.8);\n\nconst FONT_FAMILY = 'sans-serif';\n\n/**\n * Fraction of the glyph cell height that lies below the alphabetic baseline.\n * For typical sans-serif: ascent ≈ 0.8 × FONT_SIZE, descent ≈ 0.2 × FONT_SIZE.\n * The baseline sits (ascent − descent) / 2 = 0.3 × FONT_SIZE below the em midpoint,\n * so the fraction of the cell below the baseline is 0.5 − 0.3 × (FONT_SIZE / CELL_HEIGHT).\n */\nconst ATLAS_DESCENDER_FRACTION = 0.5 - 0.3 * (FONT_SIZE / CELL_HEIGHT); // ≈ 0.26\n\n/**\n * Fraction of the glyph cell height from the em-midpoint (drawing y) up to the top of a digit glyph.\n * For typical sans-serif: cap height ≈ 0.72 × FONT_SIZE; baseline is 0.3 × FONT_SIZE below the midpoint,\n * so the distance from midpoint to digit top ≈ (0.72 − 0.3) × FONT_SIZE = 0.42 × FONT_SIZE.\n * As a fraction of CELL_HEIGHT: 0.42 × (FONT_SIZE / CELL_HEIGHT).\n */\nconst ATLAS_ASCENT_FRACTION = 0.42 * (FONT_SIZE / CELL_HEIGHT);\n\n/**\n * Horizontal padding (pixels) added on each side of a glyph cell to prevent\n * UV bleeding between adjacent cells at low resolutions / with mipmaps.\n */\nconst CELL_PADDING = 2;\n\n/**\n * Target atlas width in pixels. Must be a power of two.\n * With ~96 glyphs at an average advance width of ~38 px (+ 2 px padding),\n * each row holds ≈ 12 glyphs, and all glyphs fit inside a 512 × 512 atlas.\n */\nconst ATLAS_WIDTH = 512;\n\n/**\n * The Unicode replacement character (U+FFFD '?'). Rendered whenever\n * {@link render} encounters a character that is not in the atlas.\n */\nconst REPLACEMENT_CHAR = '\\uFFFD';\n\n/**\n * All characters pre-rendered into the atlas.\n *\n * Printable ASCII 0x20–0x7E (95 chars) followed by the replacement character\n * so that every out-of-range character has a visible fallback glyph.\n */\nconst SUPPORTED_CHARS: string[] = [\n\t...Array.from({ length: 95 }, (_, i) => String.fromCharCode(i + 0x20)),\n\tREPLACEMENT_CHAR,\n];\n\n// Variables -------------------------------------------------------------------------\n\n/** WebGL texture for the glyph atlas. Lazily initialised on first use. Takes ~1 ms. */\nlet atlasTexture: WebGLTexture | undefined;\n\n/**\n * Per-character metrics table.\n * Keys are individual characters; values describe where that glyph lives in the atlas.\n */\nlet metricsTable: Map<string, GlyphMetrics> | undefined;\n\n// Functions -------------------------------------------------------------------------\n\n/** Returns the next integer that is a power of two and ≥ `n`. */\nfunction nextPowerOfTwo(n: number): number {\n\tif (n <= 1) return 1;\n\tlet p = 1;\n\twhile (p < n) p <<= 1;\n\treturn p;\n}\n\n/**\n * Builds the glyph atlas: measures every supported character, packs the glyphs\n * into rows, draws them onto a Canvas 2D, uploads the result as a WebGL texture,\n * and populates {@link metricsTable}.\n */\nfunction initGlyphAtlas(): void {\n\t// ── 1. Measure every glyph ──────────────────────────────────────────────\n\tconst measureCanvas = document.createElement('canvas');\n\tmeasureCanvas.width = ATLAS_WIDTH;\n\tmeasureCanvas.height = CELL_HEIGHT;\n\tconst mCtx = measureCanvas.getContext('2d');\n\tif (!mCtx) throw new Error('Could not get 2D context for glyph measurement.');\n\n\t/** Font string passed to Canvas 2D context. */\n\tconst FONT_STRING = `${FONT_SIZE}px ${FONT_FAMILY}`;\n\n\tmCtx.font = FONT_STRING;\n\n\t/** Cell width (px) for each character, including padding on both sides. */\n\tconst cellWidths: number[] = SUPPORTED_CHARS.map((ch) => {\n\t\tconst measured = mCtx.measureText(ch).width;\n\t\treturn Math.ceil(measured) + CELL_PADDING * 2;\n\t});\n\n\t// ── 2. Pack glyphs into rows ─────────────────────────────────────────────\n\tinterface GlyphPlacement {\n\t\tchar: string;\n\t\t/** Pixel X of left edge of cell (including left padding) */\n\t\tcellX: number; //\n\t\t/** Pixel Y of top edge of cell (row top, canvas-space, y-down) */\n\t\tcellY: number;\n\t\tcellWidth: number;\n\t}\n\n\tconst placements: GlyphPlacement[] = [];\n\tlet cursorX = 0;\n\tlet cursorY = 0;\n\tlet numRows = 1;\n\n\tfor (let i = 0; i < SUPPORTED_CHARS.length; i++) {\n\t\tconst cw = cellWidths[i]!;\n\n\t\tif (cursorX + cw > ATLAS_WIDTH) {\n\t\t\t// Start a new row.\n\t\t\tcursorX = 0;\n\t\t\tcursorY += CELL_HEIGHT;\n\t\t\tnumRows++;\n\t\t}\n\n\t\tplacements.push({\n\t\t\tchar: SUPPORTED_CHARS[i]!,\n\t\t\tcellX: cursorX,\n\t\t\tcellY: cursorY,\n\t\t\tcellWidth: cw,\n\t\t});\n\n\t\tcursorX += cw;\n\t}\n\n\tconst atlasHeight = nextPowerOfTwo(numRows * CELL_HEIGHT);\n\n\t// ── 3. Draw all glyphs onto the atlas canvas ─────────────────────────────\n\tconst atlasCanvas = document.createElement('canvas');\n\tatlasCanvas.width = ATLAS_WIDTH;\n\tatlasCanvas.height = atlasHeight;\n\tconst ctx = atlasCanvas.getContext('2d');\n\tif (!ctx) throw new Error('Could not get 2D context for glyph atlas.');\n\n\tctx.clearRect(0, 0, ATLAS_WIDTH, atlasHeight);\n\tctx.fillStyle = 'white';\n\tctx.textBaseline = 'middle';\n\tctx.font = FONT_STRING;\n\n\t// Build the metrics table while drawing.\n\tconst table = new Map<string, GlyphMetrics>();\n\n\tfor (const p of placements) {\n\t\t// Draw glyph centred within its cell (excluding padding).\n\t\tconst drawX = p.cellX + CELL_PADDING;\n\t\tconst drawY = p.cellY + CELL_HEIGHT / 2;\n\t\tctx.fillText(p.char, drawX, drawY);\n\n\t\t// UV coordinates: (0,0) = bottom-left after UNPACK_FLIP_Y_WEBGL.\n\t\t// Canvas Y increases downward; flipping maps canvasY → (atlasHeight - canvasY).\n\t\t// Inset by CELL_PADDING so UVs reference only the inner glyph pixels, not the padding border.\n\t\tconst u0 = (p.cellX + CELL_PADDING) / ATLAS_WIDTH;\n\t\tconst u1 = (p.cellX + p.cellWidth - CELL_PADDING) / ATLAS_WIDTH;\n\t\t// Cell top in flipped space is the larger V value.\n\t\tconst v0 = (atlasHeight - (p.cellY + CELL_HEIGHT)) / atlasHeight;\n\t\tconst v1 = (atlasHeight - p.cellY) / atlasHeight;\n\n\t\t// advanceWidth is the inner glyph width (without padding) relative to cell height.\n\t\tconst innerWidth = p.cellWidth - CELL_PADDING * 2;\n\t\tconst advanceWidth = innerWidth / CELL_HEIGHT;\n\n\t\ttable.set(p.char, { u0, v0, u1, v1, advanceWidth });\n\t}\n\n\t// ── 4. Upload to GPU ─────────────────────────────────────────────────────\n\tconst texture = gl.createTexture();\n\tif (!texture) throw new Error('Failed to create glyph atlas WebGL texture.');\n\n\tgl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);\n\tgl.bindTexture(gl.TEXTURE_2D, texture);\n\tgl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlasCanvas);\n\tgl.generateMipmap(gl.TEXTURE_2D);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n\t// CLAMP_TO_EDGE prevents UV bleeding at the atlas borders.\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n\tgl.bindTexture(gl.TEXTURE_2D, null);\n\n\tatlasTexture = texture;\n\tmetricsTable = table;\n\n\t// DEBUG: Uncomment to log atlas dimensions and append the canvas to the document for visual inspection.\n\t// console.log(\n\t// \t`[glyphatlas] Atlas generated: ${ATLAS_WIDTH} × ${atlasHeight} px, ${numRows} row(s), ${SUPPORTED_CHARS.length} glyphs.`,\n\t// );\n\t// atlasCanvas.style.cssText =\n\t// \t'position:fixed;bottom:0;right:0;background:#888;z-index:9999;border:2px solid red;';\n\t// document.body.appendChild(atlasCanvas);\n}\n\n// API -------------------------------------------------------------------------\n\n/**\n * Returns the WebGL texture of the glyph atlas.\n *\n * Lazily initialises the atlas on first call, which takes ~1 ms.\n */\nfunction getAtlasTexture(): WebGLTexture {\n\tif (atlasTexture === undefined) initGlyphAtlas();\n\treturn atlasTexture!;\n}\n\n/**\n * Returns the {@link GlyphMetrics} for `char`, or the replacement\n * character U+FFFD if the character is not present in the atlas.\n *\n * Lazily initialises the atlas on first call.\n */\nfunction getGlyphMetrics(char: string): GlyphMetrics {\n\tif (metricsTable === undefined) initGlyphAtlas();\n\treturn metricsTable!.get(char) ?? metricsTable!.get(REPLACEMENT_CHAR)!; // fallback to replacement char for unsupported glyphs\n}\n\n// Exports -------------------------------------------------------------------------\n\nexport { getAtlasTexture, getGlyphMetrics, ATLAS_DESCENDER_FRACTION, ATLAS_ASCENT_FRACTION };\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/text/textrenderer.ts",
    "content": "// src/client/scripts/esm/game/rendering/text/textrenderer.ts\n\n/**\n * This script renders arbitrary strings in world space.\n *\n * Each character is rendered as a textured quad whose height equals `size`\n * world-space units and whose width is `size × advanceWidth` — where\n * `advanceWidth` is the per-glyph ratio measured at atlas-generation time.\n */\n\nimport type { Color } from '../../../../../../shared/util/math/math.js';\nimport type { DoubleCoords } from '../../../../../../shared/chess/util/coordutil.js';\nimport type { DoubleBoundingBox } from '../../../../../../shared/util/math/bounds.js';\n\nimport primitives from '../primitives.js';\nimport { createRenderable } from '../../../webgl/Renderable.js';\nimport {\n\tgetAtlasTexture,\n\tgetGlyphMetrics,\n\tATLAS_ASCENT_FRACTION,\n\tATLAS_DESCENDER_FRACTION,\n} from './glyphatlas.js';\n\n// Functions -------------------------------------------------------------------------\n\n/**\n * Computes the total world-space width of `text` when rendered at the given `size`.\n * Unsupported characters are treated as if they were the replacement character.\n */\nfunction getTextWidth(text: string, size: number): number {\n\tlet width = 0;\n\tfor (const char of text) {\n\t\tconst m = getGlyphMetrics(char);\n\t\twidth += size * m.advanceWidth;\n\t}\n\treturn width;\n}\n\n/**\n * Computes the world-space axis-aligned bounding box of `text` when rendered at the given parameters.\n * The bottom edge is at the alphabetic baseline rather than the bottom of the cell,\n * so the invisible descender space below the baseline is excluded.\n * @param text - The string to measure.\n * @param coords - World-space [x, y] of the anchor point, positioned according to `align`.\n * @param size - World-space height of each character.\n * @param align - Horizontal alignment relative to `coords[0]`.\n */\nfunction getTextBounds(\n\ttext: string,\n\tcoords: DoubleCoords,\n\tsize: number,\n\talign: 'left' | 'center' | 'right',\n): DoubleBoundingBox {\n\tconst totalWidth = getTextWidth(text, size);\n\n\tlet left: number;\n\tif (align === 'left') left = coords[0];\n\telse if (align === 'center') left = coords[0] - totalWidth / 2;\n\telse left = coords[0] - totalWidth; // 'right'\n\n\treturn {\n\t\tleft,\n\t\tright: left + totalWidth,\n\t\t// Exclude the descender space: bottom is the alphabetic baseline, not the cell bottom.\n\t\tbottom: coords[1] - size * (0.5 - ATLAS_DESCENDER_FRACTION),\n\t\t// Use the measured cap height of a digit so the top aligns with the visible top of numbers.\n\t\ttop: coords[1] + size * ATLAS_ASCENT_FRACTION,\n\t};\n}\n\n/**\n * Renders a text string.\n * @param text - The string to render.\n * @param coords - World-space [x, y] of the anchor point.\n *                 `x` is positioned according to `align`; `y` is the vertical centre.\n * @param size - World-space height of each character.\n * @param color - RGBA tint applied to all characters.\n * @param align - Horizontal alignment relative to `coords[0]`.\n */\nfunction render(\n\ttext: string,\n\tcoords: DoubleCoords,\n\tsize: number,\n\tcolor: Color,\n\talign: 'left' | 'center' | 'right',\n): void {\n\tif (text.length === 0) return;\n\n\tconst totalWidth = getTextWidth(text, size);\n\n\t// Compute world-space X of the left edge of the first character.\n\tlet cursorX: number;\n\tif (align === 'left') cursorX = coords[0];\n\telse if (align === 'center') cursorX = coords[0] - totalWidth / 2;\n\telse cursorX = coords[0] - totalWidth; // 'right'\n\n\t// Vertical extents are constant for all glyphs (text is vertically centred on y).\n\tconst bottom = coords[1] - size / 2;\n\tconst top = coords[1] + size / 2;\n\n\tconst data: number[] = [];\n\n\tfor (const char of text) {\n\t\tconst m = getGlyphMetrics(char);\n\n\t\tconst quadWidth = size * m.advanceWidth;\n\t\tconst left = cursorX;\n\t\tconst right = cursorX + quadWidth;\n\n\t\tdata.push(\n\t\t\t// prettier-ignore\n\t\t\t...primitives.Quad_ColorTexture(left, bottom, right, top, m.u0, m.v0, m.u1, m.v1, ...color),\n\t\t);\n\n\t\tcursorX += quadWidth;\n\t}\n\n\tcreateRenderable(data, 2, 'TRIANGLES', 'colorTexture', true, getAtlasTexture()).render();\n}\n\n// Exports -------------------------------------------------------------------------\n\nexport default { getTextWidth, getTextBounds, render };\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/transitions/Transition.ts",
    "content": "// src/client/scripts/esm/game/rendering/transitions/Transition.ts\n\n/**\n * This handles the smooth transitioning from one area of the board to another.\n *\n * There are two types of transitions:\n *\n * Panning Transition - Quicker, doesn't zoom at all, teleports at the halfway t value so it can\n * span arbitrary distances in constant time.\n *\n * Zooming Transition - Slower. For varying differences in scale, it uses different\n * models with varying stages. The goal is to perform the entire transition\n * within a constant duration, while still feeling smooth and natural.\n */\n\nimport type { BoundingBox, BoundingBoxBD } from '../../../../../../shared/util/math/bounds.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport math from '../../../../../../shared/util/math/math.js';\nimport coordutil, {\n\tBDCoords,\n\tCoords,\n\tDoubleCoords,\n} from '../../../../../../shared/chess/util/coordutil.js';\n\nimport space from '../../misc/space.js';\nimport meshes from '../meshes.js';\nimport boardpos from '../boardpos.js';\nimport boarddrag from '../boarddrag.js';\nimport boardtiles from '../boardtiles.js';\nimport perspective from '../perspective.js';\nimport { GameBus } from '../../GameBus.js';\nimport preferences from '../../../components/header/preferences.js';\nimport area, { Area } from '../area.js';\n\n// Types ---------------------------------------------------------------------------------\n\n/** Main Transition type. Either Zooming OR Panning. */\ntype Transition =\n\t| (ZoomTransition & {\n\t\t\t/** Whether this is a Zooming Transition, vs a Panning one. Panning transitions don't need a destination scale. */\n\t\t\tisZoom: true;\n\t  })\n\t| (PanTransition & {\n\t\t\tisZoom: false;\n\t  });\n\nexport type ZoomTransition = {\n\t/** The destination board location. */\n\tdestinationCoords: BDCoords;\n\t/** The destination board location. */\n\tdestinationScale: BigDecimal;\n};\n\ntype PanTransition = {\n\t/** The destination board location. */\n\tdestinationCoords: BDCoords;\n};\n\n// Constants ----------------------------------------------------------------------\n\n/** The maximum number of transitions we will retain in our history, for undoing transitions. */\nconst HISTORY_CAP = 20;\n\n/** Stores config for Panning Transitions. */\nconst PAN_TRANSITION_CONFIG = {\n\t/** Duration of ALL Panning Transitions. */\n\tget DURATION_MILLIS() {\n\t\treturn preferences.getFastTransitionsMode() ? 500 : 800;\n\t},\n\t/**\n\t * The maximum distance a Panning Transition will travel before\n\t * teleporting mid-transition to reach its destination in constant time,\n\t * in world space units (not affected by board scale).\n\t */\n\tget MAX_PAN_DISTANCE() {\n\t\treturn preferences.getFastTransitionsMode() ? 45 : 90;\n\t},\n} as const;\n\n/** Stores config for Zooming Transitions. */\nconst ZOOM_TRANSITION_CONFIG = {\n\t/** The minimum duration any zooming transition must take. */\n\tget MIN_DURATION() {\n\t\treturn preferences.getFastTransitionsMode() ? 400 : 600;\n\t},\n\t/** The maximum duration any zooming transition can take. */\n\tget MAX_DURATION() {\n\t\treturn preferences.getFastTransitionsMode() ? 1200 : 3500;\n\t},\n\t/** In perspective mode we apply a multiplier so the transition goes a tad slower. */\n\tDURATION_PERSPECTIVE_MULTIPLIER: 1.3,\n\t/** The \"comfortable\" acceleration used for the start and end of the 2 & 3 stage models. */\n\tget EDGE_ACCELERATION() {\n\t\treturn preferences.getFastTransitionsMode() ? 80.0 : 40.0;\n\t},\n\t/** How the total duration of the 3-Stage Model is split between them. MUST sum to 1.0. */\n\tSTAGE_SPLIT: {\n\t\tACCELERATE: 0.25, // 25% of time accelerating scale\n\t\tCRUISE: 0.5, // 50% arbitrarily fast scale change\n\t\tDECELERATE: 0.25, // 25% decelerating scale\n\t},\n} as const;\n\nconst ONE = bd.fromBigInt(1n);\nconst NEGONE = bd.fromBigInt(-1n);\n\n// Variables ----------------------------------------------------------------------\n\nconst teleportHistory: Transition[] = [];\n\n// State --------------------------------------------------------------------------\n\n// The state of the current transition\n\n/** Whether we're currently transitioning. */\nlet isTransitioning: boolean = false;\n/**\n * If defined, then after the current transition is\n * finished, we should immediately start this transition.\n *\n * This should be defined for transitions which first require us to\n * zoom out to fit everything on screen before zooming back into them.\n */\nlet nextTransition: ZoomTransition | undefined;\n\n/** Precalculated total duration of the current transition. */\nlet durationMillis: number;\n\nlet startTime: number;\n/** Whether the current transition is a Zooming Transition, vs a Panning Transition. */\nlet isZoom: boolean;\n/**\n * If the current transition is a Zooming Transition, this is whether\n * the destination scale requires us to zoom out to get there.\n */\nlet isZoomOut: boolean;\n\n// Shared State\n\n/** The origin/start coords of the current transition. */\nlet originCoords: BDCoords;\n/** The destination coords. */\nlet destinationCoords: BDCoords;\n\n/** The origin/start scale of the current transition. */\nlet originScale: BigDecimal;\n/** The destination scale. */\nlet destinationScale: BigDecimal;\n/** The logarithm of the origin scale. */\nlet originE: number;\n/** The logarithm of the destination scale. */\nlet destinationE: number;\n/** Precalculated difference between the current transition's origin and destination scale's \"e\" value. */\nlet differenceE: number;\n\n// Pan-specific State\n\n/**\n * If the current transition is a Panning Transition, this is the precalculated\n * difference between the current transition's origin and destination coords.\n */\nlet differenceCoords: BDCoords;\n\n// Zoom-specific State, pre-calculated\n\n/** [ESTIMATION] If the current transition is a Zooming Transition, this is the origin world space coords. */\nlet originWorldSpace: DoubleCoords;\n/** If the current transition is a Zooming Transition, this is the destination world space coords. */\nlet destinationWorldSpace: DoubleCoords;\n/** Precalculated difference between the current transition's calculated origin and destination world space coords. */\nlet differenceWorldSpace: DoubleCoords;\n\n/**\n * Which kinematic model to use for the current long zoom transition.\n *\n * - C_INF: C-infinity, 1-stage model (shortest duration, smoothest).\n *   Used if its natural duration fits within the cap transition duration.\n *\n * - C_ONE_2_STAGE: C¹, velocity-continuous, 2-stage model.\n *   Used if C_INF would take too long (4e36), but this model fits within the cap duration.\n *   Without this fallback model, C_ONE_3_STAGE at specific zooms would have to\n *   accelerate, decelerate, accelerate, then decelerate again, which feels bad.\n *\n * - C_ONE_3_STAGE: C¹, velocity-continuous, 3-stage model with fixed duration.\n *   Used if both other models would take too long (4e54).\n *   Compresses the potentially arbitrarily large scale difference into stage 2.\n */\nlet zoomModel: 'C_INF' | 'C_ONE_2_STAGE' | 'C_ONE_3_STAGE';\nlet stageEndTimes: { stage1: number; stage2: number; stage3: number };\n\n// C-infinity model state\nlet initial_accel_c_inf: number;\nlet jerk_c_inf: number;\n\n// C¹ models state\nlet accel_stage1: number;\nlet accel_stage2: number;\nlet e_at_stage1_end: number;\nlet v_at_stage1_end: number;\nlet e_at_stage2_mid: number;\nlet v_at_stage2_mid: number;\nlet e_at_stage2_end: number;\nlet v_at_stage2_end: number;\n\n// Events ---------------------------------------------------------------------------\n\nGameBus.addEventListener('game-unloaded', () => {\n\teraseTelHist();\n});\n\n// Initiating Transitions ---------------------------------------------------------------------\n\n/** Sets common variables between starting either a Zooming or Panning Transition. */\nfunction onTransitionStart(): void {\n\tisTransitioning = true;\n\tstartTime = Date.now();\n\toriginCoords = boardpos.getBoardPos();\n\toriginScale = boardpos.getBoardScale();\n\n\tboardpos.eraseMomentum(); // Reset velocities to zero\n\tboarddrag.cancelBoardDrag(); // We don't want to allow dragging during a transition.\n}\n\n/** Starts a Zooming Transition. */\nfunction startZoomTransition(\n\ttel1: ZoomTransition,\n\ttel2: ZoomTransition | undefined,\n\tignoreHistory: boolean,\n): void {\n\tonTransitionStart();\n\n\tnextTransition = tel2;\n\n\tdestinationCoords = tel1.destinationCoords;\n\tdestinationScale = tel1.destinationScale;\n\toriginE = bd.ln(originScale); // We're using base E\n\tdestinationE = bd.ln(destinationScale);\n\tdifferenceE = destinationE - originE;\n\n\tisZoom = true;\n\tisZoomOut = bd.compare(destinationScale, originScale) < 0;\n\n\t// Determine world coordinates\n\tif (isZoomOut) {\n\t\toriginWorldSpace = [0, 0];\n\t\tdestinationWorldSpace = space.convertCoordToWorldSpace(\n\t\t\toriginCoords,\n\t\t\tdestinationCoords,\n\t\t\tdestinationScale,\n\t\t);\n\t} else {\n\t\t// Is a zoom-in\n\t\toriginWorldSpace = space.convertCoordToWorldSpace(destinationCoords);\n\t\tdestinationWorldSpace = [0, 0];\n\t}\n\tdifferenceWorldSpace = coordutil.subtractDoubleCoords(destinationWorldSpace, originWorldSpace);\n\n\t// Perspective duration multiplier\n\tconst perspectiveMultiplier = perspective.getEnabled()\n\t\t? ZOOM_TRANSITION_CONFIG.DURATION_PERSPECTIVE_MULTIPLIER\n\t\t: 1;\n\tconst maxDuration = ZOOM_TRANSITION_CONFIG.MAX_DURATION * perspectiveMultiplier;\n\tconst edgeAccel = ZOOM_TRANSITION_CONFIG.EDGE_ACCELERATION / perspectiveMultiplier;\n\n\t// Determine which model to use by checking each profile's\n\t// natural duration (excludes base duration or capping) in order.\n\n\t// C-infinity model natural duration if capped at our comfortable EDGE_ACCELERATION.\n\tconst natural_duration_c_inf_millis = Math.sqrt(Math.abs((6 * differenceE) / edgeAccel)) * 1000;\n\t// C¹ 2-stage model natural duration, if capped at our comfortable EDGE_ACCELERATION.\n\tconst natural_duration_c_one_millis = Math.sqrt(Math.abs(differenceE / edgeAccel)) * 2 * 1000;\n\n\tif (natural_duration_c_inf_millis <= maxDuration)\n\t\tsetupCInfinityModel(natural_duration_c_inf_millis, maxDuration);\n\telse if (natural_duration_c_one_millis <= maxDuration)\n\t\tsetupCOne2StageModel(natural_duration_c_one_millis, edgeAccel);\n\telse setupCOne3StageModel(edgeAccel, maxDuration); // Both other models would take too long. Use the fixed-duration 3-stage profile.\n\n\t// console.log(\"Duration: \" + durationMillis + \"ms\");\n\n\tif (!ignoreHistory)\n\t\tpushToTelHistory({\n\t\t\tisZoom,\n\t\t\tdestinationCoords: boardpos.getBoardPos(),\n\t\t\tdestinationScale: boardpos.getBoardScale(),\n\t\t});\n}\n\n/** Sets up the C-Infinity 1-Stage Model for the current zoom transition. */\nfunction setupCInfinityModel(natural_duration_c_inf_millis: number, maxDuration: number): void {\n\t// console.log('Using C-Infinity 1-Stage Model');\n\tzoomModel = 'C_INF';\n\n\t// Add the base duration to the natural duration, and cap at the long zoom duration.\n\tdurationMillis = Math.max(ZOOM_TRANSITION_CONFIG.MIN_DURATION, natural_duration_c_inf_millis);\n\tdurationMillis = Math.min(durationMillis, maxDuration);\n\tconst T = durationMillis / 1000; // Final duration in seconds\n\n\t// Based on this final duration, solve for the required initial acceleration and jerk.\n\tif (T > 0) {\n\t\tinitial_accel_c_inf = (6 * differenceE) / (T * T);\n\t\tjerk_c_inf = (-2 * initial_accel_c_inf) / T; // Jerk is constant throughout\n\t} else {\n\t\tinitial_accel_c_inf = 0;\n\t\tjerk_c_inf = 0;\n\t}\n}\n\n/** Sets up the C¹ 2-Stage Model for the current zoom transition. */\nfunction setupCOne2StageModel(natural_duration_c_one_millis: number, edgeAccel: number): void {\n\t// --- CASE B: C¹ 2-STAGE MODEL (Velocity Continuous) ---\n\t// console.log('Using C¹ 2-Stage Model');\n\tzoomModel = 'C_ONE_2_STAGE';\n\n\tdurationMillis = natural_duration_c_one_millis;\n\n\taccel_stage1 = Math.sign(differenceE) * edgeAccel;\n\tconst t_half_secs = durationMillis / 2000;\n\n\tstageEndTimes = {\n\t\tstage1: t_half_secs * 1000,\n\t\t// Not used, but set for consistency\n\t\tstage2: durationMillis,\n\t\tstage3: durationMillis,\n\t};\n\n\t// Pre-calculate boundary conditions for the handoff.\n\tv_at_stage1_end = accel_stage1 * t_half_secs;\n\te_at_stage1_end = originE + 0.5 * accel_stage1 * t_half_secs * t_half_secs;\n}\n\n/** Sets up the C¹ 3-Stage Model for the current zoom transition. */\nfunction setupCOne3StageModel(edgeAccel: number, maxDuration: number): void {\n\t// --- CASE C: 3-STAGE MODEL ---\n\t// console.log('Using C¹ 3-Stage Model');\n\tzoomModel = 'C_ONE_3_STAGE';\n\n\tdurationMillis = maxDuration;\n\n\tconst t1 = (durationMillis * ZOOM_TRANSITION_CONFIG.STAGE_SPLIT.ACCELERATE) / 1000;\n\tconst t2 = (durationMillis * ZOOM_TRANSITION_CONFIG.STAGE_SPLIT.CRUISE) / 1000;\n\tconst t_s2_half = t2 / 2;\n\n\tstageEndTimes = {\n\t\tstage1: t1 * 1000,\n\t\tstage2: (t1 + t2) * 1000,\n\t\tstage3: durationMillis,\n\t};\n\n\t// Set Stage 1 acceleration and determine the distance it covers.\n\t// The direction of acceleration depends on the direction of the zoom.\n\taccel_stage1 = Math.sign(differenceE) * edgeAccel;\n\n\t// Distance covered in Stage 1 & 3 is determined by the fixed edge acceleration.\n\t// Using d = v₀t + 0.5at², where v₀=0 for stage 1. Stage 3 is symmetrical.\n\tconst dist_stage1_and_3 = accel_stage1 * t1 * t1;\n\n\t// Calculate the remaining distance that must be covered in Stage 2.\n\tconst remaining_dist = differenceE - dist_stage1_and_3;\n\n\t// Solve for the Stage 2 acceleration needed to cover that remaining distance.\n\t// We use the formula: d = v₀t + 0.5at²\n\t// For the first half of stage 2, v₀ is the velocity at the end of stage 1.\n\tv_at_stage1_end = accel_stage1 * t1;\n\t// The distance for the first half of stage 2 is remaining_dist / 2.\n\t// (remaining_dist / 2) = v_at_stage1_end * t_s2_half + 0.5 * a₂ * t_s2_half²\n\t// Rearranging to solve for a₂:\n\taccel_stage2 = (remaining_dist - 2 * v_at_stage1_end * t_s2_half) / (t_s2_half * t_s2_half);\n\n\tconst edgeAccelPositive = Math.sign(differenceE) === 1;\n\tif ((edgeAccelPositive && accel_stage2 < 0) || (!edgeAccelPositive && accel_stage2 > 0)) {\n\t\tconsole.warn('Calculated stage 2 acceleration has the wrong sign: ' + accel_stage2);\n\t}\n\n\t// Pre-calculate all boundary conditions to use in the update loop.\n\te_at_stage1_end = originE + 0.5 * dist_stage1_and_3;\n\tv_at_stage2_mid = v_at_stage1_end + accel_stage2 * t_s2_half;\n\te_at_stage2_mid =\n\t\te_at_stage1_end + v_at_stage1_end * t_s2_half + 0.5 * accel_stage2 * t_s2_half * t_s2_half;\n\n\t// By symmetry of the C¹ model within Stage 2, velocity at the end is guaranteed to match velocity at the start.\n\tv_at_stage2_end = v_at_stage1_end;\n\te_at_stage2_end = e_at_stage1_end + remaining_dist; // By definition\n}\n\n/** Starts a Panning Transition. */\nfunction startPanTransition(endCoord: BDCoords, ignoreHistory: boolean): void {\n\tonTransitionStart();\n\n\tdestinationCoords = endCoord;\n\tdifferenceCoords = coordutil.subtractBDCoords(destinationCoords, originCoords);\n\tdestinationScale = originScale;\n\n\tisZoom = false;\n\n\tdurationMillis = PAN_TRANSITION_CONFIG.DURATION_MILLIS;\n\n\tif (!ignoreHistory) pushToTelHistory({ isZoom, destinationCoords: boardpos.getBoardPos() });\n}\n\n/**\n * Starts a Zooming Transition to an integer bounding box.\n * If an intermediate zoom-out is needed first, it will be done.\n */\nfunction zoomToCoordsBox(box: BoundingBox): void {\n\tconst boxFloating = meshes.expandTileBoundingBoxToEncompassWholeSquare(box);\n\tconst thisArea = area.calculateFromUnpaddedBox(boxFloating);\n\tarea.initTransitionFromArea(thisArea, false);\n}\n\n/**\n * Starts a Zooming Transition to a list of coordinates.\n * Will not incur an intermediate transition if all coords are not on screen originally.\n */\nfunction singleZoomToCoordsList(coordsList: Coords[]): void {\n\tconst transitionArea: Area = area.calculateFromCoordsList(coordsList);\n\tzoomTransitionToArea(transitionArea);\n}\n\n/**\n * Starts a Zooming Transition to floating point coords location.\n * Will not incur an intermediate transition if it is not on screen originally.\n */\nfunction singleZoomToBDCoords(coords: BDCoords): void {\n\tconst snapBoundingBox: BoundingBoxBD = {\n\t\tleft: coords[0],\n\t\tright: coords[0],\n\t\tbottom: coords[1],\n\t\ttop: coords[1],\n\t};\n\tconst boxFloating: BoundingBoxBD =\n\t\tmeshes.expandTileBoundingBoxToEncompassWholeSquareBD(snapBoundingBox);\n\tconst transitionArea: Area = area.calculateFromUnpaddedBox(boxFloating);\n\tzoomTransitionToArea(transitionArea);\n}\n\n/**\n * Starts a Zooming Transition to a predefined Area.\n *\n * Will not incur a following transition if the area is not on screen.\n */\nfunction zoomTransitionToArea(theArea: Area): void {\n\tconst trans: ZoomTransition = {\n\t\tdestinationCoords: theArea.coords,\n\t\tdestinationScale: theArea.scale,\n\t};\n\tstartZoomTransition(trans, undefined, false);\n}\n\n/** Appends the given transition to the history. */\nfunction pushToTelHistory(trans: Transition): void {\n\tteleportHistory.push(trans);\n\tif (teleportHistory.length > HISTORY_CAP) teleportHistory.shift(); // Trim excess\n}\n\n/** Undos the last transition by transitioning to that transition's  */\nfunction undoTransition(): void {\n\tconst previousTrans = teleportHistory.pop();\n\tif (!previousTrans) return; // Nothing in history\n\n\tif (previousTrans.isZoom) {\n\t\t// Zooming Transition\n\t\tconst thisArea: Area = {\n\t\t\tcoords: previousTrans.destinationCoords,\n\t\t\tscale: previousTrans.destinationScale,\n\t\t\tboundingBox: boardtiles.getBoundingBoxOfBoard(\n\t\t\t\tpreviousTrans.destinationCoords,\n\t\t\t\tpreviousTrans.destinationScale,\n\t\t\t),\n\t\t};\n\t\tarea.initTransitionFromArea(thisArea, true);\n\t} else {\n\t\t// Panning transition\n\t\tstartPanTransition(previousTrans.destinationCoords, true);\n\t}\n}\n\n// Updating --------------------------------------------------------------------------------------\n\n/** If we are currently transitioning, this updates the board position and scale. */\nfunction update(): void {\n\tif (!isTransitioning) return; // Not transitioning\n\n\tconst elapsedTime = Date.now() - startTime;\n\tif (elapsedTime >= durationMillis) {\n\t\tfinishTransition();\n\t\treturn;\n\t}\n\n\tif (isZoom) {\n\t\t// Zooming Transition\n\t\tupdateZoomingTransition(elapsedTime);\n\t} else {\n\t\t// Panning Transition\n\t\tconst t = elapsedTime / durationMillis; // 0-1 elapsed time (t) value\n\t\tconst easedT = math.easeInOut(t);\n\t\tupdatePanningTransition(t, easedT, originCoords, destinationCoords, differenceCoords);\n\t}\n}\n\n/**\n * Handles the kinematic update logic for all zoom transitions.\n */\nfunction updateZoomingTransition(elapsedTime: number): void {\n\tconst t_sec = elapsedTime / 1000;\n\tlet currentE: number;\n\n\tif (zoomModel === 'C_INF') currentE = updateCInfinityTransition(t_sec);\n\telse if (zoomModel === 'C_ONE_2_STAGE')\n\t\tcurrentE = updateCOne2StageTransition(t_sec, elapsedTime);\n\telse currentE = updateCOne3StageTransition(t_sec, elapsedTime);\n\n\tapplyZoomState(currentE, elapsedTime);\n}\n\n/** Calculates the current \"e\" value for the current C-Infinity 1-Stage Model zoom transition. */\nfunction updateCInfinityTransition(t_sec: number): number {\n\t// Position with constant jerk is given by the cubic formula:\n\t// e(t) = e₀ + v₀t + 0.5a₀t² + (1/6)jt³\n\t// Since e₀ and v₀ are 0 relative to the start:\n\treturn (\n\t\toriginE +\n\t\t0.5 * initial_accel_c_inf * t_sec * t_sec +\n\t\t(1 / 6) * jerk_c_inf * t_sec * t_sec * t_sec\n\t);\n}\n\n/** Calculates the current \"e\" value for the current C¹ 2-Stage Model zoom transition. */\nfunction updateCOne2StageTransition(t_sec: number, elapsedTime: number): number {\n\tif (elapsedTime <= stageEndTimes.stage1) {\n\t\t// Stage 1: Accelerate\n\t\tconst t = t_sec;\n\t\treturn originE + 0.5 * accel_stage1 * t * t;\n\t} else {\n\t\t// Stage 2: Symmetrical Decelerate\n\t\tconst t_s2 = t_sec - stageEndTimes.stage1 / 1000;\n\t\treturn e_at_stage1_end + v_at_stage1_end * t_s2 - 0.5 * accel_stage1 * t_s2 * t_s2;\n\t}\n}\n\n/** Calculates the current \"e\" value for the current C¹ 3-Stage Model zoom transition. */\nfunction updateCOne3StageTransition(t_sec: number, elapsedTime: number): number {\n\tif (elapsedTime <= stageEndTimes.stage1) {\n\t\t// STAGE 1: Constant positive acceleration\n\t\t// console.log(\"Stage 1\");\n\t\tconst t = t_sec;\n\t\treturn originE + 0.5 * accel_stage1 * t * t;\n\t} else if (elapsedTime <= stageEndTimes.stage2) {\n\t\t// STAGE 2: Higher acceleration, then symmetrical deceleration.\n\t\t// console.log(\"Stage 2\");\n\t\tconst t_s2 = t_sec - stageEndTimes.stage1 / 1000;\n\t\tconst t_s2_half = (stageEndTimes.stage2 - stageEndTimes.stage1) / 2000;\n\t\tif (t_s2 <= t_s2_half) {\n\t\t\t// First half of Stage 2: Constant acceleration\n\t\t\treturn e_at_stage1_end + v_at_stage1_end * t_s2 + 0.5 * accel_stage2 * t_s2 * t_s2;\n\t\t} else {\n\t\t\t// Second half of Stage 2: Symmetrical constant deceleration\n\t\t\tconst t_s2_b = t_s2 - t_s2_half; // Time into the second half\n\t\t\treturn (\n\t\t\t\te_at_stage2_mid + v_at_stage2_mid * t_s2_b - 0.5 * accel_stage2 * t_s2_b * t_s2_b\n\t\t\t);\n\t\t}\n\t} else {\n\t\t// STAGE 3: Constant negative acceleration (symmetrical to stage 1)\n\t\t// console.log(\"Stage 3\");\n\t\tconst t_s3 = t_sec - stageEndTimes.stage2 / 1000;\n\t\treturn e_at_stage2_end + v_at_stage2_end * t_s3 - 0.5 * accel_stage1 * t_s3 * t_s3;\n\t}\n}\n\n/** Applies the current board scale and position based on the given \"e\" value and focus point. */\nfunction applyZoomState(currentE: number, elapsedTime: number): void {\n\t// This focus point location logic is identical for all models.\n\tconst focus: BDCoords = isZoomOut ? originCoords : destinationCoords;\n\n\tlet scaleProgress = 0;\n\tif (differenceE !== 0) {\n\t\t// Normal case: Tie focus point progress to the scale's kinematic progress.\n\t\tscaleProgress = (currentE - originE) / differenceE;\n\t} else {\n\t\t// If there is no scale change, this is a pure pan.\n\t\t// Tie focus point progress to time instead.\n\t\t// Fixes a bug where zooms with equal start and end scale don't pan the position.\n\t\tconst t = elapsedTime / durationMillis;\n\t\tscaleProgress = math.easeInOut(t); // Use a standard ease-in-out for the pan.\n\t}\n\n\t// Apply the final scale and position to the board.\n\tconst newScale = bd.exp(bd.fromNumber(currentE));\n\tboardpos.setBoardScale(newScale);\n\n\t// Calculate and set the new board position, based on where the focus point should be.\n\t// SEE GRAPH ON DESMOS \"World-space converted to boardPos\" for my notes while writing this algorithm\n\n\tconst worldX = bd.fromNumber(originWorldSpace[0] + differenceWorldSpace[0] * scaleProgress);\n\tconst worldY = bd.fromNumber(originWorldSpace[1] + differenceWorldSpace[1] * scaleProgress);\n\n\t// Convert the world-space offset to a board-space offset\n\tconst shiftX = bd.divideFloating(worldX, newScale);\n\tconst shiftY = bd.divideFloating(worldY, newScale);\n\n\t// Apply the shift to the target coordinates to get the new board position\n\tconst newX = bd.subtract(focus[0], shiftX);\n\tconst newY = bd.subtract(focus[1], shiftY);\n\n\tboardpos.setBoardPos([newX, newY]);\n}\n\n/** Updates the board position and scale for the current PANNING Transition. */\nfunction updatePanningTransition(\n\tt: number,\n\teasedT: number,\n\toriginCoords: BDCoords,\n\tdestinationCoords: BDCoords,\n\tdifferenceCoords: BDCoords,\n): void {\n\t// What is the scale?\n\t// What is the maximum distance we should pan b4 teleporting to the other half?\n\tconst boardScale = boardpos.getBoardScale();\n\tconst maxPanDist = bd.fromNumber(PAN_TRANSITION_CONFIG.MAX_PAN_DISTANCE);\n\tconst maxDistSquares = bd.divideFloating(maxPanDist, boardScale);\n\tconst transGreaterThanMaxDist =\n\t\tbd.compare(bd.abs(differenceCoords[0]), maxDistSquares) > 0 ||\n\t\tbd.compare(bd.abs(differenceCoords[1]), maxDistSquares) > 0;\n\n\tlet newX: BigDecimal;\n\tlet newY: BigDecimal;\n\n\tconst difference = coordutil.copyBDCoords(differenceCoords);\n\tconst easedTBD = bd.fromNumber(easedT);\n\n\tif (!transGreaterThanMaxDist) {\n\t\t// No mid-transition teleport required to maintain constant duration.\n\t\t// Calculate new world-space\n\t\tconst addX = bd.multiply(difference[0], easedTBD);\n\t\tconst addY = bd.multiply(difference[1], easedTBD);\n\t\t// Convert to board position\n\t\tnewX = bd.add(originCoords[0], addX);\n\t\tnewY = bd.add(originCoords[1], addY);\n\t} else {\n\t\t// Mid-transition teleport REQUIRED to maintain constant duration.\n\t\t// 1st half or 2nd half?\n\t\tconst firstHalf = t < 0.5;\n\t\tconst neg = firstHalf ? ONE : NEGONE;\n\t\tconst actualEasedT = bd.fromNumber(firstHalf ? easedT : 1 - easedT);\n\n\t\t// Create a new, shorter vector that points in the exact same direction,\n\t\t// but with a length that is visually manageable on screen.\n\n\t\t// To preserve the vector's direction, we must scale it based on its largest component.\n\t\tconst absDiffX = bd.abs(difference[0]);\n\t\tconst absDiffY = bd.abs(difference[1]);\n\t\tconst maxComponent = bd.max(absDiffX, absDiffY);\n\n\t\tconst ratio = bd.divideFloating(maxDistSquares, maxComponent);\n\n\t\tdifference[0] = bd.multiplyFloating(difference[0], ratio);\n\t\tdifference[1] = bd.multiplyFloating(difference[1], ratio);\n\n\t\tconst target = firstHalf ? originCoords : destinationCoords;\n\n\t\tconst addX = bd.multiplyFloating(bd.multiplyFloating(difference[0], actualEasedT), neg);\n\t\tconst addY = bd.multiplyFloating(bd.multiplyFloating(difference[1], actualEasedT), neg);\n\n\t\tnewX = bd.add(target[0], addX);\n\t\tnewY = bd.add(target[1], addY);\n\t}\n\n\tboardpos.setBoardPos([newX, newY]);\n}\n\n/** Sets the board position & scale to the destination of the current transition, and ends the transition. */\nfunction finishTransition(): void {\n\t// Called at the end of a teleport\n\t// Set the final coords and scale\n\tboardpos.setBoardPos(destinationCoords);\n\tboardpos.setBoardScale(destinationScale);\n\n\tif (nextTransition)\n\t\tstartZoomTransition(nextTransition, undefined, true); // true to ignore history for the second part of a two-step zoom\n\telse isTransitioning = false;\n}\n\n// Utility ------------------------------------------------------------------------------\n\n/** Whether we are currently transitioning.  */\nfunction areTransitioning(): boolean {\n\treturn isTransitioning;\n}\n\n/** Erases teleport history. */\nfunction eraseTelHist(): void {\n\tteleportHistory.length = 0;\n}\n\n/** Cancels the current transition. */\nfunction terminate(): void {\n\t// Clear current transition state\n\tisTransitioning = false;\n\tnextTransition = undefined;\n}\n\n// Exports ------------------------------------------------------------------------------\n\nexport default {\n\t// Initiating Transitions\n\tareTransitioning,\n\tstartZoomTransition,\n\tstartPanTransition,\n\tzoomToCoordsBox,\n\tsingleZoomToCoordsList,\n\tsingleZoomToBDCoords,\n\tundoTransition,\n\t// Updating\n\tupdate,\n\t// Utility\n\teraseTelHist,\n\tterminate,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/rendering/webgl.ts",
    "content": "// src/client/scripts/esm/game/rendering/webgl.ts\n\nimport type { Vec3 } from '../../../../../shared/util/math/vectors.js';\n\nimport camera from './camera.js';\n\n/**\n * This script stores our global WebGL rendering context,\n * and other utility methods.\n */\n\n/** The WebGL rendering context. This is our web-based render engine. */\nlet gl: WebGL2RenderingContext; // The WebGL context. Is initiated in initGL()\n\n/**\n * The color the screen should be cleared to every frame.\n * This can be changed to give the sky a different color.\n */\nlet clearColor: Vec3 = [0.5, 0.5, 0.5]; // Grey\n\n/**\n * Specifies the condition under which a fragment passes the depth test,\n * determining whether it should be drawn based on its depth value\n * relative to the existing depth buffer values.\n *\n * By default, we want objects rendered to only be visible if they are closer\n * (less than) or equal to other objects already rendered this frame. The gl\n * depth function can be changed throughout the run, but we always reset it\n * back to this default afterward.\n *\n * Accepted values: `NEVER`, `LESS`, `EQUAL`, `LEQUAL`, `GREATER`, `NOTEQUAL`, `GEQUAL`, `ALWAYS`\n */\nconst defaultDepthFuncParam = 'LEQUAL';\n\n/**\n * Whether to cull (skip) rendering back faces.\n * We can prevent the rasteurizer from calculating pixels on faces facing AWAY from us with backface culling.\n *\n * IF WE AREN'T CAREFUL about all vertices going into the same clockwise/counterclockwise\n * direction, then some objects will be invisible!\n */\nconst culling = false;\n/**\n * If true, whether a face is determined as a front face depends\n * on whether it's vertices move in a clockwise direction, otherwise counterclockwise.\n */\nconst frontFaceVerticesAreClockwise = true;\n\n/**\n * Sets the color the screen will be cleared to every frame.\n *\n * This is useful for changing the sky color.\n * @param newClearColor - The new clear color: `[r,g,b]`\n */\nfunction setClearColor(newClearColor: Vec3): void {\n\tclearColor = newClearColor;\n}\n\n/**\n * Initiate the WebGL context. This is our web-based render engine.\n */\nfunction init(): void {\n\t// Without alpha in the options, shading yields incorrect colors! This removes the alpha component of the back buffer.\n\tconst newContext = camera.canvas.getContext('webgl2', {\n\t\talpha: false,\n\t\tstencil: true,\n\t\tpreserveDrawingBuffer: true, // Reduces likelihood of context lost?\n\t}); // Stencil required for masking world border stuff\n\tif (!newContext) {\n\t\t// WebGL2 not supported\n\t\talert(translations.webgl_unsupported);\n\t\tthrow new Error('WebGL2 not supported by browser.');\n\t\t// gl = camera.canvas.getContext('webgl', { alpha: false });\n\t}\n\t// if (!gl) { // Init WebGL experimental\n\t// \tconsole.log(\"Browser doesn't support WebGL-1, falling back on experiment-webgl.\");\n\t// \tgl = camera.canvas.getContext('experimental-webgl', { alpha: false});\n\t// }\n\t// if (!gl) { // Experimental also failed to init\n\t// \talert(translations.webgl_unsupported);\n\t// \tthrow new Error(\"WebGL not supported.\");\n\t// }\n\n\tgl = newContext;\n\n\tgl.clearDepth(1.0); // Set the clear depth value\n\tclearScreen();\n\n\tgl.enable(gl.DEPTH_TEST);\n\tgl.depthFunc(gl[defaultDepthFuncParam]);\n\n\tgl.enable(gl.BLEND);\n\ttoggleNormalBlending();\n\n\tif (culling) {\n\t\tgl.enable(gl.CULL_FACE);\n\t\tconst dir = frontFaceVerticesAreClockwise ? gl.CW : gl.CCW;\n\t\tgl.frontFace(dir); // Specifies what faces are considered front, depending on their vertices direction.\n\t\tgl.cullFace(gl.BACK); // Skip rendering back faces. Alertnatively we could skip rendering FRONT faces.\n\t}\n\n\tgl.clearStencil(0); // Good practice, although 0 is the default\n}\n\n/**\n * Clears color buffer and depth buffers.\n * Needs to be called every frame.\n */\nfunction clearScreen(): void {\n\tgl.clearColor(...clearColor, 1.0);\n\tgl.stencilMask(0xff); // Ensure all stencil bits are writable before clearing.\n\tgl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);\n}\n\n/**\n * Toggles normal blending mode. Transparent objects will correctly have\n * their color shaded onto the color behind them.\n */\nfunction toggleNormalBlending(): void {\n\t// Non-premultiplied alpha blending mode. (Pre-multiplied would be gl.ONE, gl.ONE_MINUS_SRC_ALPHA)\n\tgl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);\n}\n\n/**\n * Toggles inverse blending mode, which will negate any color currently in the buffer.\n *\n * This is useful for rendering crosshairs, because they will appear black on white backgrounds,\n * and white on black backgrounds.\n */\nfunction enableBlending_Inverse(): void {\n\tgl.blendFunc(gl.ONE_MINUS_DST_COLOR, gl.ZERO);\n}\n\n/**\n * Executes a function (typically a render function) while the depth function paramter\n * is `ALWAYS`. Objects will be rendered no matter if they are behind or on top of other objects.\n * This is useful for preventing tearing when objects are on the same z-level in perspective.\n */\nfunction executeWithDepthFunc_ALWAYS(func: Function): void {\n\t// This prevents tearing when rendering in the same z-level and in perspective.\n\tgl.depthFunc(gl.ALWAYS); // Temporary toggle the depth function to ALWAYS.\n\tfunc();\n\tgl.depthFunc(gl[defaultDepthFuncParam]); // Return to the original blending.\n}\n\n/**\n * Executes a function (typically a render function) while inverse blending is enabled.\n * Objects rendered will take the opposite color of what's currently in the buffer.\n *\n * This is useful for rendering crosshairs, because they will appear black on white backgrounds,\n * and white on black backgrounds.\n */\nfunction executeWithInverseBlending(func: Function): void {\n\tenableBlending_Inverse();\n\tfunc();\n\ttoggleNormalBlending();\n}\n\n// /**\n//  * Queries common WebGL context values and logs them to the console.\n//  * Each user device may have different supported values.\n//  */\n// function queryWebGLContextInfo() {\n// \t// Create a canvas and attempt to get WebGL 2 context, fallback to WebGL 1 if unavailable\n// \tconst canvas = document.createElement('canvas');\n// \tconst gl = canvas.getContext('webgl2') || canvas.getContext('webgl');  // WebGL 2 if available, otherwise WebGL 1\n\n// \tif (!gl) {\n// \t\tconsole.error('WebGL is not supported in this browser.');\n// \t} else {\n// \t\tconsole.log(gl instanceof WebGL2RenderingContext ? 'WebGL 2 is supported' : 'WebGL 1 is supported');\n\n// \t\tconst params = [\n// \t\t\t{ name: 'MAX_TEXTURE_SIZE', desc: 'Maximum texture size', guaranteed: 64 },\n// \t\t\t{ name: 'MAX_CUBE_MAP_TEXTURE_SIZE', desc: 'Maximum cube map texture size', guaranteed: 16 },\n// \t\t\t{ name: 'MAX_RENDERBUFFER_SIZE', desc: 'Maximum renderbuffer size', guaranteed: 1 },\n// \t\t\t{ name: 'MAX_TEXTURE_IMAGE_UNITS', desc: 'Maximum texture units for fragment shader', guaranteed: 8 },\n// \t\t\t{ name: 'MAX_VERTEX_TEXTURE_IMAGE_UNITS', desc: 'Maximum texture units for vertex shader', guaranteed: 0 },\n// \t\t\t{ name: 'MAX_COMBINED_TEXTURE_IMAGE_UNITS', desc: 'Maximum combined texture units', guaranteed: 8 },\n// \t\t\t{ name: 'MAX_VERTEX_ATTRIBS', desc: 'Maximum vertex attributes', guaranteed: 8 },\n// \t\t\t{ name: 'MAX_VERTEX_UNIFORM_VECTORS', desc: 'Maximum vertex uniform vectors', guaranteed: 128 },\n// \t\t\t{ name: 'MAX_FRAGMENT_UNIFORM_VECTORS', desc: 'Maximum fragment uniform vectors', guaranteed: 16 },\n// \t\t\t{ name: 'MAX_VARYING_VECTORS', desc: 'Maximum varying vectors', guaranteed: 8 },\n// \t\t\t{ name: 'MAX_VIEWPORT_DIMS', desc: 'Maximum viewport dimensions', guaranteed: [0, 0] },\n// \t\t\t{ name: 'ALIASED_POINT_SIZE_RANGE', desc: 'Aliased point size range', guaranteed: [1, 1] },\n// \t\t\t{ name: 'ALIASED_LINE_WIDTH_RANGE', desc: 'Aliased line width range', guaranteed: [1, 1] },\n// \t\t\t{ name: 'MAX_VERTEX_UNIFORM_COMPONENTS', desc: 'Maximum vertex uniform components', guaranteed: 1024 },\n// \t\t\t{ name: 'MAX_FRAGMENT_UNIFORM_COMPONENTS', desc: 'Maximum fragment uniform components', guaranteed: 1024 },\n// \t\t\t{ name: 'MAX_VERTEX_OUTPUT_COMPONENTS', desc: 'Maximum vertex output components', guaranteed: 64 },\n// \t\t\t{ name: 'MAX_FRAGMENT_INPUT_COMPONENTS', desc: 'Maximum fragment input components', guaranteed: 60 },\n// \t\t\t{ name: 'MAX_DRAW_BUFFERS', desc: 'Maximum draw buffers', guaranteed: 4 },\n// \t\t\t{ name: 'MAX_COLOR_ATTACHMENTS', desc: 'Maximum color attachments', guaranteed: 4 },\n// \t\t\t{ name: 'MAX_SAMPLES', desc: 'Maximum samples', guaranteed: 4 }\n// \t\t];\n\n// \t\t// Output WebGL Context Information\n// \t\tconsole.log('WebGL Context Information:');\n// \t\tparams.forEach(param => {\n// \t\t\ttry {\n// \t\t\t\tconst value = gl.getParameter(gl[param.name]);\n// \t\t\t\tconsole.log(`${param.desc}:`, value, `(Guaranteed: ${param.guaranteed})`);\n// \t\t\t} catch (e) {\n// \t\t\t\tconsole.warn(`Error fetching ${param.name}:`, e.message);\n// \t\t\t}\n// \t\t});\n// \t}\n\n// \t// Shortened version:\n\n// \t// Create a canvas and attempt to get WebGL 2 context, fallback to WebGL 1 if unavailable\n// \t// const canvas = document.createElement('canvas');\n// \t// const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');  // WebGL 2 if available, otherwise WebGL 1\n\n// \t// if (!gl) {\n// \t// \tconsole.error('WebGL not supported.');\n// \t// } else {\n// \t// \tconsole.log(gl instanceof WebGL2RenderingContext ? 'WebGL 2' : 'WebGL 1');\n\n// \t// \tconst params = [\n// \t// \t\t{ name: 'MAX_TEXTURE_SIZE', guaranteed: 64 },\n// \t// \t\t{ name: 'MAX_CUBE_MAP_TEXTURE_SIZE', guaranteed: 16 },\n// \t// \t\t{ name: 'MAX_RENDERBUFFER_SIZE', guaranteed: 1 },\n// \t// \t\t{ name: 'MAX_TEXTURE_IMAGE_UNITS', guaranteed: 8 },\n// \t// \t\t{ name: 'MAX_VERTEX_TEXTURE_IMAGE_UNITS', guaranteed: 0 },\n// \t// \t\t{ name: 'MAX_COMBINED_TEXTURE_IMAGE_UNITS', guaranteed: 8 },\n// \t// \t\t{ name: 'MAX_VERTEX_ATTRIBS', guaranteed: 8 },\n// \t// \t\t{ name: 'MAX_VERTEX_UNIFORM_VECTORS', guaranteed: 128 },\n// \t// \t\t{ name: 'MAX_FRAGMENT_UNIFORM_VECTORS', guaranteed: 16 },\n// \t// \t\t{ name: 'MAX_VARYING_VECTORS', guaranteed: 8 },\n// \t// \t\t{ name: 'MAX_VIEWPORT_DIMS', guaranteed: [0, 0] },\n// \t// \t\t{ name: 'ALIASED_POINT_SIZE_RANGE', guaranteed: [1, 1] },\n// \t// \t\t{ name: 'ALIASED_LINE_WIDTH_RANGE', guaranteed: [1, 1] },\n// \t// \t\t{ name: 'MAX_VERTEX_UNIFORM_COMPONENTS', guaranteed: 1024 },\n// \t// \t\t{ name: 'MAX_FRAGMENT_UNIFORM_COMPONENTS', guaranteed: 1024 },\n// \t// \t\t{ name: 'MAX_VERTEX_OUTPUT_COMPONENTS', guaranteed: 64 },\n// \t// \t\t{ name: 'MAX_FRAGMENT_INPUT_COMPONENTS', guaranteed: 60 },\n// \t// \t\t{ name: 'MAX_DRAW_BUFFERS', guaranteed: 4 },\n// \t// \t\t{ name: 'MAX_COLOR_ATTACHMENTS', guaranteed: 4 },\n// \t// \t\t{ name: 'MAX_SAMPLES', guaranteed: 4 }\n// \t// \t];\n\n// \t// \tparams.forEach(param => {\n// \t// \t\ttry {\n// \t// \t\t\tconst value = gl.getParameter(gl[param.name]);\n// \t// \t\t\tconsole.log(`${param.name}: ${value}, G: ${param.guaranteed}`);\n// \t// \t\t} catch (e) {\n// \t// \t\t\tconsole.warn(`Error on ${param.name}`);\n// \t// \t\t}\n// \t// \t});\n// \t// }\n// }\n\n/**\n * Enables depth testing in WebGL.\n * This will ensure that objects closer to the camera are drawn in front of objects farther away.\n */\nfunction enableDepthTest(): void {\n\tgl.enable(gl.DEPTH_TEST);\n}\n\n/**\n * Disables depth testing in WebGL.\n * This will ensure that all objects are drawn regardless of their distance from the camera.\n * More efficient that setting the depth test condition to gl.ALWAYS\n */\nfunction disableDepthTest(): void {\n\tgl.disable(gl.DEPTH_TEST);\n}\n\nexport default {\n\tinit,\n\tclearScreen,\n\texecuteWithDepthFunc_ALWAYS,\n\texecuteWithInverseBlending,\n\tsetClearColor,\n\t// queryWebGLContextInfo,\n\tenableDepthTest,\n\tdisableDepthTest,\n};\n\nexport { gl };\n"
  },
  {
    "path": "src/client/scripts/esm/game/websocket/socketclose.ts",
    "content": "// src/client/scripts/esm/game/websocket/socketclose.ts\n\n/**\n * Handles websocket close events and reconnection logic.\n *\n * Determines the appropriate response to different closure reasons,\n * including reconnection, timeout, and user notification.\n */\n\nimport wsutil from '../../../../../shared/util/wsutil.js';\n\nimport toast from '../gui/toast.js';\nimport config from '../config.js';\nimport invites from '../misc/invites.js';\nimport socketman from './socketman.js';\nimport socketsubs from './socketsubs.js';\nimport validatorama from '../../util/validatorama.js';\nimport socketmessages from './socketmessages.js';\n\n// Constants -------------------------------------------------------------------\n\n/** Time before attempting resub after too many requests. */\nconst timeToResubAfterTooManyRequestsMillis = 10000;\n/** Time before attempting resub after message too big. */\nconst timeToResubAfterMessageTooBigMillis = 5000;\n\n// Variables -------------------------------------------------------------------\n\nlet inTimeout = false;\n\n/**\n * The last time the server closed our socket connection request because\n * we were missing a browser-id cookie, in millis since the Unix Epoch.\n */\nlet lastTimeWeGotAuthorizationNeededMessage: number | undefined;\n\n/** Returns whether we're currently in a rate-limit timeout. */\nfunction isInTimeout(): boolean {\n\treturn inTimeout;\n}\n\n// Close Handler ---------------------------------------------------------------\n\n/**\n * Called when our open socket fires the 'close' event.\n * Cancels echo timers and on-reply functions, then handles reconnection\n * based on the closure reason.\n * @param event - The 'close' event fired.\n * @param socketWasDefined - Whether the socket was fully open before closing.\n */\nfunction onclose(event: CloseEvent, socketWasDefined: boolean): void {\n\tif (config.DEV_BUILD) console.log('WebSocket connection closed:', event.code, event.reason);\n\n\tsocketmessages.cancelAllEchoTimers();\n\tsocketmessages.cancelInactivityTimer();\n\tsocketmessages.resetOnreplyFuncs();\n\n\tconst trimmedReason = event.reason.trim();\n\tconst notByChoice = wsutil.wasSocketClosureNotByTheirChoice(event.code, trimmedReason);\n\n\t/**\n\t * True if we want to show the loading animation.\n\t * If closed not by our choice, but with no subscriptions, close the ping meter anyway.\n\t */\n\tconst detail = notByChoice && !socketsubs.zeroSubs();\n\tdocument.dispatchEvent(new CustomEvent('socket-closed', { detail }));\n\n\t// Connection closed unexpectedly (network interrupted) or server is down.\n\t// We did nothing wrong on our part, it's okay to instantly try to reconnect!\n\t// But don't if the connection wasn't fully open or this creates spamming!\n\tif (event.code === 1006) {\n\t\tif (socketWasDefined) socketman.resubAll();\n\t\treturn;\n\t}\n\n\tswitch (trimmedReason) {\n\t\tcase 'Connection expired':\n\t\t\tsocketman.resubAll();\n\t\t\tbreak;\n\t\tcase 'Connection closed by client':\n\t\t\tbreak;\n\t\tcase 'Connection closed by client. Renew.':\n\t\t\tconsole.log('Closed web socket successfully. Renewing now..');\n\t\t\tsocketman.resubAll();\n\t\t\tbreak;\n\t\tcase 'Unable to identify client IP address':\n\t\t\ttoast.show(\n\t\t\t\t`${translations.websocket.unable_to_identify_ip} ${translations.websocket.please_report_bug}`,\n\t\t\t\t{ error: true, durationMultiplier: 100 },\n\t\t\t);\n\t\t\tinvites.clearIfOnPlayPage();\n\t\t\tbreak;\n\t\tcase 'Authentication needed':\n\t\t\tonAuthenticationNeeded();\n\t\t\tbreak;\n\t\tcase 'Logged out':\n\t\t\tdocument.dispatchEvent(new CustomEvent('logout'));\n\t\t\tsocketman.resubAll();\n\t\t\tbreak;\n\t\tcase 'Too Many Requests. Try again soon.':\n\t\t\ttoast.show(translations.websocket.too_many_requests, {\n\t\t\t\tdurationMillis: timeToResubAfterTooManyRequestsMillis,\n\t\t\t});\n\t\t\tenterTimeout(timeToResubAfterTooManyRequestsMillis);\n\t\t\tbreak;\n\t\tcase 'Message Too Big':\n\t\t\ttoast.show(\n\t\t\t\t`${translations.websocket.message_too_big} ${translations.websocket.please_report_bug}`,\n\t\t\t\t{ error: true, durationMultiplier: 3 },\n\t\t\t);\n\t\t\tenterTimeout(timeToResubAfterMessageTooBigMillis);\n\t\t\tbreak;\n\t\tcase 'Too Many Sockets':\n\t\t\ttoast.show(\n\t\t\t\t`${translations.websocket.too_many_sockets} ${translations.websocket.please_report_bug}`,\n\t\t\t\t{ error: true, durationMultiplier: 3 },\n\t\t\t);\n\t\t\twindow.setTimeout(() => socketman.resubAll(), timeToResubAfterTooManyRequestsMillis);\n\t\t\tbreak;\n\t\tcase 'Origin Error':\n\t\t\ttoast.show(\n\t\t\t\t`${translations.websocket.origin_error} ${translations.websocket.please_report_bug}`,\n\t\t\t\t{ error: true, durationMultiplier: 3 },\n\t\t\t);\n\t\t\tinvites.clearIfOnPlayPage();\n\t\t\tenterTimeout(timeToResubAfterTooManyRequestsMillis);\n\t\t\tbreak;\n\t\tcase 'No echo heard':\n\t\t\tsocketman.dispatchLostConnectionCustomEvent();\n\t\t\tsocketman.resubAll();\n\t\t\tbreak;\n\t\tdefault:\n\t\t\ttoast.show(\n\t\t\t\t`${translations.websocket.connection_closed} \"${trimmedReason}\". Code: ${event.code}. ${translations.websocket.please_report_bug}`,\n\t\t\t\t{ error: true, durationMultiplier: 100 },\n\t\t\t);\n\t\t\tconsole.error(\n\t\t\t\t'Unknown reason why the WebSocket connection was closed. Not reopening or resubscribing.',\n\t\t\t);\n\t}\n}\n\n// Timeout Management ----------------------------------------------------------\n\n/**\n * Enters a rate-limit timeout period during which we won't reconnect.\n * @param timeMillis - The duration to remain in timeout, in milliseconds.\n */\nfunction enterTimeout(timeMillis: number): void {\n\tif (timeMillis === undefined)\n\t\treturn console.error('Cannot enter timeout for an undefined amount of time!');\n\tif (inTimeout) return;\n\tinTimeout = true;\n\twindow.setTimeout(() => leaveTimeout(), timeMillis);\n\tinvites.clearIfOnPlayPage();\n}\n\n/** Timeout from sending too many requests is over, try to reconnect. */\nfunction leaveTimeout(): void {\n\tinTimeout = false;\n\tsocketman.resubAll();\n}\n\n// Authentication Handling -----------------------------------------------------\n\n/**\n * Called when the server closes our websocket due to missing authentication.\n * Attempts to refresh the browser-id cookie and reconnect.\n */\nasync function onAuthenticationNeeded(): Promise<void> {\n\tinvites.clearIfOnPlayPage();\n\n\t// If this is the second time we're getting this message,\n\t// that means that cookies aren't working on this browser.\n\tconst now = Date.now();\n\tif (lastTimeWeGotAuthorizationNeededMessage !== undefined) {\n\t\tconst difference = now - lastTimeWeGotAuthorizationNeededMessage;\n\t\t// 24 hours\n\t\tif (difference < 1000 * 60 * 60 * 24) {\n\t\t\ttoast.show(translations.websocket.online_play_disabled);\n\t\t\tlastTimeWeGotAuthorizationNeededMessage = now;\n\t\t\treturn;\n\t\t}\n\t}\n\tlastTimeWeGotAuthorizationNeededMessage = now;\n\n\tawait validatorama.refreshToken();\n\tsocketman.resubAll();\n}\n\n// Exports -------------------------------------------------------------------\n\nexport default {\n\tonclose,\n\tisInTimeout,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/websocket/socketman.ts",
    "content": "// src/client/scripts/esm/game/websocket/socketman.ts\n\n/**\n * Manages the websocket connection lifecycle: opening, closing,\n * reconnecting, and resubscribing after unexpected disconnections.\n * Also owns the socket instance and debug toggle.\n */\n\nimport toast from '../gui/toast.js';\nimport config from '../config.js';\nimport thread from '../../util/thread.js';\nimport invites from '../misc/invites.js';\nimport onlinegame from '../misc/onlinegame/onlinegame.js';\nimport socketsubs from './socketsubs.js';\nimport socketclose from './socketclose.js';\nimport validatorama from '../../util/validatorama.js';\nimport socketrouter from './socketrouter.js';\n\n// Constants -------------------------------------------------------------------\n\n/** Time to wait for HTTP connection before assuming lost connection. */\nconst TIME_TO_WAIT_FOR_HTTP_MILLIS = 5000;\n/**\n * Delays in milliseconds between reconnection attempts after network loss.\n * The first element is used before the first attempt (0 = instant),\n * and the last element repeats indefinitely.\n */\nconst RECONNECT_DELAY_MILLIS = [0, 2500, 5000] as const;\n\n// Variables -------------------------------------------------------------------\n\n/** The websocket object used to communicate with the server. */\nlet socket: WebSocket | undefined;\n/** True if currently attempting to create a socket connection. */\nlet openingSocket = false;\n/**\n * The timeout ID of the timer to display lost connection\n * if we don't hear back after attempting to open a socket.\n */\nlet reqOut: false | number = false;\n/**\n * True if we are having trouble connecting. If true, and we reconnect,\n * we'll display \"Reconnected.\"\n */\nlet noConnection = false;\n\n/** Enables simulated websocket latency and prints all sent and received messages. */\nlet DEBUG = false;\n\n// Initialization --------------------------------------------------------------\n\ndocument.addEventListener('connection-lost', () => {\n\t// Displays a toast, notifying the user they lost connection.\n\tnoConnection = true;\n\ttoast.show(translations.websocket.no_connection, {\n\t\tdurationMillis: TIME_TO_WAIT_FOR_HTTP_MILLIS,\n\t});\n});\n\n// Page navigation handling\nwindow.addEventListener('pageshow', function (event) {\n\tif (event.persisted) {\n\t\tconsole.log('Page was returned to using the back or forward button.');\n\t\tresubAll();\n\t}\n});\n\n// Debug -----------------------------------------------------------------------\n\n/** Returns whether debug mode is enabled. */\nfunction isDebugEnabled(): boolean {\n\treturn DEBUG;\n}\n\n/** Toggles debug mode on or off, showing a toast notification. */\nfunction toggleDebug(): void {\n\tDEBUG = !DEBUG;\n\ttoast.show(`Toggled websocket latency: ${DEBUG}`);\n}\n\n// Socket Access ---------------------------------------------------------------\n\n/** Returns the current websocket instance, or undefined if not connected. */\nfunction getSocket(): WebSocket | undefined {\n\treturn socket;\n}\n\n// Connection Events -----------------------------------------------------------\n\n/** Dispatches a custom event indicating that websocket connection was lost. */\nfunction dispatchLostConnectionCustomEvent(): void {\n\tdocument.dispatchEvent(new CustomEvent('connection-lost'));\n}\n\n// Socket Lifecycle ------------------------------------------------------------\n\n/**\n * Repeatedly tries to open a websocket to the server until successful,\n * unless we are in timeout. Never opens more than one socket at a time.\n * @returns Whether a socket was successfully opened.\n */\nasync function establishSocket(): Promise<boolean> {\n\tif (socketclose.isInTimeout()) return false;\n\n\twhile (openingSocket || (socket && socket.readyState !== WebSocket.OPEN)) {\n\t\tif (config.DEV_BUILD) console.log('Waiting for the socket to be established or closed..');\n\t\tawait thread.sleep(100);\n\t}\n\tif (socket && socket.readyState === WebSocket.OPEN) return true;\n\n\topeningSocket = true;\n\n\t// Await validatorama because it may be refreshing our session cookies\n\tawait validatorama.waitUntilInitialRequestBack();\n\n\tlet success = false;\n\tlet attemptIndex = 0;\n\n\t// Always attempt at least once (even with zero subs), then retry while subs exist.\n\tdo {\n\t\tconst cappedAttemptIndex = Math.min(attemptIndex, RECONNECT_DELAY_MILLIS.length - 1);\n\t\tconst delay = RECONNECT_DELAY_MILLIS[cappedAttemptIndex]!;\n\t\tif (attemptIndex > 0) {\n\t\t\tnoConnection = true;\n\t\t\ttoast.show(translations.websocket.no_connection, {\n\t\t\t\tdurationMillis: TIME_TO_WAIT_FOR_HTTP_MILLIS,\n\t\t\t});\n\t\t\tinvites.clearIfOnPlayPage();\n\t\t\tawait thread.sleep(delay);\n\t\t}\n\t\tsuccess = await openSocket();\n\t\tattemptIndex++;\n\t} while (!success && !socketsubs.zeroSubs());\n\n\tif (success && noConnection)\n\t\ttoast.show(translations.websocket.reconnected, { durationMillis: 1000 });\n\tnoConnection = false;\n\n\topeningSocket = false;\n\treturn success;\n}\n\n/**\n * Attempts to open our websocket to the server.\n * @returns Whether the socket was opened successfully.\n */\nasync function openSocket(): Promise<boolean> {\n\tonSocketUpgradeReqLeave();\n\treturn new Promise((resolve, _reject) => {\n\t\tlet url = `wss://${window.location.hostname}`;\n\t\tif (window.location.port !== '443') url += `:${window.location.port}`;\n\t\tconst ws = new WebSocket(url);\n\t\tws.onopen = () => {\n\t\t\tonReqBack();\n\t\t\tsocket = ws;\n\t\t\tresolve(true);\n\t\t};\n\t\tws.onerror = (_event) => {\n\t\t\tonReqBack();\n\t\t\tresolve(false);\n\t\t};\n\t\tws.onmessage = (event: MessageEvent) => socketrouter.onmessage(event);\n\t\tws.onclose = (event: CloseEvent) => {\n\t\t\tconst wasFullyOpen = socket !== undefined;\n\t\t\tsocket = undefined;\n\t\t\tsocketclose.onclose(event, wasFullyOpen);\n\t\t};\n\t});\n}\n\n/**\n * Dispatches a socket-opening event and starts a timer\n * that assumes lost connection if no response arrives.\n */\nfunction onSocketUpgradeReqLeave(): void {\n\t// Dispatches a custom event indicating that a socket connection is being opened.\n\tdocument.dispatchEvent(new CustomEvent('socket-opening'));\n\treqOut = window.setTimeout(() => httpLostConnection(), TIME_TO_WAIT_FOR_HTTP_MILLIS);\n}\n\n/** Cancels the timer that assumes lost connection. */\nfunction onReqBack(): void {\n\tif (typeof reqOut !== 'boolean') clearTimeout(reqOut);\n\treqOut = false;\n}\n\n/** Displays \"Lost connection\" and keeps repeating until we successfully connect. */\nfunction httpLostConnection(): void {\n\tnoConnection = true;\n\ttoast.show(translations.websocket.no_connection, {\n\t\tdurationMillis: TIME_TO_WAIT_FOR_HTTP_MILLIS,\n\t});\n\treqOut = window.setTimeout(() => httpLostConnection(), TIME_TO_WAIT_FOR_HTTP_MILLIS);\n}\n\n/** Closes the socket. Called when it's no longer in use (no active subscriptions). */\nfunction closeSocket(): void {\n\tif (!socket) return;\n\tif (socket.readyState !== WebSocket.OPEN)\n\t\treturn console.error(\"Cannot close socket because it's not open! Yet socket is defined.\");\n\tsocket.close(1000, 'Connection closed by client');\n}\n\n// Resubscription --------------------------------------------------------------\n\n/**\n * Called when the socket unexpectedly closes. Reopens the socket\n * and resubscribes to everything that was previously subscribed.\n */\nasync function resubAll(): Promise<void> {\n\tif (config.DEV_BUILD) console.log('Resubbing all..');\n\n\tif (socketsubs.zeroSubs()) {\n\t\tnoConnection = false;\n\t\tconsole.log('No subs to sub to.');\n\t\treturn;\n\t} else {\n\t\tif (!(await establishSocket())) return;\n\t}\n\n\tfor (const sub of socketsubs.validSubs) {\n\t\tif (!socketsubs.areSubbedToSub(sub)) continue;\n\t\tswitch (sub) {\n\t\t\tcase 'invites':\n\t\t\t\tawait invites.subscribeToInvites(true);\n\t\t\t\tbreak;\n\t\t\tcase 'game':\n\t\t\t\tonlinegame.resyncToGame();\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tconsole.error(\n\t\t\t\t\t`Cannot resub to all subs after an unexpected socket closure with strange sub ${sub}!`,\n\t\t\t\t);\n\t\t}\n\t}\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\tgetSocket,\n\testablishSocket,\n\tcloseSocket,\n\tresubAll,\n\ttoggleDebug,\n\tisDebugEnabled,\n\tdispatchLostConnectionCustomEvent,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/websocket/socketmessages.ts",
    "content": "// src/client/scripts/esm/game/websocket/socketmessages.ts\n\n/**\n * Handles outgoing websocket messages, echo tracking, and on-reply functions.\n */\n\nimport uuid from '../../../../../shared/util/uuid.js';\nimport wsutil from '../../../../../shared/util/wsutil.js';\n\nimport toast from '../gui/toast.js';\nimport socketman from './socketman.js';\nimport socketsubs from './socketsubs.js';\n\n// Types -----------------------------------------------------------------------\n\ntype MessageID = number;\n\ntype WebsocketMessageValue = MessageEvent['data'];\n\n/** The shape of an outgoing websocket payload sent to the server. */\ntype OutgoingPayload = {\n\troute: string;\n\tcontents: {\n\t\taction: string;\n\t\tvalue: WebsocketMessageValue;\n\t};\n\tid?: number;\n};\n\n// Constants -------------------------------------------------------------------\n\n/** Time to wait for echo before assuming disconnection. */\nconst timeToWaitForEchoMillis = 5000;\n/** Time the websocket remains open without subscriptions. */\nconst cushionBeforeAutoCloseMillis = 10000;\n/** Simulated websocket latency in debug mode. */\nconst simulatedWebsocketLatencyMillis_Debug = 1000;\n/** Whether to also print incoming echos in debug mode. */\nconst alsoPrintIncomingEchos = false;\n\n// Variables -------------------------------------------------------------------\n\n/** Echo timers for sent messages awaiting acknowledgement. */\nlet echoTimers: Record<string, { timeSent: number; timeoutID: number }> = {};\n\n/** Functions to execute when we get a specific reply back. */\nlet onreplyFuncs: { [key: MessageID]: Function } = {};\n\n/** The timeout ID that auto-closes the socket when we're not subscribed to anything. */\nlet timeoutIDToAutoClose: number;\n\n/**\n * The timeout ID for detecting server inactivity.\n * If no message is received within the expected window, the client\n * assumes the connection is dead and closes the socket.\n */\nlet inactivityTimerID: number | undefined;\n\n// Echo Tracking ---------------------------------------------------------------\n\n/**\n * Called when we hear a server echo. Cancels the timer that assumes\n * disconnection, and updates the ping display.\n */\nfunction cancelTimerOfMessageID(ID: number): void {\n\tconst echoTimer = echoTimers[ID];\n\tif (!echoTimer) {\n\t\tconsole.error('Could not find echo timer for message.');\n\t\treturn;\n\t}\n\n\t// Update the Ping meter with the round-trip time\n\tconst timeTaken = Date.now() - echoTimer.timeSent;\n\tdocument.dispatchEvent(new CustomEvent('ping', { detail: timeTaken }));\n\n\tclearTimeout(echoTimer.timeoutID);\n\tdelete echoTimers[ID];\n}\n\n/**\n * Closes the current websocket when an echo hasn't been heard.\n * Called a few seconds after not hearing a server echo.\n */\nfunction renewConnection(messageID: MessageID): void {\n\tif (messageID) {\n\t\tdelete echoTimers[messageID];\n\t}\n\tconst socket = socketman.getSocket();\n\tif (!socket) return;\n\tconsole.log(\n\t\t`Renewing connection after we haven't received an echo for ${timeToWaitForEchoMillis} milliseconds...`,\n\t);\n\tsocketman.dispatchLostConnectionCustomEvent();\n\tsocket.close(1000, 'Connection closed by client. Renew.');\n}\n\n/**\n * Cancels all timers that assume disconnection.\n * Called when the socket connection is terminated.\n */\nfunction cancelAllEchoTimers(): void {\n\tfor (const echoTimerEntry of Object.values(echoTimers)) {\n\t\tclearTimeout(echoTimerEntry.timeoutID);\n\t}\n\techoTimers = {};\n}\n\n// On-Reply Functions ----------------------------------------------------------\n\n/**\n * Flags an outgoing message to execute a function when the server replies.\n * @param messageID - The ID of the outgoing message\n * @param onreplyFunc - The function to execute on reply\n */\nfunction scheduleOnreplyFunc(messageID: MessageID, onreplyFunc?: () => void): void {\n\tif (!onreplyFunc) return;\n\tonreplyFuncs[messageID] = onreplyFunc;\n}\n\n/**\n * When we receive a message with the `replyto` property,\n * executes the on-reply function for that sent message.\n */\nfunction executeOnreplyFunc(id: number | undefined): void {\n\tif (id === undefined) return;\n\tif (!onreplyFuncs[id]) return;\n\tonreplyFuncs[id]();\n\tdelete onreplyFuncs[id];\n}\n\n/** Erases all on-reply functions. Called when the socket is terminated. */\nfunction resetOnreplyFuncs(): void {\n\tonreplyFuncs = {};\n}\n\n// Timer Management ------------------------------------------------------------\n\n/** If we have zero subscriptions, resets the timer to auto-close the socket. */\nfunction resetTimerToCloseSocket(): void {\n\tclearTimeout(timeoutIDToAutoClose);\n\tif (socketsubs.zeroSubs()) {\n\t\ttimeoutIDToAutoClose = window.setTimeout(\n\t\t\t() => socketman.closeSocket(),\n\t\t\tcushionBeforeAutoCloseMillis,\n\t\t);\n\t}\n}\n\n// Inactivity Detection --------------------------------------------------------\n\n/**\n * Reschedules the inactivity timer. Called on every incoming message.\n * If no message is received within a certain time frame, the client\n * assumes the connection is dead and closes the socket.\n */\nfunction rescheduleInactivityTimer(): void {\n\tcancelInactivityTimer();\n\tif (socketsubs.zeroSubs()) return;\n\tinactivityTimerID = window.setTimeout(\n\t\tonInactivityTimeout,\n\t\twsutil.timeOfInactivityToRenewConnection + timeToWaitForEchoMillis,\n\t);\n}\n\n/** Cancels the inactivity timer. Called when the socket closes. */\nfunction cancelInactivityTimer(): void {\n\tif (inactivityTimerID !== undefined) {\n\t\tclearTimeout(inactivityTimerID);\n\t\tinactivityTimerID = undefined;\n\t}\n}\n\n/**\n * Called when no message has been received within the expected time frame.\n * Closes the socket and dispatches a lost connection event.\n */\nfunction onInactivityTimeout(): void {\n\tinactivityTimerID = undefined;\n\tconst socket = socketman.getSocket();\n\tif (!socket) return;\n\tconsole.log(\n\t\t`No message received for ${wsutil.timeOfInactivityToRenewConnection + timeToWaitForEchoMillis}ms. Assuming connection lost.`,\n\t);\n\tsocketman.dispatchLostConnectionCustomEvent();\n\tsocket.close(1000, 'Connection closed by client. Renew.');\n}\n\n// Sending Messages ------------------------------------------------------------\n\n/**\n * Sends a message to the server with the provided route, action, and values.\n * @param route - Where the server needs to forward this to. general/invites/game\n * @param action - What action to take within the route.\n * @param value - The contents of the message\n * @param isUserAction - Whether this message is a direct result of a user action. Default: false\n * @param onreplyFunc - Optional function to execute when we receive the server's response.\n * @returns *true* if the message was able to send.\n */\nasync function send(\n\troute: string,\n\taction: string,\n\tvalue?: WebsocketMessageValue,\n\tisUserAction?: boolean,\n\tonreplyFunc?: () => void,\n): Promise<boolean> {\n\tif (!(await socketman.establishSocket())) {\n\t\tif (isUserAction) toast.show(translations.websocket.too_many_requests);\n\t\tif (onreplyFunc) onreplyFunc();\n\t\treturn false;\n\t}\n\n\tresetTimerToCloseSocket();\n\n\tlet payload: OutgoingPayload;\n\tif (action === 'echo') {\n\t\tpayload = {\n\t\t\troute: 'echo',\n\t\t\tcontents: value,\n\t\t};\n\t} else {\n\t\t// Not an echo, attach an ID and expect an echo back.\n\t\tpayload = {\n\t\t\troute,\n\t\t\tcontents: {\n\t\t\t\taction,\n\t\t\t\tvalue,\n\t\t\t},\n\t\t\tid: uuid.generateNumbID(10),\n\t\t};\n\n\t\tif (socketman.isDebugEnabled()) console.log(`Sending: ${JSON.stringify(payload)}`);\n\n\t\t// Set a timer to assume disconnection if echo not received\n\t\techoTimers[payload.id!] = {\n\t\t\ttimeSent: Date.now(),\n\t\t\ttimeoutID: window.setTimeout(\n\t\t\t\t() => renewConnection(payload.id!),\n\t\t\t\ttimeToWaitForEchoMillis,\n\t\t\t),\n\t\t};\n\n\t\tscheduleOnreplyFunc(payload.id!, onreplyFunc);\n\t}\n\n\tconst socket = socketman.getSocket();\n\tif (!socket || socket.readyState !== WebSocket.OPEN) return false; // Closed state, can't send message.\n\n\tconst stringifiedMessage = JSON.stringify(payload);\n\n\tif (socketman.isDebugEnabled()) {\n\t\twindow.setTimeout(\n\t\t\t() => socket.send(stringifiedMessage),\n\t\t\tsimulatedWebsocketLatencyMillis_Debug,\n\t\t);\n\t} else socket.send(stringifiedMessage); // Send immediately\n\n\treturn true;\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\tsend,\n\tcancelTimerOfMessageID,\n\tcancelAllEchoTimers,\n\texecuteOnreplyFunc,\n\tresetOnreplyFuncs,\n\trescheduleInactivityTimer,\n\tcancelInactivityTimer,\n\talsoPrintIncomingEchos,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/websocket/socketrouter.ts",
    "content": "// src/client/scripts/esm/game/websocket/socketrouter.ts\n\n/**\n * Routes incoming websocket messages to the appropriate handler\n * based on the subscription type.\n */\n\nimport type { GeneralMessage } from './socketschemas.js';\n\nimport * as z from 'zod';\n\nimport timeutil from '../../../../../shared/util/timeutil.js';\nimport { GAME_VERSION } from '../../../../../shared/game_version.js';\n\nimport toast from '../gui/toast.js';\nimport invites from '../misc/invites.js';\nimport socketman from './socketman.js';\nimport LocalStorage from '../../util/LocalStorage.js';\nimport socketmessages from './socketmessages.js';\nimport onlinegamerouter from '../misc/onlinegame/onlinegamerouter.js';\nimport { MasterSchema } from './socketschemas.js';\n\n// Types -----------------------------------------------------------------------\n\n/** Information about the last hard refresh we attempted. */\ntype HardRefreshInfo = {\n\ttimeLastHardRefreshed: number;\n\texpectedVersion: string;\n\trefreshFailed?: boolean;\n};\n\n// Routing ---------------------------------------------------------------------\n\n/**\n * Called when we receive an incoming server websocket message.\n * Validates it with Zod, sends an echo to the server, then routes the message.\n * @param serverMessage - The incoming server message event.\n */\nfunction onmessage(serverMessage: MessageEvent): void {\n\tlet parsedUnvalidatedMessage: any;\n\ttry {\n\t\tparsedUnvalidatedMessage = JSON.parse(serverMessage.data);\n\t} catch (error) {\n\t\treturn console.error('Error parsing incoming message as JSON:', error);\n\t}\n\n\t// Any incoming message proves the connection is alive.\n\t// Reschedule the inactivity timer that detects silent disconnections.\n\tsocketmessages.rescheduleInactivityTimer();\n\n\tconst zod_result = MasterSchema.safeParse(parsedUnvalidatedMessage);\n\tif (!zod_result.success) {\n\t\ttoast.show(translations.websocket.malformed_message, {\n\t\t\terror: true,\n\t\t\tdurationMillis: 100000,\n\t\t});\n\t\tconsole.error(\n\t\t\t'Received malformed websocket message from the server:',\n\t\t\tparsedUnvalidatedMessage,\n\t\t);\n\t\tconsole.error('Error:', z.prettifyError(zod_result.error));\n\t\treturn;\n\t}\n\n\t// Validation was a success! Message contains valid parameters.\n\n\tconst message = zod_result.data;\n\n\tif (socketman.isDebugEnabled()) {\n\t\tif (message.route === 'echo') {\n\t\t\tif (socketmessages.alsoPrintIncomingEchos)\n\t\t\t\tconsole.log(`Incoming message: ${JSON.stringify(message)}`);\n\t\t} else console.log(`Incoming message: ${JSON.stringify(message)}`);\n\t}\n\n\tif (message.route === 'echo') return socketmessages.cancelTimerOfMessageID(message.contents);\n\n\t// Handle reply-only messages (no route property).\n\t// These exist only to execute on-reply functions.\n\tif (message.route === undefined) {\n\t\t// TEMPORARY. TO HELP DEBUG why zod errors are happening all the time on the server!\n\t\tif (message.id === undefined) {\n\t\t\tconsole.error(\n\t\t\t\t'Received reply-only message without id field. This should not happen after Zod validation. Message:',\n\t\t\t\tJSON.stringify(message),\n\t\t\t);\n\t\t}\n\t\tsocketmessages.send('general', 'echo', message.id);\n\t\tsocketmessages.executeOnreplyFunc(message.replyto);\n\t\treturn;\n\t}\n\n\t// Not an echo or reply-only...\n\n\t// Send our echo — we always echo every message EXCEPT echos themselves\n\t// TEMPORARY. TO HELP DEBUG why zod errors are happening all the time on the server!\n\tif (message.id === undefined) {\n\t\tconsole.error(\n\t\t\t'Received routed message without id field. This should not happen after Zod validation. Route:',\n\t\t\tmessage.route,\n\t\t\t'Message:',\n\t\t\tJSON.stringify(message),\n\t\t);\n\t}\n\tsocketmessages.send('general', 'echo', message.id);\n\n\t// Execute any on-reply function\n\tsocketmessages.executeOnreplyFunc(message.replyto);\n\n\tswitch (message.route) {\n\t\tcase 'general':\n\t\t\tongeneralmessage(message.contents);\n\t\t\tbreak;\n\t\tcase 'invites':\n\t\t\tinvites.onmessage(message.contents);\n\t\t\tbreak;\n\t\tcase 'game':\n\t\t\tonlinegamerouter.routeMessage(message.contents);\n\t\t\tbreak;\n\t\tdefault:\n\t\t\tconsole.error(\n\t\t\t\t// @ts-ignore\n\t\t\t\t`Unknown socket subscription \"${message.route}\" received from the server!`,\n\t\t\t);\n\t\t\tbreak;\n\t}\n}\n\n/**\n * Handles incoming messages with route \"general\".\n * @param message - The validated general route message contents\n */\nfunction ongeneralmessage(message: GeneralMessage): void {\n\tswitch (message.action) {\n\t\tcase 'notify':\n\t\t\ttoast.show(message.value);\n\t\t\tbreak;\n\t\tcase 'notifyerror':\n\t\t\ttoast.show(message.value, { error: true, durationMultiplier: 2 });\n\t\t\tbreak;\n\t\tcase 'print':\n\t\t\tconsole.log(message.value);\n\t\t\tbreak;\n\t\tcase 'printerror':\n\t\t\tconsole.error(message.value);\n\t\t\tbreak;\n\t\tcase 'renewconnection':\n\t\t\t// Server sends this expecting an echo, to verify we're still connected.\n\t\t\tbreak;\n\t\tcase 'gameversion':\n\t\t\tif (message.value !== GAME_VERSION) handleHardRefresh(message.value);\n\t\t\tbreak;\n\t\tdefault:\n\t\t\t// @ts-ignore\n\t\t\tconsole.log(`Unknown server action \"${message.action}\" in general route.`);\n\t\t\tbreak;\n\t}\n}\n\n/**\n * Attempts a hard refresh if the server reports a newer game version.\n * Prevents endless refreshing cycles for browsers that don't support hard refresh.\n * @param LATEST_GAME_VERSION - The game version the server is currently running.\n */\nfunction handleHardRefresh(LATEST_GAME_VERSION: string): void {\n\tconst reloadInfo = {\n\t\ttimeLastHardRefreshed: Date.now(),\n\t\texpectedVersion: LATEST_GAME_VERSION,\n\t};\n\tconst preexistingHardRefreshInfo: HardRefreshInfo = LocalStorage.loadItem('hardrefreshinfo');\n\tif (preexistingHardRefreshInfo?.expectedVersion === LATEST_GAME_VERSION) {\n\t\tif (!preexistingHardRefreshInfo.refreshFailed)\n\t\t\tconsole.warn(\n\t\t\t\t`location.reload(true) failed to hard refresh. Server version: ${LATEST_GAME_VERSION}. Still running: ${GAME_VERSION}`,\n\t\t\t);\n\t\tpreexistingHardRefreshInfo.refreshFailed = true;\n\t\tsaveInfo(preexistingHardRefreshInfo);\n\t\treturn;\n\t}\n\tsaveInfo(reloadInfo);\n\t// @ts-expect-error This parameter does indeed exist -> https://developer.mozilla.org/en-US/docs/Web/API/Location/reload\n\tlocation.reload(true);\n\n\tfunction saveInfo(info: HardRefreshInfo): void {\n\t\tLocalStorage.saveItem('hardrefreshinfo', info, timeutil.getTotalMilliseconds({ hours: 4 })); // I think cloudflare caches scripts for 4 hours\n\t}\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\tonmessage,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/game/websocket/socketschemas.ts",
    "content": "// src/client/scripts/esm/game/websocket/socketschemas.ts\n\n/**\n * This script defines all Zod schemas for validating incoming server websocket messages.\n *\n * All schemas are centralized here to avoid circular dependency issues.\n *\n * Schemas are organized by route: general, invites, game, and a master schema\n * that combines them all together with echo and reply-only message handling.\n */\n\nimport * as z from 'zod';\n\nimport typeutil from '../../../../../shared/chess/util/typeutil.js';\nimport {\n\tClockValuesSchema,\n\tDisconnectInfoSchema,\n\tGameUpdateMessageSchema,\n\tMetaDataSchema,\n\tOpponentsMoveMessageSchema,\n\tPlayerRatingChangeInfoSchema,\n\tRatingSchema,\n\tServerUsernameContainerSchema,\n\tTimeControlSchema,\n} from '../../../../../shared/types.js';\n\n// Common Helper Schemas ---------------------------------------------------------------\n\n/** The publicity of a game/invite. */\nconst PublicitySchema = z.enum(['public', 'private']);\n\n// Invite Helper Schemas ---------------------------------------------------------------\n\n/** The invite object. NOT an HTML object. */\nexport type Invite = z.infer<typeof InviteSchema>;\nconst InviteSchema = z.strictObject({\n\t/** Who owns the invite. */\n\tusernamecontainer: ServerUsernameContainerSchema,\n\t/** A unique identifier. */\n\tid: z.string(),\n\t/** Used to verify if an invite is your own. */\n\ttag: z.string().optional(),\n\t/** The name of the variant. */\n\tvariant: z.string(),\n\t/** The clock value. */\n\tclock: TimeControlSchema,\n\t/** The player color (null = Random). */\n\tcolor: z.union([typeutil.PlayerSchema, z.literal(null)]),\n\t/** Whether the game is public or private. */\n\tpublicity: PublicitySchema,\n\t/** Whether the game is rated or casual. */\n\trated: z.enum(['casual', 'rated']),\n});\n\n// Game Helper Schemas ---------------------------------------------------------------\n\n/** Zod schema for the id of an online game. */\nconst GameIDSchema = z.number().int().nonnegative();\n\n/**\n * Static information about an online game that is unchanging.\n * Only needed once, when we originally load the game, not on subsequent updates/resyncs.\n */\nexport type ServerGameInfo = z.infer<typeof ServerGameInfoSchema>;\nconst ServerGameInfoSchema = z.strictObject({\n\t/** The id of the online game. */\n\tid: GameIDSchema,\n\trated: z.boolean(),\n\tpublicity: PublicitySchema,\n\tplayerRatings: typeutil.GenPlayerGroupSchema(RatingSchema),\n});\n\n/**\n * The message contents when we receive a server websocket `'joingame'` message.\n * Contains everything a {@link GameUpdateMessage} would have, and more!\n *\n * The extra stuff included here does not need to be specified when we're resyncing to\n * a game, or receiving a game update, as we already know it.\n */\nexport type JoinGameMessage = z.infer<typeof JoinGameMessageSchema>;\nconst JoinGameMessageSchema = GameUpdateMessageSchema.extend({\n\tgameInfo: ServerGameInfoSchema,\n\t/** The metadata of the game, including the TimeControl, player names, date, etc. */\n\tmetadata: MetaDataSchema,\n\tyouAreColor: typeutil.PlayerSchema,\n});\n\n// General Schema ---------------------------------------------------------------\n\n/** Represents all possible types an incoming 'general' route websocket message contents could be. */\nexport type GeneralMessage = z.infer<typeof GeneralSchema>;\nconst GeneralSchema = z.discriminatedUnion('action', [\n\tz.strictObject({ action: z.literal('notify'), value: z.string() }),\n\tz.strictObject({ action: z.literal('notifyerror'), value: z.string() }),\n\tz.strictObject({ action: z.literal('print'), value: z.string() }),\n\tz.strictObject({ action: z.literal('printerror'), value: z.string() }),\n\tz.strictObject({ action: z.literal('renewconnection') }),\n\tz.strictObject({ action: z.literal('gameversion'), value: z.string() }),\n]);\n\n// Invites Schema ---------------------------------------------------------------\n\n/** Represents all possible types an incoming 'invites' route websocket message contents could be. */\nexport type InvitesMessage = z.infer<typeof InvitesSchema>;\nconst InvitesSchema = z.discriminatedUnion('action', [\n\tz.strictObject({\n\t\taction: z.literal('inviteslist'),\n\t\tvalue: z.strictObject({ invitesList: z.array(InviteSchema), currentGameCount: z.number() }),\n\t}),\n\tz.strictObject({ action: z.literal('gamecount'), value: z.number() }),\n]);\n\n// Game Schema ---------------------------------------------------------------\n\n/** All possible types an incoming 'game' route websocket message contents could be. */\nexport type GameMessage = z.infer<typeof GameSchema>;\nconst GameSchema = z.discriminatedUnion('action', [\n\tz.strictObject({ action: z.literal('joingame'), value: JoinGameMessageSchema }),\n\tz.strictObject({\n\t\taction: z.literal('logged-game-info'),\n\t\tvalue: z.strictObject({\n\t\t\tgame_id: GameIDSchema,\n\t\t\trated: z.literal([0, 1]),\n\t\t\tprivate: z.literal([0, 1]),\n\t\t\ttermination: z.string(),\n\t\t\ticn: z.string(),\n\t\t}),\n\t}),\n\tz.strictObject({ action: z.literal('move'), value: OpponentsMoveMessageSchema }),\n\tz.strictObject({ action: z.literal('clock'), value: ClockValuesSchema }),\n\tz.strictObject({\n\t\taction: z.literal('gameupdate'),\n\t\tvalue: GameUpdateMessageSchema,\n\t}),\n\tz.strictObject({\n\t\taction: z.literal('gameratingchange'),\n\t\tvalue: z.record(z.string(), PlayerRatingChangeInfoSchema),\n\t}),\n\tz.strictObject({ action: z.literal('unsub') }),\n\tz.strictObject({ action: z.literal('login') }),\n\tz.strictObject({ action: z.literal('nogame') }),\n\tz.strictObject({ action: z.literal('leavegame') }),\n\tz.strictObject({\n\t\taction: z.literal('opponentafk'),\n\t\tvalue: z.strictObject({ millisUntilAutoAFKResign: z.number() }),\n\t}),\n\tz.strictObject({ action: z.literal('opponentafkreturn') }),\n\tz.strictObject({\n\t\taction: z.literal('opponentdisconnect'),\n\t\tvalue: DisconnectInfoSchema,\n\t}),\n\tz.strictObject({ action: z.literal('opponentdisconnectreturn') }),\n\tz.strictObject({ action: z.literal('drawoffer') }),\n\tz.strictObject({ action: z.literal('declinedraw') }),\n]);\n\n// Master Schema ---------------------------------------------------------------\n\n/** The schema for validating all incoming websocket messages. */\nconst MasterSchema = z.discriminatedUnion('route', [\n\t// Echo messages\n\tz.strictObject({\n\t\troute: z.literal('echo'),\n\t\tcontents: z.number(),\n\t}),\n\t// Reply-only messages (no route property, only exist to execute on-reply functions)\n\tz.strictObject({\n\t\tid: z.number(),\n\t\troute: z.undefined(),\n\t\treplyto: z.number(),\n\t}),\n\t// Routed messages\n\tz.strictObject({\n\t\tid: z.number(),\n\t\troute: z.literal('general'),\n\t\tcontents: GeneralSchema,\n\t\treplyto: z.number().optional(),\n\t}),\n\tz.strictObject({\n\t\tid: z.number(),\n\t\troute: z.literal('invites'),\n\t\tcontents: InvitesSchema,\n\t\treplyto: z.number().optional(),\n\t}),\n\tz.strictObject({\n\t\tid: z.number(),\n\t\troute: z.literal('game'),\n\t\tcontents: GameSchema,\n\t\treplyto: z.number().optional(),\n\t}),\n]);\n\n// Exports ---------------------------------------------------------------\n\nexport { MasterSchema };\n"
  },
  {
    "path": "src/client/scripts/esm/game/websocket/socketsubs.ts",
    "content": "// src/client/scripts/esm/game/websocket/socketsubs.ts\n\n/**\n * Manages subscription state for the client websocket system.\n *\n * Tracks which subscriptions (e.g. 'invites', 'game') are currently active,\n * and provides methods to add, remove, and query subscriptions.\n */\n\nimport socketmessages from './socketmessages.js';\n\nconst validSubs = ['invites', 'game'] as const;\n\ntype Sub = (typeof validSubs)[number];\n\nconst subs: Record<Sub, boolean> = {\n\tinvites: false,\n\tgame: false,\n};\n\n/** Returns true if we're currently not subscribed to anything. */\nfunction zeroSubs(): boolean {\n\tfor (const sub of validSubs) if (subs[sub] === true) return false;\n\treturn true;\n}\n\n/**\n * Whether we are subbed to the given subscription list.\n * @param sub - The name of the sub\n */\nfunction areSubbedToSub(sub: Sub): boolean {\n\treturn subs[sub] !== false;\n}\n\n/**\n * Marks ourself as subscribed to a subscription list.\n * @param sub - The name of the sub to add\n */\nfunction addSub(sub: Sub): void {\n\tsubs[sub] = true;\n}\n\n/**\n * Marks ourself as no longer subscribed to a subscription list.\n *\n * If our websocket happens to close unexpectedly, we won't re-subscribe to it.\n * @param sub - The name of the sub to delete\n */\nfunction deleteSub(sub: Sub): void {\n\tsubs[sub] = false;\n}\n\n/**\n * Unsubs from the provided subscription list,\n * informing the server we no longer want updates.\n * @param sub - The name of the sub to unsubscribe from\n */\nfunction unsubFromSub(sub: Sub): void {\n\tif (!areSubbedToSub(sub)) return; // Already unsubbed.\n\tdeleteSub(sub);\n\t// Tell the server we no longer want updates.\n\tsocketmessages.send('general', 'unsub', sub);\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\tvalidSubs,\n\tzeroSubs,\n\tareSubbedToSub,\n\taddSub,\n\tdeleteSub,\n\tunsubFromSub,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/util/ImageLoader.ts",
    "content": "// src/client/scripts/esm/util/ImageLoader.ts\n\nimport { retryFetch, RetryFetchOptions } from './httputils';\n\nclass ImageLoader {\n\t/** Default retry options if none are provided. */\n\tprivate static defaultRetryOptions: RetryFetchOptions = { maxAttempts: 1 }; // No retries by default\n\n\t/**\n\t * Requests an image from the server with retry logic and returns a promise\n\t * that resolves to an HTMLImageElement.\n\t * @param url The URL of the image to request.\n\t * @param retryOptions Optional configuration for the retry behavior.\n\t * @returns A promise that resolves with the loaded HTMLImageElement.\n\t */\n\tpublic static loadImage(\n\t\turl: string,\n\t\tretryOptions: RetryFetchOptions = this.defaultRetryOptions,\n\t): Promise<HTMLImageElement> {\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tretryFetch(url, undefined, retryOptions)\n\t\t\t\t.then((response) => {\n\t\t\t\t\tif (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);\n\t\t\t\t\treturn response.blob();\n\t\t\t\t})\n\t\t\t\t.then((blob) => {\n\t\t\t\t\tconst image = new Image();\n\t\t\t\t\tconst objectURL = URL.createObjectURL(blob);\n\n\t\t\t\t\timage.onload = () => {\n\t\t\t\t\t\t// Revoke the object URL after the image has been loaded to free up memory\n\t\t\t\t\t\tURL.revokeObjectURL(objectURL);\n\t\t\t\t\t\tresolve(image);\n\t\t\t\t\t};\n\n\t\t\t\t\timage.onerror = () => {\n\t\t\t\t\t\t// Revoke the object URL on error as well\n\t\t\t\t\t\tURL.revokeObjectURL(objectURL);\n\t\t\t\t\t\treject(new Error(`Failed to load image at ${url}`));\n\t\t\t\t\t};\n\n\t\t\t\t\timage.src = objectURL;\n\t\t\t\t})\n\t\t\t\t.catch((error) => {\n\t\t\t\t\treject(error);\n\t\t\t\t});\n\t\t});\n\t}\n}\n\nexport default ImageLoader;\n"
  },
  {
    "path": "src/client/scripts/esm/util/IndexedDB.ts",
    "content": "// src/client/scripts/esm/util/IndexedDB.ts\n\n/**\n * This script handles reading, saving, and deleting browser IndexedDB data.\n *\n * IndexedDB provides persistent large-scale storage beyond localStorage's limitations.\n */\n\n/** An entry in IndexedDB storage */\ninterface Entry {\n\t/** The actual value of the entry */\n\tvalue: any;\n\t/** The timestamp the entry will become stale, at which point it should be deleted. */\n\texpires?: number;\n}\n\nconst DB_NAME = 'infinitechess';\nconst DB_VERSION = 1;\nconst STORE_NAME = 'entries';\n\nlet dbInstance: IDBDatabase | null = null;\nlet dbInitPromise: Promise<IDBDatabase> | null = null;\n\n// Do this on load every time\neraseExpiredItems().catch((error: unknown) => {\n\t// Can happen in testing environment where IndexedDB is not available\n\tconst msg = error instanceof Error ? error.message : String(error);\n\tconsole.error('Error erasing expired IndexedDB items on init:', msg);\n});\n\n/**\n * Initializes the IndexedDB database.\n * Returns a promise that resolves to the database instance.\n */\nfunction initDB(): Promise<IDBDatabase> {\n\tif (dbInstance) return Promise.resolve(dbInstance);\n\tif (dbInitPromise) return dbInitPromise;\n\n\tdbInitPromise = new Promise((resolve, reject) => {\n\t\t// Check if IndexedDB is available\n\t\tconst idb = (globalThis as any).indexedDB;\n\t\tif (!idb) {\n\t\t\treject(new Error('IndexedDB is not supported in this browser'));\n\t\t\treturn;\n\t\t}\n\n\t\tconst request = idb.open(DB_NAME, DB_VERSION);\n\n\t\trequest.onblocked = () => {\n\t\t\tconsole.warn('IndexedDB upgrade blocked: another tab/session holds the DB open');\n\t\t};\n\n\t\trequest.onerror = () => {\n\t\t\tdbInitPromise = null; // Allow future calls to retry opening the DB\n\t\t\treject(new Error('Failed to open IndexedDB database'));\n\t\t};\n\n\t\trequest.onsuccess = () => {\n\t\t\tdbInstance = request.result;\n\t\t\tif (dbInstance) {\n\t\t\t\tdbInstance.onversionchange = () => dbInstance?.close();\n\t\t\t\tresolve(dbInstance);\n\t\t\t} else {\n\t\t\t\treject(new Error('Failed to initialize IndexedDB database'));\n\t\t\t}\n\t\t};\n\n\t\trequest.onupgradeneeded = (event: IDBVersionChangeEvent) => {\n\t\t\tconst db = (event.target as IDBOpenDBRequest).result;\n\n\t\t\t// Create object store if it doesn't exist\n\t\t\tif (!db.objectStoreNames.contains(STORE_NAME)) {\n\t\t\t\tdb.createObjectStore(STORE_NAME);\n\t\t\t}\n\t\t};\n\t});\n\n\treturn dbInitPromise;\n}\n\n/** Run a readonly transaction and return the request result. */\nasync function withRead<T>(op: (_store: IDBObjectStore) => IDBRequest<T>): Promise<T> {\n\tconst db = await initDB();\n\treturn new Promise<T>((resolve, reject) => {\n\t\t// Open a readonly transaction on the object store\n\t\tconst tx = db.transaction([STORE_NAME], 'readonly');\n\t\tconst store = tx.objectStore(STORE_NAME);\n\t\t// Execute caller-provided operation (e.g., store.get(key))\n\t\tconst req = op(store);\n\n\t\treq.onsuccess = () => resolve(req.result as T);\n\t\t// Reject on transaction or request errors\n\t\ttx.onerror = () => reject(tx.error || new Error('Transaction error'));\n\t\treq.onerror = () => reject(req.error || new Error('Request error'));\n\t});\n}\n\n/** Run a readwrite transaction. Resolves when the transaction completes. */\nasync function withWrite<R>(op: (_store: IDBObjectStore) => IDBRequest<R>): Promise<void> {\n\tconst db = await initDB();\n\treturn new Promise<void>((resolve, reject) => {\n\t\t// Open a readwrite transaction to modify data\n\t\tconst tx = db.transaction([STORE_NAME], 'readwrite');\n\t\tconst store = tx.objectStore(STORE_NAME);\n\t\t// Execute caller-provided operation (e.g., store.put(...), store.delete(...))\n\t\tconst req = op(store);\n\n\t\t// Resolve only after the entire transaction finishes\n\t\ttx.oncomplete = () => resolve();\n\t\t// Reject on transaction or request errors\n\t\ttx.onerror = () => reject(tx.error || new Error('Transaction error'));\n\t\treq.onerror = () => reject(req.error || new Error('Request error'));\n\t});\n}\n\n/**\n * Saves an item in browser IndexedDB storage\n * @param key - The key-name to give this entry.\n * @param value - What to save\n * @param [expiryMillis] How long until this entry should be auto-deleted for being stale. Leave undefined to never expire.\n * @returns A promise that resolves when the item is saved\n */\nasync function saveItem<T>(key: string, value: T, expiryMillis?: number): Promise<void> {\n\tconst timeExpires = expiryMillis !== undefined ? Date.now() + expiryMillis : undefined;\n\tconst save: Entry = { value, expires: timeExpires };\n\treturn withWrite((store) => store.put(save, key));\n}\n\n/**\n * Loads an item from browser IndexedDB storage\n * @param key - The name/key of the item in storage\n * @returns A promise that resolves to the entry value, or undefined if not found\n */\nasync function loadItem<T>(key: string): Promise<T | undefined> {\n\tconst save = await withRead<any>((store) => store.get(key));\n\tif (save === undefined) return undefined;\n\n\t// Check if the item has expired or is in old format\n\tif (hasItemExpired(save)) {\n\t\tawait deleteItem(key);\n\t\treturn undefined;\n\t}\n\n\t// Not expired, return the value\n\treturn save.value as T;\n}\n\n/**\n * Deletes an item from browser IndexedDB storage\n * @param key The name/key of the item in storage\n * @returns A promise that resolves when the item is deleted\n */\nasync function deleteItem(key: string): Promise<void> {\n\treturn withWrite((store) => store.delete(key));\n}\n\n/**\n * Checks if an entry has expired\n * @param save - The entry to check. The latest format is { value: any, expires: number }\n * @returns True if the entry has expired\n */\nfunction hasItemExpired(save: unknown): boolean {\n\t// Verify it's an object, and the `expires` property is present.\n\t// Checking in this way will allow typescript afterward to know it has that property.\n\tif (\n\t\ttypeof save !== 'object' ||\n\t\tsave === null ||\n\t\t// This is true EVEN if the property is present but set to undefined!\n\t\t!('expires' in save) ||\n\t\t(save.expires !== undefined && typeof save.expires !== 'number')\n\t) {\n\t\tconsole.log(`IndexedDB item was in an old format. Deleting it...`);\n\t\treturn true;\n\t}\n\n\tif (save.expires === undefined) return false; // Never expires\n\n\treturn Date.now() >= save.expires;\n}\n\n/**\n * Erases all expired items from IndexedDB storage\n * @returns A promise that resolves when all expired items are deleted\n */\nasync function eraseExpiredItems(): Promise<void> {\n\tconst db = await initDB();\n\tconst keysToDelete: string[] = [];\n\n\t// Use a cursor to iterate through entries and check expiry without deserializing values\n\tawait new Promise<void>((resolve, reject) => {\n\t\tconst tx = db.transaction([STORE_NAME], 'readonly');\n\t\tconst store = tx.objectStore(STORE_NAME);\n\t\tconst request = store.openCursor();\n\n\t\trequest.onsuccess = () => {\n\t\t\tconst cursor = request.result;\n\t\t\tif (cursor) {\n\t\t\t\tconst entry = cursor.value as Entry | any;\n\t\t\t\t// Check if entry has expired or is in old format using hasItemExpired\n\t\t\t\tif (hasItemExpired(entry)) {\n\t\t\t\t\tkeysToDelete.push(cursor.key as string);\n\t\t\t\t}\n\t\t\t\tcursor.continue();\n\t\t\t}\n\t\t};\n\n\t\ttx.oncomplete = () => resolve();\n\t\ttx.onerror = () => reject(tx.error || new Error('Transaction error'));\n\t\trequest.onerror = () => reject(request.error || new Error('Request error'));\n\t});\n\n\t// Delete all expired items in a single transaction\n\tif (keysToDelete.length > 0) {\n\t\tawait new Promise<void>((resolve, reject) => {\n\t\t\tconst tx = db.transaction([STORE_NAME], 'readwrite');\n\t\t\tconst store = tx.objectStore(STORE_NAME);\n\n\t\t\tfor (const key of keysToDelete) {\n\t\t\t\tstore.delete(key);\n\t\t\t}\n\n\t\t\ttx.oncomplete = () => resolve();\n\t\t\ttx.onerror = () => reject(tx.error || new Error('Transaction error'));\n\t\t});\n\t}\n}\n\n/**\n * Gets all keys present in the IndexedDB storage\n * @returns A promise that resolves to an array of all keys\n */\nasync function getAllKeys(): Promise<string[]> {\n\tconst keys = await withRead<IDBValidKey[]>((store) => store.getAllKeys());\n\treturn keys as string[];\n}\n\n/**\n * Erases all items from IndexedDB storage\n * @returns A promise that resolves when all items are deleted\n */\nasync function eraseAll(): Promise<void> {\n\treturn withWrite((store) => store.clear());\n}\n\n/** Reset the cached DB instance (close if open) so the next call to initDB() re-initializes. */\nfunction resetDBInstance(): void {\n\t// Close the existing database connection if it’s open (ignore any close errors)\n\ttry {\n\t\tdbInstance?.close();\n\t} catch {\n\t\t// Ignore\n\t}\n\t// Null out cached references so initDB() will run fresh\n\tdbInstance = null;\n\tdbInitPromise = null;\n}\n\nexport default {\n\tsaveItem,\n\tloadItem,\n\tdeleteItem,\n\tgetAllKeys,\n\teraseExpiredItems,\n\teraseAll,\n\tresetDBInstance,\n\t// Unit test constants\n\tDB_NAME,\n\tDB_VERSION,\n\tSTORE_NAME,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/util/LocalStorage.ts",
    "content": "// src/client/scripts/esm/util/LocalStorage.ts\n\n/**\n * This script handles reading, saving, and deleting expired\n * browser local storage data for us!\n * Without it, things we save NEVER expire or are deleted.\n * (unless the user clears their browser cache)\n */\n\nimport jsutil from '../../../../shared/util/jsutil.js';\n\n/** An entry in local storage */\ninterface Entry {\n\t/** The actual value of the entry */\n\tvalue: any;\n\t/** The timestamp the entry will become stale, at which point it should be deleted. */\n\texpires: number;\n}\n\n/** For debugging. This prints to the console all save and delete operations. */\nconst printSavesAndDeletes = false;\n\nconst defaultExpiryTimeMillis = 1000 * 60 * 60 * 24; // 24 hours\n// const defaultExpiryTimeMillis = 1000 * 20; // 20 seconds\n\n// Do this on load every time\neraseExpiredItems();\n\n/**\n * Saves an item in browser local storage\n * @param key - The key-name to give this entry.\n * @param value - What to save\n * @param [expiryMillis] How long until this entry should be auto-deleted for being stale\n */\nfunction saveItem(key: string, value: any, expiryMillis: number = defaultExpiryTimeMillis): void {\n\tif (printSavesAndDeletes) console.log(`Saving key to local storage: ${key}`);\n\tconst timeExpires = Date.now() + expiryMillis;\n\tconst save: Entry = { value, expires: timeExpires };\n\tconst stringifiedSave = JSON.stringify(save, jsutil.stringifyReplacer);\n\tlocalStorage.setItem(key, stringifiedSave);\n}\n\n/**\n * Loads an item from browser local storage\n * @param key - The name/key of the item in storage\n * @returns The entry\n */\nfunction loadItem(key: string): any {\n\tconst stringifiedSave: string | null = localStorage.getItem(key); // \"{ value, expiry }\"\n\tif (stringifiedSave === null) return;\n\tlet save: Entry | any;\n\ttry {\n\t\tsave = JSON.parse(stringifiedSave, jsutil.parseReviver); // { value, expires }\n\t} catch (_e) {\n\t\t// Value wasn't in json format, just delete it. They have to be in json because we always store the 'expiry' property.\n\t\tdeleteItem(key);\n\t\treturn;\n\t}\n\tif (hasItemExpired(save)) {\n\t\tdeleteItem(key);\n\t\treturn;\n\t}\n\t// Not expired...\n\n\t// console.log(`Fetched key ${key} from local storage:`);\n\t// console.log(save);\n\n\treturn save.value;\n}\n\n/**\n * Deletes an item from browser local storage\n * @param key The name/key of the item in storage\n */\nfunction deleteItem(key: string): void {\n\tif (printSavesAndDeletes) console.log(`Deleting local storage item with key '${key}!'`);\n\tlocalStorage.removeItem(key);\n}\n\nfunction hasItemExpired(save: Entry | any): boolean {\n\tif (save.expires === undefined) {\n\t\tconsole.log(`Local storage item was in an old format. Deleting it...`);\n\t\treturn true;\n\t}\n\treturn Date.now() >= save.expires;\n}\n\nfunction eraseExpiredItems(): void {\n\tconst keys = Object.keys(localStorage);\n\n\t// if (keys.length > 0) console.log(`Items in local storage: ${JSON.stringify(keys)}`);\n\n\tfor (const key of keys) {\n\t\tloadItem(key); // Auto-deletes expired items\n\t}\n}\n\nfunction eraseAll(): void {\n\tconsole.log('Erasing ALL items in local storage...');\n\tconst keys = Object.keys(localStorage);\n\tfor (const key of keys) {\n\t\tdeleteItem(key); // Auto-deletes expired items\n\t}\n}\n\nexport default {\n\tsaveItem,\n\tloadItem,\n\tdeleteItem,\n\teraseExpiredItems,\n\teraseAll,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/util/PerlinNoise.ts",
    "content": "// src/client/scripts/esm/util/PerlinNoise.ts\n\n/**\n * A factory for creating a tileable (periodic) 1D Perlin-style noise generator.\n */\n\n/**\n * A pre-shuffled array of numbers from 0-255.\n * This is a standard permutation table used in many noise algorithms.\n * We double it to avoid needing extra modulo operations inside the noise function.\n */\nconst p = [\n\t151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69,\n\t142, 8, 99, 37, 240, 21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219,\n\t203, 117, 35, 11, 32, 57, 177, 33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175,\n\t74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83, 111, 229, 122, 60, 211, 133, 230,\n\t220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25, 63, 161, 1, 216, 80, 73, 209, 76,\n\t132, 187, 208, 89, 18, 169, 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186,\n\t3, 64, 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206, 59,\n\t227, 47, 16, 58, 17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70,\n\t221, 153, 101, 155, 167, 43, 172, 9, 129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178,\n\t185, 112, 104, 218, 246, 97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241, 81,\n\t51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157, 184, 84, 204, 176, 115,\n\t121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128, 195,\n\t78, 66, 215, 61, 156, 180,\n];\nconst perm = [...p, ...p];\n\n/** A simple linear interpolation function. */\nfunction lerp(a: number, b: number, t: number): number {\n\treturn a + t * (b - a);\n}\n\n/** A smoothing function (quintic curve) to avoid artifacts in the noise. */\nfunction fade(t: number): number {\n\treturn t * t * t * (t * (t * 6 - 15) + 10);\n}\n\n/**\n * Creates and returns a new 1D noise function that is periodic (tileable).\n * @param period The interval after which the noise pattern should repeat. Must be an integer.\n * @returns A function that takes a number `t` and returns a noise value between -1 and 1.\n */\nfunction create1DNoiseGenerator(period: number): (_t: number) => number {\n\tif (period > 256) throw Error('Period must be 256 or less.');\n\n\t// Pre-calculate random gradients for the length of the period.\n\t// For 1D noise, a \"gradient\" is just a random number, either 1 or -1.\n\tconst gradients = new Array(period);\n\tfor (let i = 0; i < period; i++) {\n\t\tgradients[i] = perm[i]! % 2 === 0 ? 1 : -1;\n\t}\n\n\treturn (t: number) => {\n\t\t// Find the integer grid points surrounding t\n\t\tconst x0 = Math.floor(t);\n\t\tconst x1 = x0 + 1;\n\n\t\t// Get the fractional part of t\n\t\tconst t0 = t - x0;\n\n\t\t// This is the magic for making the noise tileable.\n\t\t// We use the modulo operator to wrap the grid coordinates around the period.\n\t\t// So, the gradient for point `period` will be the same as for point `0`.\n\t\tconst g0 = gradients[x0 % period]!;\n\t\tconst g1 = gradients[x1 % period]!;\n\n\t\t// Calculate the contribution of each gradient at point t\n\t\tconst n0 = g0 * t0;\n\t\tconst n1 = g1 * (t0 - 1);\n\n\t\t// Apply the fade curve to the fractional part for smooth interpolation\n\t\tconst fadeT = fade(t0);\n\n\t\t// Interpolate between the two contributions and scale the output\n\t\t// to be consistently within the approximate range of -1 to 1.\n\t\treturn lerp(n0, n1, fadeT) * 2.2;\n\t};\n}\n\nexport default {\n\tcreate1DNoiseGenerator,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/util/compression.ts",
    "content": "// src/client/scripts/esm/util/compression.ts\n\n/**\n * General-purpose string compression utilities using the Web Streams\n * CompressionStream / DecompressionStream APIs.\n *\n * Compressed output is base64-encoded so it can be safely stored and\n * transmitted as a plain string.\n */\n\n// Types -----------------------------------------------------------------------\n\n/** The compression algorithm used when storing a compressed string. */\nexport type CompressionMode = 'none' | 'deflate-raw';\n\n// Constants -----------------------------------------------------------------------\n\n/**\n * Set to `true` to enable verbose compression/decompression diagnostics:\n * - `console.time` timing for every compress/decompress call.\n * - After compression: before/after character counts, bytes saved, and ratio.\n */\nconst DEBUG_COMPRESSION = false;\n\n// Helpers ---------------------------------------------------------------------\n\n/** Reads all chunks from a ReadableStream into a single Uint8Array. */\nasync function readAllChunks(readable: ReadableStream<Uint8Array>): Promise<Uint8Array> {\n\tconst chunks: Uint8Array[] = [];\n\tconst reader = readable.getReader();\n\twhile (true) {\n\t\tconst { done, value } = await reader.read();\n\t\tif (done) break;\n\t\tchunks.push(value);\n\t}\n\tconst totalLength = chunks.reduce((sum, c) => sum + c.length, 0);\n\tconst combined = new Uint8Array(totalLength);\n\tlet offset = 0;\n\tfor (const chunk of chunks) {\n\t\tcombined.set(chunk, offset);\n\t\toffset += chunk.length;\n\t}\n\treturn combined;\n}\n\n/** Base64-encodes a Uint8Array in fixed-size chunks to avoid stack overflow on large payloads. */\nfunction uint8ArrayToBase64(bytes: Uint8Array): string {\n\tlet binary = '';\n\tconst chunkSize = 8192;\n\tfor (let i = 0; i < bytes.length; i += chunkSize) {\n\t\tbinary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));\n\t}\n\treturn btoa(binary);\n}\n\n/**\n * Decompresses a base64-encoded string that was previously compressed with\n * `CompressionStream('deflate-raw')`.\n * @throws If `DecompressionStream` is unavailable or decompression fails.\n */\nasync function decompressStringBase64(compressedBase64: string): Promise<string> {\n\tconst binary = atob(compressedBase64);\n\tconst bytes = new Uint8Array(binary.length);\n\tfor (let i = 0; i < binary.length; i++) {\n\t\tbytes[i] = binary.charCodeAt(i);\n\t}\n\n\tconst stream = new DecompressionStream('deflate-raw');\n\tconst writer = stream.writable.getWriter();\n\twriter.write(bytes);\n\twriter.close();\n\n\tconst decompressed = await readAllChunks(stream.readable);\n\treturn new TextDecoder().decode(decompressed);\n}\n\n// API ---------------------------------------------------------------------\n\n/**\n * Attempts to compress a string using `CompressionStream('deflate-raw')`.\n *\n * The compressed output is base64-encoded so it can be stored as a plain string.\n * Falls back gracefully to `'none'` if:\n * - `CompressionStream` is not available in the current environment, or\n * - Compression does not actually reduce the string length, or\n * - An unexpected error occurs during compression.\n *\n * @returns An object with `data` (the compressed-and-base64-encoded string, or\n *          the original string when compression is `'none'`) and `compression`\n *          indicating which mode was used.\n */\nasync function compressString(\n\tstr: string,\n): Promise<{ data: string; compression: CompressionMode }> {\n\tif (typeof CompressionStream === 'undefined') {\n\t\treturn { data: str, compression: 'none' };\n\t}\n\n\tconst label = `Compressed ${str.length} characters`;\n\tif (DEBUG_COMPRESSION) console.time(label);\n\n\ttry {\n\t\tconst encoded = new TextEncoder().encode(str);\n\t\tconst stream = new CompressionStream('deflate-raw');\n\t\tconst writer = stream.writable.getWriter();\n\t\twriter.write(encoded);\n\t\twriter.close();\n\n\t\tconst compressed = await readAllChunks(stream.readable);\n\t\tconst base64 = uint8ArrayToBase64(compressed);\n\n\t\tif (DEBUG_COMPRESSION) {\n\t\t\tconsole.timeEnd(label);\n\t\t\tconst ratio = ((base64.length * 100) / str.length).toFixed(1);\n\t\t\tconsole.log(\n\t\t\t\t`Before: ${str.length} characters. After: ${base64.length} characters. (${ratio}% of original)`,\n\t\t\t);\n\t\t}\n\n\t\t// Only use compression if it actually reduces size\n\t\tif (base64.length < str.length) {\n\t\t\treturn { data: base64, compression: 'deflate-raw' };\n\t\t}\n\t} catch (err) {\n\t\tif (DEBUG_COMPRESSION) console.timeEnd(label);\n\t\tconsole.warn('Compression failed, falling back to uncompressed:', err);\n\t}\n\n\t// Fallback to uncompressed if compression is unavailable, fails, or doesn't reduce size\n\treturn { data: str, compression: 'none' };\n}\n\n/**\n * Decompresses a string according to its stored compression mode.\n * - `'none'`: returns `data` unchanged.\n * - `'deflate-raw'`: base64-decodes then inflates the data.\n *\n * @throws If the mode is `'deflate-raw'` and `DecompressionStream` is not\n *         available in the current environment, or if decompression fails.\n */\nasync function decompressString(data: string, mode: CompressionMode): Promise<string> {\n\tif (mode === 'none') return data;\n\tif (mode === 'deflate-raw') {\n\t\tif (typeof DecompressionStream === 'undefined') {\n\t\t\tthrow new Error('Browser does not support DecompressionStream.');\n\t\t}\n\n\t\tconst label = `Decompressed ${data.length} characters`;\n\t\tif (DEBUG_COMPRESSION) console.time(label);\n\n\t\tconst result = await decompressStringBase64(data);\n\n\t\tif (DEBUG_COMPRESSION) console.timeEnd(label);\n\n\t\treturn result;\n\t}\n\tthrow new Error(`Unsupported compression mode: \"${mode}\"`);\n}\n\n// Exports ---------------------------------------------------------------------\n\nexport default {\n\tcompressString,\n\tdecompressString,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/util/docutil.ts",
    "content": "// src/client/scripts/esm/util/docutil.ts\n\n/**\n * This script contains utility methods for the document/window objects, or the page.\n *\n * ZERO dependancies.\n */\n\n/**\n * Determines if the current page is running on a local environment (localhost or local IP).\n * @returns *true* if the page is running locally, *false* otherwise.\n */\nfunction isLocalEnvironment(): boolean {\n\tconst hostname = window.location.hostname;\n\n\t// Check for common localhost hostnames and local IP ranges\n\treturn (\n\t\thostname === 'localhost' || // Localhost\n\t\thostname === '127.0.0.1' || // Loopback IP address\n\t\thostname.startsWith('192.168.') || // Private IPv4 address space\n\t\thostname.startsWith('10.') || // Private IPv4 address space\n\t\t(hostname.startsWith('172.') &&\n\t\t\tparseInt(hostname.split('.')[1]!, 10) >= 16 &&\n\t\t\tparseInt(hostname.split('.')[1]!, 10) <= 31) // Private IPv4 address space\n\t);\n}\n\n/**\n * Copies the provided text to the operating system's clipboard.\n * @param text - The text to copy\n */\nfunction copyToClipboard(text: string): void {\n\tnavigator.clipboard\n\t\t.writeText(text)\n\t\t.then(() => {\n\t\t\tconsole.log('Copied to clipboard');\n\t\t})\n\t\t.catch((error) => {\n\t\t\tconsole.error('Failed to copy to clipboard', error);\n\t\t});\n}\n\n/**\n * Returns true if the current device has a mouse pointer.\n */\nfunction isMouseSupported(): boolean {\n\t// \"pointer: coarse\" are devices will less pointer accuracy (not \"fine\" like a mouse)\n\t// See W3 documentation: https://www.w3.org/TR/mediaqueries-4/#mf-interaction\n\t// USING \"any-pointer\" CAUSES false positives on mobile devices!\n\treturn window.matchMedia('(pointer: fine)').matches;\n}\n\n/**\n * Returns true if the current device supports touch events.\n */\nfunction isTouchSupported(): boolean {\n\t// \"pointer: coarse\" are devices will less pointer accuracy (not \"fine\" like a mouse)\n\treturn (\n\t\t'ontouchstart' in window ||\n\t\tnavigator.maxTouchPoints > 0 ||\n\t\twindow.matchMedia('(pointer: coarse)').matches\n\t);\n}\n\n/**\n * Gets the last segment of the current URL without query parameters.\n * \"/member/jacob?lng=en-US\" ==> \"jacob\"\n */\nfunction getLastSegmentOfURL(): string {\n\tconst url = new URL(window.location.href);\n\tconst pathname = url.pathname;\n\tconst segments = pathname.split('/').filter(Boolean); // Remove empty segments caused by leading/trailing slashes\n\treturn segments[segments.length - 1] ?? ''; // Fallback to an empty string if no segment exists\n}\n\n/**\n * Extracts the pathname from a given href.\n * (e.g. \"https://www.infinitechess.org/news?lng=en-US\" ==> \"/news\")\n * @param href - The href to extract the pathname from. Can be a relative or absolute URL.\n * @returns The pathname of the href (e.g., '/news').\n */\nfunction getPathnameFromHref(href: string): string {\n\tconst url = new URL(href, window.location.origin);\n\treturn url.pathname;\n}\n\n/**\n * Searches the document for the specified cookie, and returns it if found.\n * @param cookieName The name of the cookie you would like to retrieve.\n * @returns The cookie, if it exists, otherwise, undefined.\n */\nfunction getCookieValue(cookieName: string): string | undefined {\n\tconst cookieArray = document.cookie.split('; ');\n\n\tfor (let i = 0; i < cookieArray.length; i++) {\n\t\tconst cookiePair = cookieArray[i]!.split('=');\n\t\tif (cookiePair[0] === cookieName) return cookiePair[1];\n\t}\n\n\treturn; // Typescript is angry without this\n}\n\n/**\n * Sets a cookie in the document\n * @param cookieName - The name of the cookie\n * @param value - The value of the cookie\n * @param days - How many days until the cookie should expire.\n */\nfunction updateCookie(cookieName: string, value: string, days: number): void {\n\tlet expires = '';\n\tif (days) {\n\t\tconst date = new Date();\n\t\tdate.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);\n\t\texpires = '; expires=' + date.toUTCString();\n\t}\n\tdocument.cookie = cookieName + '=' + (value || '') + expires + '; path=/';\n}\n\n/**\n * Deletes a document cookie.\n * @param cookieName - The name of the cookie you would like to delete.\n */\nfunction deleteCookie(cookieName: string): void {\n\tdocument.cookie = cookieName + '=; Max-Age=-99999999;';\n}\n\n/**\n * Parse an SVG string into a live SVGElement.\n * @param svgText — a string containing valid `<svg>…</svg>` markup\n * @returns the newly created SVG element\n */\nfunction createSvgElementFromString(svgText: string): SVGElement {\n\tconst parser = new DOMParser();\n\tconst doc = parser.parseFromString(svgText, 'image/svg+xml');\n\tconst svg = doc.querySelector('svg');\n\tif (!svg) throw new Error('Failed to parse SVG string.');\n\treturn svg;\n}\n\nexport default {\n\tisLocalEnvironment,\n\tcopyToClipboard,\n\tisMouseSupported,\n\tisTouchSupported,\n\tgetLastSegmentOfURL,\n\tgetPathnameFromHref,\n\tgetCookieValue,\n\tupdateCookie,\n\tdeleteCookie,\n\tcreateSvgElementFromString,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/util/httputils.ts",
    "content": "// src/client/scripts/esm/util/httputils.ts\n\n/**\n * This script contains http/fetch utility methods.\n */\n\n/** Options for {@link retryFetch} */\ninterface RetryFetchOptions {\n\t/**\n\t * Maximum number of fetch attempts (e.g., 1 means one attempt, no retries).\n\t * Assumed to be 1 or greater. Defaults to 3.\n\t */\n\tmaxAttempts?: number;\n\t/**\n\t * Initial delay in milliseconds *before the first retry* (i.e., after the first attempt fails).\n\t * Defaults to 1000ms.\n\t */\n\tinitialDelayMs?: number;\n\t/**\n\t * Factor by which the delay increases after each retry (e.g., 2 for exponential, 1 for linear).\n\t * Defaults to 2.\n\t */\n\tbackoffFactor?: number;\n}\n\n/** Default options for {@link retryFetch} */\nconst defaultRetryFetchOptions: Required<RetryFetchOptions> = {\n\tmaxAttempts: 3,\n\tinitialDelayMs: 1000,\n\tbackoffFactor: 2,\n};\n\n/**\n * A wrapper around fetch that provides retry logic.\n * Retries on network errors and 5xx server errors.\n * Terminates on client errors 4xx.\n * @param url The URL to fetch. Can be a string, URL object, or Request object.\n * @param fetchInit The init object for the fetch call (method, headers, body, etc.).\n * @param retryOptions Configuration for the retry behavior.\n * @returns A Promise that resolves with the Response if:\n *          - The request is successful (e.g., 2xx).\n *          - The request results in a non-retryable error (e.g., 4xx).\n *          - Retries are exhausted, and the last attempt resulted in a retryable server error (5xx response).\n * @throws An Error if:\n *         - Retries are exhausted, and the last attempt resulted in a network error.\n */\nasync function retryFetch(\n\turl: string | URL | Request,\n\tfetchInit?: RequestInit,\n\tretryOptions?: RetryFetchOptions,\n): Promise<Response> {\n\tconst options: Required<RetryFetchOptions> = {\n\t\t...defaultRetryFetchOptions,\n\t\t...retryOptions,\n\t};\n\n\tlet currentDelayMs = options.initialDelayMs;\n\t// Helper for logging the URL\n\tconst getUrlString = (targetUrl: typeof url): string => {\n\t\tif (typeof targetUrl === 'string') return targetUrl;\n\t\tif (targetUrl instanceof URL) return targetUrl.href;\n\t\tif (targetUrl instanceof Request) return targetUrl.url;\n\t\treturn 'Unknown URL';\n\t};\n\tconst urlString = getUrlString(url);\n\n\tfor (let attempt = 1; attempt <= options.maxAttempts; attempt++) {\n\t\tconst isLastAttempt = attempt === options.maxAttempts;\n\n\t\ttry {\n\t\t\t// console.log(`retryFetch: Attempt ${attempt}/${options.maxAttempts} for ${urlString}...`);\n\t\t\tconst response = await fetch(url, fetchInit);\n\n\t\t\t// Check for retryable server errors (5xx)\n\t\t\tif (response.status >= 500 && response.status <= 599) {\n\t\t\t\tif (isLastAttempt) {\n\t\t\t\t\t// console.warn(`retryFetch: Max attempts reached. Last attempt for ${urlString} resulted in status ${response.status}.`);\n\t\t\t\t\treturn response; // Return the final 5xx response\n\t\t\t\t}\n\t\t\t\t// Not the last attempt, so log and prepare for retry\n\t\t\t\t// console.warn(`retryFetch: Attempt ${attempt} for ${urlString} failed with status ${response.status}. Retrying...`);\n\t\t\t\t// Fall through to wait and retry\n\t\t\t} else {\n\t\t\t\t// Not a 5xx error. Could be 2xx (success), 4xx (client error), or other.\n\t\t\t\t// No retry for these based on the hardcoded logic.\n\t\t\t\treturn response;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\t// Network error occurred\n\t\t\tif (isLastAttempt) {\n\t\t\t\t// console.error(`retryFetch: Max attempts reached. Last attempt for ${urlString} failed with network error:`, error);\n\t\t\t\tthrow error; // Re-throw the final network error\n\t\t\t}\n\t\t\t// Not the last attempt, so log and prepare for retry\n\t\t\t// console.warn(`retryFetch: Attempt ${attempt} for ${urlString} failed with network error: ${(error as Error).message}. Retrying...`);\n\t\t\t// Fall through to wait and retry\n\t\t}\n\n\t\t// If we reach here, a retry is scheduled (and it's not the last attempt)\n\t\tawait new Promise((resolve) => setTimeout(resolve, currentDelayMs));\n\t\tcurrentDelayMs *= options.backoffFactor;\n\t}\n\n\t// This line should be theoretically unreachable if options.maxAttempts >= 1,\n\t// as the loop will always return or throw on its final iteration.\n\t// It's included for defensive programming in case of unexpected state.\n\tthrow new Error(\n\t\t`retryFetch: Exited retry loop unexpectedly for ${urlString}. This should not happen if maxAttempts >= 1.`,\n\t);\n}\n\nexport { retryFetch };\n\nexport type { RetryFetchOptions };\n"
  },
  {
    "path": "src/client/scripts/esm/util/indexeddb.unit.test.ts",
    "content": "// src/client/scripts/esm/util/indexeddb.unit.test.ts\n\n/**\n * Functional tests for the IndexedDB storage module using a simulated IDB.\n * Uses fake-indexeddb and the module's resetDBInstance() for isolation.\n */\n\nimport { IDBFactory, IDBKeyRange } from 'fake-indexeddb';\nimport { describe, it, expect, beforeEach } from 'vitest';\n\nimport indexeddb from './IndexedDB.js';\n\nbeforeEach(() => {\n\t// Fresh fake IndexedDB and key range per test\n\t(globalThis as any).indexedDB = new IDBFactory();\n\t(globalThis as any).IDBKeyRange = IDBKeyRange;\n\t// Ensure module will open a brand-new DB for this test\n\tindexeddb.resetDBInstance();\n});\n\ndescribe('IndexedDB storage functional behavior', () => {\n\tit('getAllKeys returns [] initially', async () => {\n\t\texpect(await indexeddb.getAllKeys()).toEqual([]);\n\t});\n\n\tit('saves and loads an item', async () => {\n\t\tawait indexeddb.saveItem('pos:1', { fen: 'start' });\n\t\tconst value = await indexeddb.loadItem<{ fen: string }>('pos:1');\n\t\texpect(value).toEqual({ fen: 'start' });\n\t});\n\n\tit('overwrites an existing item with the same key', async () => {\n\t\tawait indexeddb.saveItem('k', 'one');\n\t\tawait indexeddb.saveItem('k', 'two');\n\t\tconst value = await indexeddb.loadItem<string>('k');\n\t\texpect(value).toBe('two');\n\t});\n\n\tit('returns undefined for a missing key', async () => {\n\t\tconst value = await indexeddb.loadItem('missing');\n\t\texpect(value).toBeUndefined();\n\t});\n\n\tit('deletes an item', async () => {\n\t\tawait indexeddb.saveItem('x', 123);\n\t\tawait indexeddb.deleteItem('x');\n\t\tconst value = await indexeddb.loadItem('x');\n\t\texpect(value).toBeUndefined();\n\t});\n\n\tit('delete of a missing key resolves (no error)', async () => {\n\t\tawait expect(indexeddb.deleteItem('nope')).resolves.toBeUndefined();\n\t});\n\n\tit('getAllKeys returns the current keys only', async () => {\n\t\tawait indexeddb.saveItem('a', 1);\n\t\tawait indexeddb.saveItem('b', 2);\n\t\tawait indexeddb.deleteItem('a');\n\t\tconst keys = await indexeddb.getAllKeys();\n\t\texpect(keys.sort()).toEqual(['b']);\n\t});\n\n\tit('eraseAll clears all items', async () => {\n\t\tawait indexeddb.saveItem('a', 1);\n\t\tawait indexeddb.saveItem('b', 2);\n\t\tawait indexeddb.eraseAll();\n\t\texpect(await indexeddb.getAllKeys()).toEqual([]);\n\t});\n\n\tit('handles concurrent writes and reads', async () => {\n\t\tconst writes = Array.from({ length: 50 }, (_, i) => indexeddb.saveItem(`k${i}`, { v: i }));\n\t\tawait Promise.all(writes);\n\n\t\tconst keys = await indexeddb.getAllKeys();\n\t\tconst numericSorted = [...keys].sort(\n\t\t\t(a, b) => parseInt(a.slice(1), 10) - parseInt(b.slice(1), 10),\n\t\t);\n\t\texpect(numericSorted).toEqual(Array.from({ length: 50 }, (_, i) => `k${i}`));\n\n\t\tconst reads = await Promise.all([\n\t\t\tindexeddb.loadItem('k0'),\n\t\t\tindexeddb.loadItem('k25'),\n\t\t\tindexeddb.loadItem('k49'),\n\t\t]);\n\t\texpect(reads).toEqual([{ v: 0 }, { v: 25 }, { v: 49 }]);\n\t});\n\n\tit('resetDBInstance causes a fresh database (previous keys gone)', async () => {\n\t\tawait indexeddb.saveItem('temp', 42);\n\t\texpect(await indexeddb.getAllKeys()).toEqual(['temp']);\n\n\t\t// Simulate a fresh environment\n\t\tindexeddb.resetDBInstance();\n\t\t(globalThis as any).indexedDB = new IDBFactory();\n\t\t(globalThis as any).IDBKeyRange = IDBKeyRange;\n\n\t\t// New open should yield empty store\n\t\texpect(await indexeddb.getAllKeys()).toEqual([]);\n\t});\n\n\tit('saves an item with custom expiry time', async () => {\n\t\tconst expiryMillis = 10000; // 10 seconds\n\t\tawait indexeddb.saveItem('k', 'value', expiryMillis);\n\t\tconst value = await indexeddb.loadItem<string>('k');\n\t\texpect(value).toBe('value');\n\t});\n\n\tit('auto-deletes expired items on load', async () => {\n\t\tconst shortExpiry = 1; // 1 millisecond\n\t\tawait indexeddb.saveItem('expiring', 'test', shortExpiry);\n\n\t\t// Wait for expiry\n\t\tawait new Promise((resolve) => setTimeout(resolve, 10));\n\n\t\t// loadItem should delete the expired item and return undefined\n\t\tconst value = await indexeddb.loadItem('expiring');\n\t\texpect(value).toBeUndefined();\n\n\t\t// Key should be deleted\n\t\tconst keys = await indexeddb.getAllKeys();\n\t\texpect(keys).not.toContain('expiring');\n\t});\n\n\tit('eraseExpiredItems removes only expired items', async () => {\n\t\tconst shortExpiry = 1; // 1 millisecond\n\t\tconst longExpiry = 60000; // 60 seconds\n\n\t\tawait indexeddb.saveItem('expired1', 'test1', shortExpiry);\n\t\tawait indexeddb.saveItem('expired2', 'test2', shortExpiry);\n\t\tawait indexeddb.saveItem('valid', 'test3', longExpiry);\n\n\t\t// Wait for short-lived items to expire\n\t\tawait new Promise((resolve) => setTimeout(resolve, 10));\n\n\t\tawait indexeddb.eraseExpiredItems();\n\n\t\tconst keys = await indexeddb.getAllKeys();\n\t\texpect(keys).toEqual(['valid']);\n\n\t\tconst validValue = await indexeddb.loadItem('valid');\n\t\texpect(validValue).toBe('test3');\n\t});\n\n\tit('handles items saved without expiry (old format)', async () => {\n\t\t// First save an item normally to ensure DB is initialized\n\t\tawait indexeddb.saveItem('temp', 'temp');\n\n\t\t// Manually save an item in the old format (without expiry) by directly accessing IDB\n\t\tawait new Promise<void>((resolve, reject) => {\n\t\t\tconst request = (globalThis as any).indexedDB.open(\n\t\t\t\tindexeddb.DB_NAME,\n\t\t\t\tindexeddb.DB_VERSION,\n\t\t\t);\n\t\t\trequest.onsuccess = () => {\n\t\t\t\tconst db = request.result;\n\t\t\t\tconst tx = db.transaction([indexeddb.STORE_NAME], 'readwrite');\n\t\t\t\tconst store = tx.objectStore(indexeddb.STORE_NAME);\n\t\t\t\t// Save old format: just the value, no wrapper object\n\t\t\t\tstore.put('old-value', 'old-key');\n\t\t\t\ttx.oncomplete = () => {\n\t\t\t\t\tdb.close();\n\t\t\t\t\tresolve();\n\t\t\t\t};\n\t\t\t\ttx.onerror = () => reject(tx.error);\n\t\t\t};\n\t\t\trequest.onerror = () => reject(request.error);\n\t\t});\n\n\t\t// Reset to get fresh connection\n\t\tindexeddb.resetDBInstance();\n\n\t\t// loadItem should delete the old format item and return undefined\n\t\tconst value = await indexeddb.loadItem('old-key');\n\t\texpect(value).toBeUndefined();\n\n\t\t// Verify it was deleted\n\t\tconst keys = await indexeddb.getAllKeys();\n\t\texpect(keys).not.toContain('old-key');\n\t});\n});\n"
  },
  {
    "path": "src/client/scripts/esm/util/mouse.ts",
    "content": "// src/client/scripts/esm/util/mouse.ts\n\n/**\n * This script contains several wrappers for getting the\n * mouse position, world space, or coordinates,\n * reading the correct listener depending on whether we're in perspective mode or not.\n */\n\nimport type { BDCoords, Coords, DoubleCoords } from '../../../../shared/chess/util/coordutil.js';\n\nimport space from '../game/misc/space.js';\nimport camera from '../game/rendering/camera.js';\nimport perspective from '../game/rendering/perspective.js';\nimport { listener_document, listener_overlay } from '../game/chess/game.js';\nimport input, { InputListener, Mouse, MouseButton } from '../game/input.js';\n\n/**\n * This is capable of getting the mouse position, EVEN IF\n * it is off screen! Only the document's event listener is capable\n * of receiving 'mousemove' events when the mouse is off screen.\n *\n * If another pointer id is used, such as a touch event, we cannot\n * detect the mouse position when it is off screen.\n *\n * ONLY WORKS IF WE LEFT-CLICK-DRAG off the screen. NOT if we right-click-drag!\n */\nfunction getPhysicalPointerPosition_Offscreen(physicalPointerId: string): DoubleCoords | undefined {\n\tif (physicalPointerId === 'mouse') {\n\t\t// The mouse on the document is sensitive to 'mousemove' events even when the mouse is outside the element/window.\n\t\t// This allows us to continue dragging the board/piece even when the mouse is outside the window.\n\t\tconst mousePos = listener_document.getPhysicalPointerPos(physicalPointerId);\n\t\tif (!mousePos) return undefined;\n\t\t// Make the coordinates relative to the element instead of the document.\n\t\treturn input.getRelativeMousePosition(mousePos, listener_overlay.element);\n\t} else {\n\t\treturn listener_overlay.getPhysicalPointerPos(physicalPointerId);\n\t}\n}\n\n/**\n * Returns the world space coordinates of the mouse pointer,\n * or the crosshair if the mouse is locked (in perspective mode).\n */\nfunction getMouseWorld(button: MouseButton = Mouse.LEFT): DoubleCoords | undefined {\n\tif (!perspective.getEnabled()) {\n\t\tconst physicalPointerId = listener_overlay.getMousePhysicalId(button);\n\t\tif (!physicalPointerId) return undefined;\n\t\tlet mousePos = getPhysicalPointerPosition_Offscreen(physicalPointerId);\n\t\tif (!mousePos) {\n\t\t\t// Pointer likely doesn't exist anymore (touch event lifted).\n\t\t\t// This will return its last known position.\n\t\t\tmousePos = listener_overlay.getMousePosition(button);\n\t\t}\n\t\tif (!mousePos) return undefined;\n\t\treturn convertMousePositionToWorldSpace(mousePos, listener_overlay.element);\n\t} else return getCrossHairWorld(); // Mouse is locked, we must be in perspective mode. Calculate the mouse world according to the crosshair location instead.\n}\n\n/**\n * Returns the world space coordinates of the given pointer,\n * or the crosshair if in perspective mode.\n */\nfunction getPointerWorld(pointerId: string): DoubleCoords | undefined {\n\tif (!perspective.getEnabled()) {\n\t\tconst physicalPointerId = listener_overlay.getPhysicalPointerIdOfPointer(pointerId);\n\t\tif (!physicalPointerId) return undefined;\n\t\tconst pointerPos = getPhysicalPointerPosition_Offscreen(physicalPointerId);\n\t\tif (!pointerPos) return undefined;\n\t\treturn convertMousePositionToWorldSpace(pointerPos, listener_overlay.element);\n\t} else return getCrossHairWorld(); // Mouse is locked, we must be in perspective mode. Calculate the mouse world according to the crosshair location instead.\n}\n\n/**\n * Returns the world space coordinates of a PHYSICAL pointer,\n * or the crosshair if in perspective mode.\n */\nfunction getPhysicalPointerWorld(physicalPointerId: string): DoubleCoords | undefined {\n\tif (!perspective.getEnabled()) {\n\t\tconst pointerPos = getPhysicalPointerPosition_Offscreen(physicalPointerId);\n\t\tif (!pointerPos) return undefined;\n\t\treturn convertMousePositionToWorldSpace(pointerPos, listener_overlay.element);\n\t} else return getCrossHairWorld(); // Mouse is locked, we must be in perspective mode. Calculate the mouse world according to the crosshair location instead.\n}\n\n/**\n * Returns the world position of the crosshair, dependant on perspective mode rotations.\n * May only return undefined in the case we're looking into the sky.\n */\nfunction getCrossHairWorld(): DoubleCoords | undefined {\n\tif (perspective.isLookingUp()) return;\n\n\tconst rotX = (Math.PI / 180) * perspective.getRotX();\n\tconst rotZ = (Math.PI / 180) * perspective.getRotZ();\n\n\t// Calculate intersection point\n\tconst hyp = -Math.tan(rotX) * camera.getPosition()[2];\n\n\t// x^2 + y^2 = hyp^2\n\t// hyp = sqrt( x^2 + y^2 )\n\n\tconst mouseWorld: DoubleCoords = [hyp * Math.sin(rotZ), hyp * Math.cos(rotZ)];\n\n\t// console.log(mouseWorld);\n\treturn mouseWorld;\n}\n\nfunction convertMousePositionToWorldSpace(\n\tmouse: DoubleCoords,\n\telement: HTMLElement | typeof document,\n): DoubleCoords {\n\tconst mouseCopy: DoubleCoords = [...mouse];\n\tconst screenBox = camera.getScreenBoundingBox();\n\tconst screenWidth = screenBox.right - screenBox.left;\n\tconst screenHeight = screenBox.top - screenBox.bottom;\n\tconst clientWidth = element instanceof HTMLElement ? element.clientWidth : window.innerWidth;\n\tconst clientHeight = element instanceof HTMLElement ? element.clientHeight : window.innerHeight;\n\t// The world space coordinates are sensitive to whether we're viewing white's or black's perspective.\n\t// prettier-ignore\n\tconst mouseWorldSpace: DoubleCoords = perspective.getIsViewingBlackPerspective() ? [\n\t\tscreenBox.right - (mouseCopy[0] / clientWidth) * screenWidth,\n\t\t// [0,0] is the top LEFT corner of the screen, according to mouse coordinates.\n\t\tscreenBox.bottom + (mouseCopy[1] / clientHeight) * screenHeight,\n\t] : [\n\t\tscreenBox.left + (mouseCopy[0] / clientWidth) * screenWidth,\n\t\t// [0,0] is the top LEFT corner of the screen, according to mouse coordinates.\n\t\tscreenBox.top - (mouseCopy[1] / clientHeight) * screenHeight,\n\t];\n\treturn mouseWorldSpace;\n}\n\nfunction getTileMouseOver_Float(button: MouseButton = Mouse.LEFT): BDCoords | undefined {\n\tconst mouseWorld = getMouseWorld(button);\n\tif (!mouseWorld) return undefined;\n\treturn space.convertWorldSpaceToCoords(mouseWorld);\n}\n\nfunction getTileMouseOver_Integer(button: MouseButton = Mouse.LEFT): Coords | undefined {\n\tconst mouseWorld = getMouseWorld(button);\n\tif (!mouseWorld) return undefined;\n\treturn space.convertWorldSpaceToCoords_Rounded(mouseWorld);\n}\n\n/** Returns the floating point tile the given LOGICAL pointer is over. */\nfunction getTilePointerOver_Float(pointerId: string): BDCoords | undefined {\n\tconst physicalPointerId = listener_overlay.getPhysicalPointerIdOfPointer(pointerId);\n\tif (!physicalPointerId) return;\n\t// const pointerCoords = listener_overlay.getPointerPos(pointerId)!;\n\tconst pointerCoords = getPhysicalPointerPosition_Offscreen(physicalPointerId);\n\tif (!pointerCoords) return undefined;\n\n\tconst pointerWorld = convertMousePositionToWorldSpace(pointerCoords, listener_overlay.element);\n\treturn space.convertWorldSpaceToCoords(pointerWorld);\n}\n\n/** Gets the given pointer's current coordinates being hovered over, rounded to the integer square. */\nfunction getTilePointerOver_Integer(pointerId: string): Coords | undefined {\n\tconst pointerWorld: DoubleCoords | undefined = getPointerWorld(pointerId);\n\tif (!pointerWorld) return undefined;\n\treturn space.convertWorldSpaceToCoords_Rounded(pointerWorld);\n}\n\n/**\n * Wrapper for reading the correct listener for whether the mouse button is down,\n * depending on whether we're in perspective mode or not.\n */\nfunction isMouseDown(button: MouseButton): boolean {\n\tif (perspective.isMouseLocked()) return listener_document.isMouseDown(button);\n\telse return listener_overlay.isMouseDown(button);\n}\n\n/**\n * Wrapper for reading the correct listener for whether the mouse button is held,\n * depending on whether we're in perspective mode or not.\n */\nfunction isMouseHeld(button: MouseButton): boolean {\n\tif (perspective.isMouseLocked()) return listener_document.isMouseHeld(button);\n\telse return listener_overlay.isMouseHeld(button);\n}\n\n/**\n * Wrapper for reading the correct listener for whether the mouse button was click simulated,\n * depending on whether we're in perspective mode or not.\n */\nfunction isMouseClicked(button: MouseButton): boolean {\n\tif (perspective.isMouseLocked()) return listener_document.isMouseClicked(button);\n\telse return listener_overlay.isMouseClicked(button);\n}\n\n/**\n * Wrapper for reading the correct listener for whether the mouse button was double-click simulated,\n * depending on whether we're in perspective mode or not.\n */\nfunction isMouseDoubleClickDragged(button: MouseButton): boolean {\n\tif (perspective.isMouseLocked()) return listener_document.isMouseDoubleClickDragged(button);\n\telse return listener_overlay.isMouseDoubleClickDragged(button);\n}\n\n// /**\n//  * Wrapper for reading the correct listener for if the most recent\n//  * pointer for a specific mouse button action is a touch (not mouse),\n//  * depending on whether we're in perspective mode or not.\n//  */\n// function isMouseTouch(button: MouseButton): boolean {\n// \tif (perspective.isMouseLocked()) return listener_document.isMouseTouch(button);\n// \telse return listener_overlay.isMouseTouch(button);\n// }\n\n/**\n * Wrapper for reading the correct listener for the mouse wheel delta,\n * depending on whether the mouse is locked or not (perspective mode).\n */\nfunction getWheelDelta(): number {\n\tif (perspective.isMouseLocked()) return listener_document.getWheelDelta();\n\telse return listener_overlay.getWheelDelta();\n}\n\n/**\n * Wrapper for reading the correct listener for claiming the mouse down event,\n * depending on whether we're in perspective mode or not.\n */\nfunction claimMouseDown(button: MouseButton): void {\n\tif (perspective.isMouseLocked()) listener_document.claimMouseDown(button);\n\telse listener_overlay.claimMouseDown(button);\n}\n\n/**\n * Wrapper for reading the correct listener for claiming the mouse click event,\n * depending on whether we're in perspective mode or not.\n */\nfunction claimMouseClick(button: MouseButton): void {\n\tif (perspective.isMouseLocked()) listener_document.claimMouseClick(button);\n\telse listener_overlay.claimMouseClick(button);\n}\n\n/**\n * Wrapper for reading the correct listener for canceling the mouse click event,\n * depending on whether we're in perspective mode or not.\n */\nfunction cancelMouseClick(button: MouseButton): void {\n\tif (perspective.isMouseLocked()) listener_document.cancelMouseClick(button);\n\telse listener_overlay.cancelMouseClick(button);\n}\n\n/**\n * Wrapper for reading the correct listener for getting the mose recent mouse id\n * that performed the specified action, depending on whether we're in perspective mode or not.\n */\nfunction getMouseId(button: MouseButton): string | undefined {\n\tif (perspective.isMouseLocked()) return listener_document.getMouseId(button);\n\telse return listener_overlay.getMouseId(button);\n}\n\n/**\n * Returns the relevant listener for the mouse events,\n * depending on whether we're in perspective mode or not.\n */\nfunction getRelevantListener(): InputListener {\n\tif (perspective.getEnabled()) return listener_document;\n\telse return listener_overlay;\n}\n\n/**\n * Returns all the existing PHYSICAL pointers' world\n * coordinates, depending on the relevant listener.\n */\nfunction getAllPointerWorlds(): DoubleCoords[] {\n\tconst allPhysicalPointerIds = getRelevantListener().getAllPhysicalPointers();\n\tconst pointerWorlds: DoubleCoords[] = [];\n\tfor (const id of allPhysicalPointerIds) {\n\t\tconst world = getPhysicalPointerWorld(id);\n\t\t// Only push them if their world coordinates exist (won't if looking into sky)\n\t\tif (world) pointerWorlds.push(world);\n\t}\n\treturn pointerWorlds;\n}\n\nexport default {\n\tgetMouseWorld,\n\tgetPointerWorld,\n\tgetPhysicalPointerWorld,\n\tconvertMousePositionToWorldSpace,\n\tgetTileMouseOver_Float,\n\tgetTileMouseOver_Integer,\n\tgetTilePointerOver_Float,\n\tgetTilePointerOver_Integer,\n\tisMouseDown,\n\tisMouseHeld,\n\tisMouseClicked,\n\tisMouseDoubleClickDragged,\n\t// isMouseTouch,\n\tgetWheelDelta,\n\tclaimMouseDown,\n\tclaimMouseClick,\n\tcancelMouseClick,\n\tgetMouseId,\n\tgetRelevantListener,\n\tgetAllPointerWorlds,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/util/pingManager.ts",
    "content": "// src/client/scripts/esm/util/pingManager.ts\n\n/**\n * PingManager\n * Manages the current ping value and handles events related to ping updates and socket closures.\n *\n * This script is only used for subtracting the ping value from the clock values the server reported.\n */\n\n// Variables -------------------------------------------------------------\n\nlet currentPing: number = 0; // Stores the current ping value\n\nconst MAX_PING_HISTORY: number = 3; // Maximum number of ping history entries to store\nconst pingHistory: number[] = []; // Stores the last 'MAX_PING_HISTORY' ping values\n\n// Functions -------------------------------------------------------------\n\n// Initialize event listeners for ping and socket-closed events\n(function init(): void {\n\tdocument.addEventListener('ping', handlePingUpdate);\n\tdocument.addEventListener('socket-closed', handleSocketClosed);\n})();\n\n/**\n * Event handler for the 'ping' event.\n * Updates the current ping value and appends it to the history.\n * @param event - The 'ping' event with the new ping value in event.detail.\n */\nfunction handlePingUpdate(event: CustomEvent<number>): void {\n\tcurrentPing = event.detail;\n\tupdatePingHistory(currentPing);\n}\n\n/**\n * Event handler for the 'socket-closed' event.\n * Resets the current ping value without clearing the ping history.\n * @param event - The 'socket-closed' event.\n */\nfunction handleSocketClosed(_event: CustomEvent<void>): void {\n\tcurrentPing = 0;\n}\n\n/**\n * Updates the ping history with the latest ping value.\n * Ensures that only the last 'MAX_PING_HISTORY' ping values are kept in the history.\n * @param ping - The latest ping value.\n */\nfunction updatePingHistory(ping: number): void {\n\tpingHistory.push(ping);\n\tif (pingHistory.length > MAX_PING_HISTORY) pingHistory.shift(); // Remove the oldest value if history exceeds MAX_PING_HISTORY\n}\n\n/**\n * Getter for the current ping value.\n * @returns The current ping value or 0 if no ping is stored.\n */\nfunction getPing(): number {\n\treturn currentPing;\n}\n\n/**\n * Returns half the current ping value. This will approximately\n * be the time it takes for a one-way websocket message.\n * @returns The current ping value or 0 if no ping is stored.\n */\nfunction getHalfPing(): number {\n\treturn currentPing / 2;\n}\n\n/**\n * Getter for the average ping value over the last 'MAX_PING_HISTORY' pings.\n * @returns The average ping value or 0 if there is no history.\n */\nfunction getAveragePing(): number {\n\tif (pingHistory.length === 0) return 0;\n\tconst sum = pingHistory.reduce((acc, ping) => acc + ping, 0);\n\treturn sum / pingHistory.length;\n}\n\n// ---------------------------------------------------------------------\n\nexport default {\n\tgetPing,\n\tgetHalfPing,\n\tgetAveragePing,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/util/splines.ts",
    "content": "// src/client/scripts/esm/util/splines.ts\n\n/**\n * This script contains utility methods for working with splines.\n */\n\nimport type { Color } from '../../../../shared/util/math/math.js';\nimport type { BDCoords, Coords, DoubleCoords } from '../../../../shared/chess/util/coordutil.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport space from '../game/misc/space.js';\nimport boardpos from '../game/rendering/boardpos.js';\nimport { createRenderable } from '../webgl/Renderable.js';\n\n// Constants ------------------------------------------------------\n\nconst ZERO = bd.fromBigInt(0n);\nconst ONE = bd.fromBigInt(1n);\nconst TWO = bd.fromBigInt(2n);\nconst THREE = bd.fromBigInt(3n);\nconst FOUR = bd.fromBigInt(4n);\n\n// Functions ---------------------------------------------------------------\n\n/**\n * Computes a natural cubic spline for a given set of points.\n * @param points - Array of y-values representing the points to interpolate.\n * @returns Array of spline coefficients (a, b, c, d) for each segment.\n */\nfunction generateCubicSplineCoefficients(\n\tpoints: bigint[],\n): { a: BigDecimal; b: BigDecimal; c: BigDecimal; d: BigDecimal }[] {\n\tconst n = points.length;\n\tif (n < 2) return [];\n\n\tconst a: BigDecimal[] = points.slice(0, -1).map((p) => bd.fromBigInt(p));\n\tconst b: BigDecimal[] = new Array(n - 1).fill(ZERO);\n\tconst c: BigDecimal[] = new Array(n).fill(ZERO);\n\tconst d: BigDecimal[] = new Array(n - 1).fill(ZERO);\n\n\tif (n === 2) {\n\t\tb[0] = bd.fromBigInt(points[1]! - points[0]!);\n\t\treturn [{ a: a[0]!, b: b[0]!, c: c[0]!, d: d[0]! }];\n\t}\n\n\t// Setup tridiagonal system\n\tconst rhs: BigDecimal[] = [];\n\tfor (let i = 0; i < n - 2; i++) {\n\t\trhs.push(bd.fromBigInt(3n * (points[i]! + points[i + 2]! - 2n * points[i + 1]!)));\n\t}\n\n\tconst subDiag: BigDecimal[] = new Array(n - 3).fill(ONE);\n\tconst mainDiag: BigDecimal[] = new Array(n - 2).fill(FOUR);\n\tconst superDiag: BigDecimal[] = new Array(n - 3).fill(ONE);\n\tconst cSolution = thomasAlgorithm(subDiag, mainDiag, superDiag, rhs);\n\n\tfor (let i = 1; i <= n - 2; i++) c[i] = cSolution[i - 1]!;\n\n\t// Compute d and b coefficients\n\tfor (let i = 0; i < n - 1; i++) {\n\t\td[i] = bd.divide(bd.subtract(c[i + 1]!, c[i]!), THREE); // d[i] = (c[i + 1] - c[i]) / 3;\n\t\t// (points[i + 1]! - points[i]!) - (2 * c[i]! + c[i + 1]!) / 3\n\t\tconst b_subtrahend = bd.fromBigInt(points[i + 1]! - points[i]!); // points[i + 1]! - points[i]!\n\t\tconst dividend = bd.add(bd.multiply(c[i]!, TWO), c[i + 1]!); // 2 * c[i]! + c[i + 1]!\n\t\tconst quotient = bd.divide(dividend, THREE); // (2 * c[i]! + c[i + 1]!) / 3\n\t\tb[i] = bd.subtract(b_subtrahend, quotient); // // (points[i + 1]! - points[i]!) - (2 * c[i]! + c[i + 1]!) / 3\n\t}\n\n\treturn a.map((aVal, i) => ({ a: aVal, b: b[i]!, c: c[i]!, d: d[i]! }));\n}\n\n/**\n * Solves a tridiagonal system using the Thomas algorithm.\n * @param a - Sub-diagonal coefficients.\n * @param b - Main diagonal coefficients.\n * @param c - Super-diagonal coefficients.\n * @param d - Right-hand side values.\n * @returns Solution array.\n */\nfunction thomasAlgorithm(\n\ta: BigDecimal[],\n\tb: BigDecimal[],\n\tc: BigDecimal[],\n\td: BigDecimal[],\n): BigDecimal[] {\n\tconst n = d.length;\n\tif (n === 0) return [];\n\n\t// Handle the 1x1 system case, which occurs when there are 3 control points.\n\t// In this case, 'a' and 'c' are empty, and 'b' and 'd' have one element.\n\t// The system is simply b[0]*x[0] = d[0], so x[0] = d[0]/b[0].\n\t// Without this, a crash happens if you move the rose 2 hops in one move.\n\tif (n === 1) return [bd.divide(d[0]!, b[0]!)];\n\n\tconst cp: BigDecimal[] = [...c];\n\tconst dp: BigDecimal[] = [...d];\n\n\tcp[0] = bd.divide(cp[0]!, b[0]!);\n\tdp[0] = bd.divide(dp[0]!, b[0]!);\n\n\tfor (let i = 1; i < n; i++) {\n\t\tconst m_denominator = bd.subtract(b[i]!, bd.multiply(a[i - 1]!, cp[i - 1]!)); // (b[i]! - a[i - 1]! * cp[i - 1]!)\n\t\tconst m = bd.divide(ONE, m_denominator); // 1 / (b[i]! - a[i - 1]! * cp[i - 1]!)\n\n\t\tconst c_i = c[i] || ZERO; // Handle case where c might be shorter\n\t\tcp[i] = bd.multiply(c_i, m); // (c[i] || 0) * m\n\n\t\tconst dp_subtrahend = bd.multiply(a[i - 1]!, dp[i - 1]!);\n\t\tconst dp_term = bd.subtract(d[i]!, dp_subtrahend);\n\t\tdp[i] = bd.multiply(dp_term, m);\n\t}\n\n\tfor (let i = n - 2; i >= 0; i--) {\n\t\tconst subtractor = bd.multiply(cp[i]!, dp[i + 1]!);\n\t\tdp[i] = bd.subtract(dp[i]!, subtractor);\n\t}\n\n\treturn dp;\n}\n\n/**\n * Evaluates the cubic spline at a given parameter t.\n * @param t - Parameter value.\n * @param coefficients - Array of spline coefficients.\n * @returns Interpolated value.\n */\nfunction evaluateSplineAt(\n\tt: number,\n\tcoefficients: { a: BigDecimal; b: BigDecimal; c: BigDecimal; d: BigDecimal }[],\n): BigDecimal {\n\tconst i = Math.max(0, Math.min(coefficients.length - 1, Math.floor(t)));\n\tconst { a, b, c, d } = coefficients[i]!;\n\n\t// Convert dt to a BigDecimal for high-precision calculations\n\tconst dt = bd.fromNumber(t - i);\n\tconst dt2 = bd.multiply(dt, dt);\n\tconst dt3 = bd.multiply(dt2, dt);\n\n\t// Evaluate polynomial: a + b*dt + c*dt^2 + d*dt^3\n\tconst termB = bd.multiply(b, dt);\n\tconst termC = bd.multiply(c, dt2);\n\tconst termD = bd.multiply(d, dt3);\n\n\treturn bd.add(a, bd.add(termB, bd.add(termC, termD)));\n}\n\n/**\n * Computes an interpolated trajectory along a cubic spline, generating a smooth path through given control points.\n * @param controlPoints - Array of 2D coordinate points defining the spline. The points of a spline are often called \"knots\" or \"control points\".\n * @param resolution - Number of interpolated points between each pair of control points.\n * @returns An array of interpolated points along the spline.\n */\nfunction generateSplinePath(controlPoints: Coords[], resolution: number): BDCoords[] {\n\t// A straight line already has infinite precision\n\tif (controlPoints.length < 3)\n\t\treturn controlPoints.map(([x, y]) => [bd.fromBigInt(x), bd.fromBigInt(y)]);\n\n\t// Extract the bigint x and y components into separate arrays.\n\tconst xPoints = controlPoints.map((point) => point[0]);\n\tconst yPoints = controlPoints.map((point) => point[1]);\n\n\t// Generate the spline coefficients for each axis.\n\tconst xSpline = generateCubicSplineCoefficients(xPoints);\n\tconst ySpline = generateCubicSplineCoefficients(yPoints);\n\n\tconst path: BDCoords[] = [];\n\tconst totalSegments = controlPoints.length - 1;\n\n\t// Loop through each segment of the spline.\n\tfor (let i = 0; i < totalSegments; i++) {\n\t\tconst isLastSegment = i === totalSegments - 1;\n\n\t\t// Interpolate points within the current segment.\n\t\tfor (let k = 0; k <= resolution; k++) {\n\t\t\t// To avoid duplicating points, skip the end of a segment if it's not the final one.\n\t\t\tif (!isLastSegment && k === resolution) continue;\n\n\t\t\t// 't' is the parameter for spline evaluation, ranging from 0 to n-1.\n\t\t\tconst t = i + k / resolution;\n\n\t\t\tlet x: BigDecimal;\n\t\t\tlet y: BigDecimal;\n\n\t\t\t/**\n\t\t\t * For the very last point, use the exact control point value to guarantee\n\t\t\t * it matches the input, avoiding any potential floating-point drift from 't'.\n\t\t\t *\n\t\t\t * A bug is created when the animation manager\n\t\t\t * expects there to be a piece at the last waypoint, but the last\n\t\t\t * waypoint isn't an integer because of floating point imprecision.\n\t\t\t *\n\t\t\t * This hasn't been tested again since converting to BigDecimals.\n\t\t\t */\n\t\t\tif (isLastSegment && k === resolution) {\n\t\t\t\tconst finalPoint = controlPoints[controlPoints.length - 1]!;\n\t\t\t\tx = bd.fromBigInt(finalPoint[0]);\n\t\t\t\ty = bd.fromBigInt(finalPoint[1]);\n\t\t\t} else {\n\t\t\t\t// Evaluate the spline at parameter 't' to get the interpolated coordinates.\n\t\t\t\tx = evaluateSplineAt(t, xSpline);\n\t\t\t\ty = evaluateSplineAt(t, ySpline);\n\t\t\t}\n\n\t\t\tpath.push([x, y]);\n\t\t}\n\t}\n\n\treturn path;\n}\n\n/**\n * Renders a debug visualization of the spline.\n * All geometric calculations are done in world space for rendering efficiency.\n * @param controlPoints - The spline waypoints as high-precision square coordinates.\n * @param width - The ribbon's desired width, specified in square units.\n * @param color - RGBA color for the ribbon.\n */\nfunction renderSplineDebug(controlPoints: BDCoords[], width: number, color: Color): void {\n\tif (controlPoints.length < 2) throw Error('Spline requires at least 2 waypoints to render.');\n\n\t// Convert all high-precision square coordinates to world-space\n\t// floating-point coordinates immediately so we can perform double arithmetic.\n\tconst worldControlPoints: DoubleCoords[] = controlPoints.map((p) =>\n\t\tspace.convertCoordToWorldSpace(p),\n\t);\n\n\t// Convert the desired width from square units to world units by applying the board scale.\n\tconst scale = boardpos.getBoardScaleAsNumber();\n\tconst halfWorldWidth = (width * scale) / 2;\n\n\tconst vertexData: number[] = [];\n\tconst leftPoints: DoubleCoords[] = [];\n\tconst rightPoints: DoubleCoords[] = [];\n\n\t// Compute left/right offsets per vertex using standard float math in world space.\n\tfor (let i = 0; i < worldControlPoints.length; i++) {\n\t\tconst point = worldControlPoints[i]!;\n\t\tlet tangent: DoubleCoords;\n\n\t\tif (i === 0) {\n\t\t\tconst next = worldControlPoints[i + 1]!;\n\t\t\ttangent = [next[0] - point[0], next[1] - point[1]];\n\t\t} else if (i === worldControlPoints.length - 1) {\n\t\t\tconst prev = worldControlPoints[i - 1]!;\n\t\t\ttangent = [point[0] - prev[0], point[1] - prev[1]];\n\t\t} else {\n\t\t\tconst prev = worldControlPoints[i - 1]!;\n\t\t\tconst next = worldControlPoints[i + 1]!;\n\t\t\ttangent = [next[0] - prev[0], next[1] - prev[1]];\n\t\t}\n\n\t\t// Normalize the tangent vector.\n\t\tconst tLen = Math.hypot(tangent[0], tangent[1]);\n\t\tif (tLen !== 0) {\n\t\t\ttangent = [tangent[0] / tLen, tangent[1] / tLen];\n\t\t} else {\n\t\t\ttangent = [0, 0];\n\t\t}\n\n\t\t// Compute the perpendicular normal vector.\n\t\tconst normal: DoubleCoords = [-tangent[1], tangent[0]];\n\n\t\t// Offset positions in world space to find the ribbon edges.\n\t\tleftPoints.push([\n\t\t\tpoint[0] + normal[0] * halfWorldWidth,\n\t\t\tpoint[1] + normal[1] * halfWorldWidth,\n\t\t]);\n\t\trightPoints.push([\n\t\t\tpoint[0] - normal[0] * halfWorldWidth,\n\t\t\tpoint[1] - normal[1] * halfWorldWidth,\n\t\t]);\n\t}\n\n\t// Build triangles for each segment.\n\tfor (let i = 0; i < worldControlPoints.length - 1; i++) {\n\t\tconst left0 = leftPoints[i]!;\n\t\tconst right0 = rightPoints[i]!;\n\t\tconst left1 = leftPoints[i + 1]!;\n\t\tconst right1 = rightPoints[i + 1]!;\n\n\t\t// Triangle 1: left0, right0, left1\n\t\tvertexData.push(...left0, ...color);\n\t\tvertexData.push(...right0, ...color);\n\t\tvertexData.push(...left1, ...color);\n\n\t\t// Triangle 2: left1, right0, right1\n\t\tvertexData.push(...left1, ...color);\n\t\tvertexData.push(...right0, ...color);\n\t\tvertexData.push(...right1, ...color);\n\t}\n\n\t// Create and render the debug model.\n\tcreateRenderable(vertexData, 2, 'TRIANGLES', 'color', true).render();\n}\n\n// Exports -----------------------------------------------------------------------------------------------------\n\nexport default {\n\tgenerateCubicSplineCoefficients,\n\tevaluateSplineAt,\n\tgenerateSplinePath,\n\trenderSplineDebug,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/util/svgtoimageconverter.ts",
    "content": "// src/client/scripts/esm/util/svgtoimageconverter.ts\n\n/**\n * This script can convert SVG elements into HTMLImageElements.\n *\n * It also can normalize the pixel data of an image by drawing it onto a canvas and re-serializing it.\n */\n\n// Functions --------------------------------------------------------------------------\n\n/** Converts a list of SVGs into a list of HTMLImageElements. Does this in parallel. */\nasync function convertSVGsToImages(svgElements: SVGElement[]): Promise<HTMLImageElement[]> {\n\ttry {\n\t\t// Create an array of promises, where each promise resolves to an HTMLImageElement\n\t\tconst conversionPromises = svgElements.map((svgElement) => svgToImage(svgElement));\n\n\t\t// Wait for all the conversion promises to resolve concurrently\n\t\tconst readyImages = await Promise.all(conversionPromises);\n\n\t\t// Optional: Append the images to the doc for debugging\n\t\t// for (const img of readyImages) {\n\t\t//     document.body.appendChild(img);\n\t\t// }\n\n\t\treturn readyImages;\n\t} catch (e) {\n\t\t// Although we assume individual svgToImage calls resolve, Promise.all itself\n\t\t// could theoretically encounter an issue, or svgToImage might throw a sync error.\n\t\tconsole.error('Error caught during conversion of SVGs to Images:', e);\n\t\treturn []; // Return an empty array in case of unexpected errors\n\t}\n}\n\n/**\n * Converts an SVG element to an Image element by serializing the SVG and creating a data URL.\n * The image does NOT have a specified width or height.\n * @param svgElement - The SVG element to convert into an image.\n * @returns A promise that resolves with the created image element.\n */\nfunction svgToImage(svgElement: SVGElement): Promise<HTMLImageElement> {\n\tconst svgID = svgElement.id; // 'pawnsW'\n\n\t// Serialize the SVG element back to a string\n\tconst svgString = new XMLSerializer().serializeToString(svgElement);\n\n\t// Log the SVG string for debugging purposes\n\t// console.log(\"SVG String: \", svgString);\n\n\t// Create a new image element\n\tconst img = new Image();\n\n\t// Convert SVG string to a data URL using encodeURIComponent for better encoding\n\tconst svgData = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svgString)}`;\n\timg.src = svgData;\n\timg.id = svgID; // Set its ID here so its easy to find it in the document later\n\n\treturn new Promise((resolve, reject): void => {\n\t\timg.onload = (): void => {\n\t\t\t// Append the image to the document for debugging\n\t\t\t// document.body.appendChild(img);\n\t\t\tresolve(img);\n\t\t};\n\t\timg.onerror = (err): void => {\n\t\t\tconsole.error(`Error loading image with ID \"${svgID}\"`, err);\n\t\t\treject(new Error(`Failed to load image with ID \"${svgID}\"`));\n\t\t};\n\t});\n}\n\n/**\n * Normalizes the pixel data of an image by drawing it onto a canvas and re-serializing it.\n * This used for patching a Firefox bug where it unintentionally darkens the image by double-multiplying the RGB channels by the alpha channel.\n *\n * We don't have to do this for the spritesheet images, because the spritesheet generator ALREADY\n * draws the images onto a large canvas and re-serializes them.\n * @param img - The image to normalize.\n * @returns A promise that resolves with the normalized image.\n */\nasync function normalizeImagePixelData(img: HTMLImageElement): Promise<HTMLImageElement> {\n\t/** The image width each piece type's image should be. */\n\tconst IMG_SIZE = 512; // High to retain as much resolution as possible during the drawing and re-serialization.\n\n\t// Proceed with canvas creation\n\tconst canvas = document.createElement('canvas');\n\tcanvas.width = IMG_SIZE;\n\tcanvas.height = IMG_SIZE;\n\tconst ctx = canvas.getContext('2d');\n\tif (ctx === null) throw new Error('2D context null.');\n\n\t// Draw original image\n\tctx.drawImage(img, 0, 0, canvas.width, canvas.height);\n\n\t// Return as standardized image\n\tconst processedImg = new Image();\n\tprocessedImg.src = canvas.toDataURL();\n\tprocessedImg.id = img.id; // Give it the same ID as the original\n\n\t// Wait for the image to load\n\tawait processedImg.decode();\n\n\t// Append the image to the document for debugging\n\t// document.body.appendChild(img);\n\n\treturn processedImg;\n}\n\n// Exports -------------------------------------------------------------------------\n\nexport default {\n\tconvertSVGsToImages,\n\tsvgToImage,\n\tnormalizeImagePixelData,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/util/thread.ts",
    "content": "// src/client/scripts/esm/util/thread.ts\n\n/**\n * This script contains a sleep method for the javascript thread.\n *\n * Javascript is single-threated, when we sleep, we don't actually\n * sleep the thread, but we delay the execution of the current function,\n * to allow other functions on the call stack to be executed before we continue.\n *\n * ZERO dependancies\n */\n\n/**\n * Pauses the current function execution for the given amount of time, allowing\n * other functions in the call stack to execute before it resumes.\n *\n * This function returns a promise that resolves after the specified number of milliseconds.\n * @param ms - The number of milliseconds to sleep before continuing execution.\n * @returns A promise that resolves after the specified delay.\n */\nfunction sleep(ms: number): Promise<void> {\n\treturn new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nexport default {\n\tsleep,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/util/tooltips.ts",
    "content": "// src/client/scripts/esm/util/tooltips.ts\n\n/**\n * JS-based tooltip system using event delegation. A single fixed div is appended to document.body\n * when the user hovers a tooltip element, avoiding any clipping issues from parent containers.\n *\n * A single set of listeners on `document.body` handles all tooltip elements,\n * new elements with tooltips can be added to the document at any time.\n *\n * Tooltip direction is determined by the element's class:\n *   tooltip-d   – below, centered\n *   tooltip-dl  – below, right-aligned to element\n *   tooltip-dr  – below, left-aligned to element\n *   tooltip-u   – above, centered\n *   tooltip-ul  – above, right-aligned to element\n *   tooltip-ur  – above, left-aligned to element\n *\n * Tooltip text comes from the element's data-tooltip attribute.\n */\n\nimport docutil from './docutil.js';\n\n// Variables ----------------------------------------------------------------------------\n\nconst tooltipClasses: string[] = [\n\t'tooltip-dl',\n\t'tooltip-d',\n\t'tooltip-dr',\n\t'tooltip-u',\n\t'tooltip-ul',\n\t'tooltip-ur',\n];\n/** CSS selector matching any element that is a tooltip target (has both a direction class and data-tooltip). */\nconst TOOLTIP_SELECTOR = tooltipClasses.map((cls) => `.${cls}[data-tooltip]`).join(', ');\n\n/** Pixels between the target element edge and the tooltip box. */\nconst TOOLTIP_GAP = 8;\n/**\n * Half the CSS border-width used for the arrow (px). Full arrow size = 2 × ARROW_HALF.\n * MUST match the `border-width` value on `#tooltip-arrow` in header.css.\n */\nconst ARROW_HALF = 5;\n/** Duration (ms) to wait after fading out before removing the tooltip from the DOM.\n * Should be slightly longer than the CSS opacity transition (0.1 s = 100 ms). */\nconst FADE_OUT_REMOVE_DELAY_MS = 150;\n\n/** The delay before a tooltip appears on hover. */\nconst TOOLTIP_DELAY_MILLIS: number = 500;\n/** Time after a click before the tooltip can reappear while still hovering. */\nconst SUPPRESS_COOLDOWN_MILLIS: number = 2000;\n/** If no new tooltip is viewed within this window, fast-transition mode turns off. */\nconst FAST_TRANSITION_COOLDOWN_MILLIS: number = 750;\n\n// State ---------------------------------------------------------------------------------\n\n/** Per-element hover/click state, lazily created on first interaction. */\ninterface TooltipState {\n\tisHovering: boolean;\n\tisHolding: boolean;\n\ttooltipVisible: boolean;\n\t/** Timer to show the tooltip after the hover delay. */\n\thoveringTimer: number | undefined;\n\t/** Timer after which tooltip suppression (from a click) is cleared. */\n\tsuppressTimer: number | undefined;\n\t/** True while the tooltip is temporarily suppressed due to a click. */\n\tsuppressed: boolean;\n}\n\n/** Per-element state map. WeakMap ensures GC when elements are removed from the DOM. */\nconst elementStates = new WeakMap<Element, TooltipState>();\n\n/** If true, tooltips appear immediately without the hover delay. */\nlet fastTransitionMode = false;\n/** Timer ID for turning off fast-transition mode after the cooldown. */\nlet fastTransitionTimeoutID: number | undefined;\n\n/** The shared tooltip box element, created once and reused. */\nlet tooltipDiv: HTMLDivElement | null = null;\n/** The shared arrow element, created once and reused. */\nlet arrowDiv: HTMLDivElement | null = null;\n/** Timer to remove the tooltip elements from the DOM after they fade out. */\nlet hideTimer: number | undefined;\n/** The rAF id for the position-tracking loop, or undefined when not running. */\nlet positionLoopId: number | undefined;\n\n// Functions ----------------------------------------------------------------------------\n\n/** Returns or creates the per-element state for a tooltip target. */\nfunction getOrCreateState(el: Element): TooltipState {\n\tlet state = elementStates.get(el);\n\tif (!state) {\n\t\tstate = {\n\t\t\tisHovering: false,\n\t\t\tisHolding: false,\n\t\t\ttooltipVisible: false,\n\t\t\thoveringTimer: undefined,\n\t\t\tsuppressTimer: undefined,\n\t\t\tsuppressed: false,\n\t\t};\n\t\telementStates.set(el, state);\n\t}\n\treturn state;\n}\n\n/**\n * Returns the nearest ancestor (or self) of `el` that is a tooltip target,\n * or null if none exists. Uses the browser-optimized `Element.closest()`.\n */\nfunction findTooltipAncestor(el: Element | null): HTMLElement | null {\n\treturn el?.closest<HTMLElement>(TOOLTIP_SELECTOR) ?? null;\n}\n\n/**\n * Returns the tooltip direction class of an element (e.g. 'tooltip-d'),\n * or null if the element has none.\n */\nfunction getTooltipClass(element: Element): string | null {\n\treturn tooltipClasses.find((cls) => element.classList.contains(cls)) ?? null;\n}\n\n/** Creates the singleton tooltip box and arrow elements (called once on first use). */\nfunction createTooltipElements(): void {\n\ttooltipDiv = document.createElement('div');\n\ttooltipDiv.id = 'tooltip-popup';\n\n\tarrowDiv = document.createElement('div');\n\tarrowDiv.id = 'tooltip-arrow';\n}\n\n/**\n * Shrinks the tooltip box to the minimum width that still fits the wrapped text,\n * removing excess horizontal padding caused by short last lines.\n * When text fits on a single line, this is a no-op.\n * The element must already be in the DOM.\n */\nfunction shrinkWrapTooltip(el: HTMLDivElement): void {\n\t// Reset any width set by a previous call so CSS takes over.\n\tel.style.width = '';\n\n\t// Measure height at CSS max-width (text may already be wrapped).\n\tconst wrappedHeight = el.offsetHeight;\n\n\t// Temporarily remove the max-width cap to measure the natural (single-line) width.\n\tel.style.maxWidth = 'none';\n\tel.style.width = 'max-content';\n\tconst naturalWidth = el.offsetWidth;\n\n\t// Restore CSS constraints and re-read the capped width.\n\tel.style.width = '';\n\tel.style.maxWidth = '';\n\tconst cappedWidth = el.offsetWidth; // = min(naturalWidth, CSS max-width)\n\n\tif (naturalWidth <= cappedWidth) return; // no wrapping; nothing to shrink\n\n\t// Text wraps. Binary-search for the narrowest box that keeps the same\n\t// rendered height (i.e., the same number of wrapped lines).\n\tlet lo = 0;\n\tlet hi = cappedWidth;\n\twhile (hi - lo > 1) {\n\t\tconst mid = Math.ceil((lo + hi) / 2);\n\t\tel.style.width = `${mid}px`;\n\t\tif (el.offsetHeight <= wrappedHeight) hi = mid;\n\t\telse lo = mid;\n\t}\n\tel.style.width = `${hi}px`;\n}\n\n/** Enables fast-transition mode so the next tooltip appears without delay. */\nfunction enableFastTransition(): void {\n\tif (fastTransitionMode) return; // Already on!\n\n\t// console.log(\"Enabled fast transition\");\n\tfastTransitionMode = true;\n}\n\n/** Cancels the timer that would exit fast-transition mode. */\nfunction cancelFastTransitionExpiryTimer(): void {\n\tclearTimeout(fastTransitionTimeoutID);\n\tfastTransitionTimeoutID = undefined;\n}\n\n/** Disables fast-transition mode. */\nfunction disableFastTransition(): void {\n\tif (!fastTransitionMode) return;\n\n\t// console.log(\"Disabled fast transition\");\n\tfastTransitionTimeoutID = undefined;\n\tfastTransitionMode = false;\n}\n\n/**\n * Positions and shows the tooltip for the given target element.\n * @param target - The element with the tooltip class and data-tooltip attribute.\n * @param direction - The tooltip direction class (e.g. 'tooltip-d').\n */\nfunction showTooltipFor(target: HTMLElement, direction: string): void {\n\tconst text = target.dataset['tooltip'];\n\tif (!text) return;\n\n\tif (!tooltipDiv || !arrowDiv) createTooltipElements();\n\tconst tip = tooltipDiv!;\n\tconst arrow = arrowDiv!;\n\n\t// Cancel any pending DOM removal so we can reuse the elements.\n\tclearTimeout(hideTimer);\n\thideTimer = undefined;\n\n\t// Set text and make invisible for measurement.\n\ttip.textContent = text;\n\ttip.style.opacity = '0';\n\n\t// Ensure elements are in the DOM so we can measure them.\n\tif (!tip.isConnected) document.body.appendChild(tip);\n\tif (!arrow.isConnected) document.body.appendChild(arrow);\n\n\t// Shrink the box width to the minimum needed for the wrapped text.\n\tshrinkWrapTooltip(tip);\n\n\t// Force a layout reflow to get accurate dimensions.\n\tconst tipWidth = tip.offsetWidth;\n\tconst tipHeight = tip.offsetHeight;\n\n\tconst isDown =\n\t\tdirection === 'tooltip-d' || direction === 'tooltip-dl' || direction === 'tooltip-dr';\n\n\t/** Recomputes and applies the tooltip position relative to the current target rect. */\n\tconst updatePosition = (): void => {\n\t\tconst targetRect = target.getBoundingClientRect();\n\n\t\t// Vertical positioning.\n\t\tlet tipTop: number;\n\t\tlet arrowTop: number;\n\t\tif (isDown) {\n\t\t\ttipTop = targetRect.bottom + TOOLTIP_GAP;\n\t\t\t// Arrow bottom aligns exactly with tooltip box top, filling the gap cleanly.\n\t\t\tarrowTop = targetRect.bottom + TOOLTIP_GAP - ARROW_HALF * 2;\n\t\t\tarrow.className = 'tooltip-arrow-down';\n\t\t} else {\n\t\t\ttipTop = targetRect.top - TOOLTIP_GAP - tipHeight;\n\t\t\t// Arrow top aligns exactly with tooltip box bottom, filling the gap cleanly.\n\t\t\tarrowTop = targetRect.top - TOOLTIP_GAP;\n\t\t\tarrow.className = 'tooltip-arrow-up';\n\t\t}\n\n\t\t// Horizontal positioning of the tooltip box.\n\t\tlet tipLeft: number;\n\t\tif (direction === 'tooltip-d' || direction === 'tooltip-u') {\n\t\t\t// Centered on the target.\n\t\t\ttipLeft = targetRect.left + targetRect.width / 2 - tipWidth / 2;\n\t\t} else if (direction === 'tooltip-dl' || direction === 'tooltip-ul') {\n\t\t\t// Right edge of tooltip aligns with right edge of target.\n\t\t\ttipLeft = targetRect.right - tipWidth;\n\t\t} else {\n\t\t\t// tooltip-dr: left edge of tooltip aligns with left edge of target.\n\t\t\ttipLeft = targetRect.left;\n\t\t}\n\n\t\t// Arrow always centered horizontally on the target.\n\t\tconst arrowLeft = targetRect.left + targetRect.width / 2 - ARROW_HALF;\n\n\t\t// Apply computed positions.\n\t\ttip.style.top = `${tipTop}px`;\n\t\ttip.style.left = `${tipLeft}px`;\n\t\tarrow.style.top = `${arrowTop}px`;\n\t\tarrow.style.left = `${arrowLeft}px`;\n\t};\n\n\tupdatePosition();\n\n\t// Keep the tooltip in sync with the target element every frame in case it moves.\n\tif (positionLoopId !== undefined) cancelAnimationFrame(positionLoopId);\n\tconst loop = (): void => {\n\t\tif (!tip.isConnected) return;\n\t\tupdatePosition();\n\t\tpositionLoopId = requestAnimationFrame(loop);\n\t};\n\tpositionLoopId = requestAnimationFrame(loop);\n\n\tarrow.style.opacity = '0';\n\n\t// Two rAF frames ensure the browser has committed the opacity:0 paint before\n\t// animating to opacity:1, so the CSS transition fires correctly from 0 → 1.\n\trequestAnimationFrame(() => {\n\t\trequestAnimationFrame(() => {\n\t\t\ttip.style.opacity = '1';\n\t\t\tarrow.style.opacity = '1';\n\t\t});\n\t});\n}\n\n/** Fades out the tooltip and removes it from the DOM once the transition ends. */\nfunction hideTooltipDiv(): void {\n\tif (!tooltipDiv || !arrowDiv) return;\n\ttooltipDiv.style.opacity = '0';\n\tarrowDiv.style.opacity = '0';\n\tclearTimeout(hideTimer);\n\thideTimer = window.setTimeout(() => {\n\t\ttooltipDiv?.remove();\n\t\tarrowDiv?.remove();\n\t}, FADE_OUT_REMOVE_DELAY_MS);\n}\n\n/** Shows the tooltip if conditions allow. */\nfunction tryShow(target: HTMLElement, state: TooltipState, direction: string): void {\n\tif (!state.isHovering || state.isHolding || state.suppressed) return;\n\t// If the element is no longer in the DOM, don't show the tooltip.\n\tif (!target.isConnected) return;\n\tstate.tooltipVisible = true;\n\tshowTooltipFor(target, direction);\n}\n\n/** Schedules (or immediately triggers) showing the tooltip. */\nfunction scheduleShow(target: HTMLElement, state: TooltipState, direction: string): void {\n\tclearTimeout(state.hoveringTimer);\n\tif (fastTransitionMode) {\n\t\ttryShow(target, state, direction);\n\t} else {\n\t\tstate.hoveringTimer = window.setTimeout(\n\t\t\t() => tryShow(target, state, direction),\n\t\t\tTOOLTIP_DELAY_MILLIS,\n\t\t);\n\t}\n}\n\n/** Hides the tooltip and suppresses it temporarily (used on click). */\nfunction suppress(state: TooltipState): void {\n\tstate.suppressed = true;\n\tstate.tooltipVisible = false;\n\tclearTimeout(state.hoveringTimer);\n\tstate.hoveringTimer = undefined;\n\thideTooltipDiv();\n\tdisableFastTransition();\n}\n\n/** Schedules the end of the click-suppression window. */\nfunction resetSuppressTimer(target: HTMLElement, state: TooltipState, direction: string): void {\n\tclearTimeout(state.suppressTimer);\n\tstate.suppressTimer = window.setTimeout(() => {\n\t\tstate.suppressed = false;\n\t\tif (state.isHovering && !state.isHolding) tryShow(target, state, direction);\n\t}, SUPPRESS_COOLDOWN_MILLIS);\n}\n\n// Delegated event listeners ------------------------------------------------------------\n\nif (docutil.isMouseSupported()) {\n\t// mouseover/mouseout bubble, letting us simulate mouseenter/mouseleave via delegation.\n\tdocument.body.addEventListener('mouseover', (e: MouseEvent) => {\n\t\tconst target = findTooltipAncestor(e.target as Element | null);\n\t\tif (!target) return;\n\n\t\t// Only fire \"enter\" when arriving from outside the tooltip element.\n\t\tconst from = e.relatedTarget as Element | null;\n\t\tif (from && target.contains(from)) return;\n\n\t\tconst state = getOrCreateState(target);\n\t\tconst direction = getTooltipClass(target)!;\n\t\tstate.isHovering = true;\n\t\tcancelFastTransitionExpiryTimer();\n\t\tscheduleShow(target, state, direction);\n\t});\n\n\tdocument.body.addEventListener('mouseout', (e: MouseEvent) => {\n\t\tconst target = findTooltipAncestor(e.target as Element | null);\n\t\tif (!target) return;\n\n\t\t// Only fire \"leave\" when moving to outside the tooltip element.\n\t\tconst to = e.relatedTarget as Element | null;\n\t\tif (to && target.contains(to)) return;\n\n\t\tconst state = getOrCreateState(target);\n\t\tstate.isHovering = false;\n\t\tstate.isHolding = false;\n\t\tclearTimeout(state.hoveringTimer);\n\n\t\t// Immediately clear suppression so re-hovering works normally.\n\t\tstate.suppressed = false;\n\t\tclearTimeout(state.suppressTimer);\n\t\tstate.suppressTimer = undefined;\n\n\t\tif (state.tooltipVisible) {\n\t\t\tenableFastTransition();\n\t\t\tfastTransitionTimeoutID = window.setTimeout(\n\t\t\t\t() => disableFastTransition(),\n\t\t\t\tFAST_TRANSITION_COOLDOWN_MILLIS,\n\t\t\t);\n\t\t}\n\n\t\tstate.tooltipVisible = false;\n\t\thideTooltipDiv();\n\t});\n\n\tdocument.body.addEventListener('mousedown', (e: MouseEvent) => {\n\t\tconst target = findTooltipAncestor(e.target as Element | null);\n\t\tif (!target) return;\n\t\tconst state = getOrCreateState(target);\n\t\tconst direction = getTooltipClass(target)!;\n\t\tstate.isHolding = true;\n\t\tsuppress(state);\n\t\tresetSuppressTimer(target, state, direction);\n\t});\n\n\tdocument.body.addEventListener('mouseup', (e: MouseEvent) => {\n\t\tconst target = findTooltipAncestor(e.target as Element | null);\n\t\tif (!target) return;\n\t\tconst state = getOrCreateState(target);\n\t\tconst direction = getTooltipClass(target)!;\n\t\tstate.isHolding = false;\n\t\tsuppress(state);\n\t\tresetSuppressTimer(target, state, direction);\n\t});\n} else {\n\t// Touch devices: show tooltip on press, hide on release/cancel.\n\tdocument.body.addEventListener('touchstart', (e: TouchEvent) => {\n\t\tconst target = findTooltipAncestor(e.target as Element | null);\n\t\tif (!target) return;\n\t\tconst state = getOrCreateState(target);\n\t\tconst direction = getTooltipClass(target)!;\n\t\tstate.isHovering = true;\n\t\tstate.hoveringTimer = window.setTimeout(\n\t\t\t() => tryShow(target, state, direction),\n\t\t\tTOOLTIP_DELAY_MILLIS,\n\t\t);\n\t});\n\n\tconst onTouchEnd = (e: TouchEvent): void => {\n\t\tconst target = findTooltipAncestor(e.target as Element | null);\n\t\tif (!target) return;\n\t\tconst state = getOrCreateState(target);\n\t\tstate.isHovering = false;\n\t\tclearTimeout(state.hoveringTimer);\n\t\tstate.tooltipVisible = false;\n\t\thideTooltipDiv();\n\t};\n\n\tdocument.body.addEventListener('touchend', onTouchEnd);\n\tdocument.body.addEventListener('touchcancel', onTouchEnd);\n}\n"
  },
  {
    "path": "src/client/scripts/esm/util/usernamecontainer.ts",
    "content": "// src/client/scripts/esm/util/usernamecontainer.ts\n\n/**\n * This script provides functionalities for the username container that contains the players' username, elo etc.\n */\n\nimport type { Rating, ServerUsernameContainer } from '../../../../shared/types.js';\n\nimport metadatautil from '../../../../shared/chess/util/metadatautil.js';\n\nimport docutil from './docutil.js';\nimport languagedropdown from '../components/header/dropdowns/languagedropdown.js';\n\n// Types ----------------------------------------------------------------------------------------\n\n/**\n * Such an object contains all display information for a given user\n */\ntype UsernameContainer = {\n\tproperties: UsernameContainerProperties;\n\t/** A reference to the documant element container. */\n\telement: HTMLDivElement;\n\t/** Cancel functions for any running `animateNumber` calls. */\n\tanimationCancels: Function[];\n};\n\n/**\n * Settings for creating HTML elements out of username containers\n */\ntype UsernameContainerProperties = {\n\t/**\n\t * Player => Clickable hyperlink to the user's profile\n\t * Guest => No clickable hyperlink\n\t * Engine => No clickable hyperlink, AND a unique SVG icon\n\t */\n\ttype: UsernameContainerType;\n\tusername: UsernameItem;\n\trating?: {\n\t\tvalue: number;\n\t\tconfident: boolean;\n\t\tchange?: number;\n\t};\n};\n\ntype UsernameContainerType = 'player' | 'guest' | 'engine';\ntype UsernameItem = {\n\t/** The actual username. */\n\tvalue: string;\n\t/**\n\t * Whether clicking the username should open their profile in a new window or not.\n\t * IGNORED IF TYPE === 'engine' or 'guest'.\n\t */\n\topenInNewWindow: boolean;\n};\ntype RatingItem = {\n\t/** The actual rating */\n\tvalue: number;\n\t/** Whether the rating is confident or not (low RD). If not confident, a question mark \"?\" is shown. */\n\tconfident: boolean;\n\t/** The change in rating of the current match, if available. */\n\tchange?: number;\n};\n\n// Variables ----------------------------------------------------------------------------------------\n\nconst profileSVGSource =\n\t'<svg class=\"svg-profile\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#000\" stroke=\"#000\" version=\"1.1\" viewBox=\"0 0 2000 2000\"><path d=\"M1656 1800H344c-70 0-123-70-96-134C370 1370 662 1200 1000 1200s629 170 752 466c27 64-25 134-96 134M592 600c0-220 183-400 408-400s408 180 408 400-183 400-408 400a405 405 0 01-408-400m1404 1164a952 952 0 00-612-697 593 593 0 00 220-560 610 610 0 00-530-503A608 608 0 00 388 600c0 190 89 357 228 467a952 952 0 00-612 697c-27 122 74 236 200 236h1590c128 0 229-114 200-236\" fill=\"#555\" fill-rule=\"evenodd\" stroke=\"none\"/></svg>';\nconst engineSVGSource =\n\t'<svg class=\"svg-engine\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 240 240\"><path d=\"M90 20v30m60-30v30M90 190v30m60-30v30m40-130h30m-30 50h30M20 90h30m-30 50h30m48 50h44c17 0 25 0 32-3a30 30 0 00 13-13c3-7 3-15 3-32V98c0-17 0-25-3-32a30 30 0 00-13-13c-7-3-15-3-32-3H98c-17 0-25 0-32 3a30 30 0 00-13 13C50 73 50 81 50 98v44c0 17 0 25 3 32 3 5 8 10 13 13 7 3 15 3 32 3Z\" stroke=\"#555\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"20\"/></svg>';\n\n// General functions ----------------------------------------------------------------------------------------\n\n/**\n * Creates an HTML Div Element containing all information to be shown about a UsernameContainer\n * @param usernamecontainer - contains information for a given user\n * @param options - settings for how to display information\n * @returns HTMLDivElement\n */\nfunction createUsernameContainer(\n\ttype: UsernameContainerType,\n\tusername: UsernameItem,\n\trating?: RatingItem,\n): UsernameContainer {\n\tconst containerDiv = document.createElement('div');\n\n\t// Profile SVG element\n\tconst svgSource = type === 'engine' ? engineSVGSource : profileSVGSource;\n\tconst svgElement = docutil.createSvgElementFromString(svgSource);\n\tcontainerDiv.appendChild(svgElement);\n\n\tif (type === 'player') {\n\t\t// Hyperlink\n\t\tconst usernameHyper = document.createElement('a');\n\t\tusernameHyper.href = languagedropdown.addLngQueryParamToLink(\n\t\t\t`/member/${username.value.toLowerCase()}`,\n\t\t);\n\t\tusernameHyper.textContent = username.value;\n\t\tif (username.openInNewWindow) usernameHyper.target = '_blank';\n\t\tusernameHyper.classList.add('username');\n\t\tusernameHyper.setAttribute('user-type', type); // Alows this container's properties to be reconstructed by other scripts from just the HTML element\n\t\tcontainerDiv.appendChild(usernameHyper);\n\t} else {\n\t\t// No hyperlink\n\t\tconst usernameDiv = document.createElement('div');\n\t\tusernameDiv.textContent = username.value;\n\t\tusernameDiv.classList.add('username');\n\t\tusernameDiv.setAttribute('user-type', type); // Alows this container's properties to be reconstructed by other scripts from just the HTML element\n\t\tcontainerDiv.appendChild(usernameDiv);\n\t}\n\n\t// rating element\n\tif (rating) {\n\t\tconst eloDiv = document.createElement('div');\n\t\teloDiv.classList.add('elo');\n\t\tcontainerDiv.appendChild(eloDiv);\n\n\t\t// Rating change element\n\t\tif (rating.change !== undefined) {\n\t\t\tconst eloChangeDiv = document.createElement('div');\n\t\t\teloChangeDiv.classList.add('eloChange');\n\t\t\tcontainerDiv.appendChild(eloChangeDiv);\n\t\t}\n\t}\n\n\tcontainerDiv.classList.add('username-embed');\n\n\t// Construct the UsernameContainer object\n\n\tconst properties: UsernameContainerProperties = {\n\t\ttype,\n\t\tusername,\n\t};\n\tif (rating) properties.rating = rating;\n\n\t// Build the container object\n\tconst usernameContainer: UsernameContainer = {\n\t\tproperties,\n\t\telement: containerDiv,\n\t\tanimationCancels: [],\n\t};\n\n\tupdateUsernameContainerRatingTextContent(usernameContainer);\n\n\t// If we have a rating change, animate that text\n\tif (rating?.change !== undefined) {\n\t\tconst oldValue = rating.value - rating.change;\n\t\tanimateRatingChange(\n\t\t\tusernameContainer,\n\t\t\toldValue,\n\t\t\trating.value,\n\t\t\trating.change,\n\t\t\trating.confident,\n\t\t);\n\t}\n\n\treturn usernameContainer;\n}\n\n/**\n * Extracts the UsernameContainerProperties from a physical html element username container.\n * @param containerDiv - the HTMLDivElement to extract information from\n * @returns a freshly created UsernameContainer or undefined, if this failed\n */\nfunction extractPropertiesFromUsernameContainerElement(\n\tcontainerDiv: HTMLDivElement,\n): ServerUsernameContainer {\n\tif (!containerDiv.classList.contains('username-embed'))\n\t\tthrow Error('Cannot extract username container from element that is not a username embed!');\n\n\t// Reconstruct type and username\n\tconst usernameElem = containerDiv.querySelector('.username')!;\n\tconst type = usernameElem.getAttribute('user-type') as 'player' | 'guest';\n\tif (!type)\n\t\tthrow Error(\n\t\t\t'Cannot extract username container from element that does not have a user-type attribute!',\n\t\t);\n\tconst result: ServerUsernameContainer = {\n\t\ttype,\n\t\tusername: usernameElem.textContent!,\n\t};\n\n\t// Reconstruct rating\n\tconst eloElem = containerDiv.querySelector('.elo');\n\tif (eloElem) result.rating = JSON.parse(eloElem.getAttribute('rating')!) as RatingItem;\n\n\treturn result;\n}\n\n/**\n * Set child_element as the only content of parent_element, with the same classes and styling\n * @param child_element\n * @param parent_element\n */\nfunction embedUsernameContainerDisplayIntoParent(\n\tchild_element: HTMLDivElement,\n\tparent_element: HTMLElement,\n): void {\n\t// First clear all other content of parent_element\n\twhile (parent_element.firstChild) {\n\t\tparent_element.removeChild(parent_element.firstChild);\n\t}\n\n\t// Append child to parent\n\tparent_element.appendChild(child_element);\n}\n\n/**\n * Test's if the mouse click event was inside a username embed.\n * @param event\n * @returns The nearest .username-embed element, or null if the click was outside\n */\nfunction wasEventClickInsideUsernameContainer(event: MouseEvent): boolean {\n\tconst targetNode = event.target as Node;\n\tconst el = targetNode instanceof Element ? targetNode : targetNode.parentElement;\n\treturn el?.closest<HTMLDivElement>('.username-embed') !== null;\n}\n\n/** Adds the elo change div to an existing username container. */\nfunction createEloChangeItem(\n\tusernamecontainer: UsernameContainer,\n\tnewRating: Rating,\n\tratingChange: number,\n): void {\n\tif (!usernamecontainer.properties.rating)\n\t\tthrow Error('Cannot create elo change item for usernamecontainer without rating!');\n\n\t// Previous rating value\n\tconst oldValue = usernamecontainer.properties.rating.value;\n\n\t// Update rating in usernamecontainer\n\tusernamecontainer.properties.rating = {\n\t\tvalue: newRating.value,\n\t\tconfident: newRating.confident,\n\t\tchange: ratingChange,\n\t};\n\n\t// rating change element\n\tconst eloChangeDiv = document.createElement('div');\n\teloChangeDiv.classList.add('eloChange');\n\tusernamecontainer.element.appendChild(eloChangeDiv);\n\n\tupdateUsernameContainerRatingTextContent(usernamecontainer);\n\n\t// Animate...\n\tanimateRatingChange(\n\t\tusernamecontainer,\n\t\toldValue,\n\t\tnewRating.value,\n\t\tratingChange,\n\t\tnewRating.confident,\n\t);\n}\n\n/**\n * Updates the text contents of each of the username container element's rating elements,\n * according to the values in the usernamecontainer properties..\n */\nfunction updateUsernameContainerRatingTextContent(usernamecontainer: UsernameContainer): void {\n\tconst element = usernamecontainer.element;\n\n\t// Update the rating\n\tif (usernamecontainer.properties.rating) {\n\t\tconst eloElem = element.querySelector('.elo') as HTMLDivElement;\n\t\tconst displayRating = metadatautil.getFormattedElo(usernamecontainer.properties.rating);\n\t\teloElem.textContent = `(${displayRating})`;\n\t\teloElem.setAttribute('rating', JSON.stringify(usernamecontainer.properties.rating)); // Allows this container's properties to be reconstructed by other scripts from just the HTML element\n\n\t\t// Update the rating change, if available\n\t\tif (usernamecontainer.properties.rating.change !== undefined) {\n\t\t\tconst eloChangeDiv = element.querySelector('.eloChange')!;\n\t\t\teloChangeDiv.textContent = metadatautil.getWhiteBlackRatingDiff(\n\t\t\t\tusernamecontainer.properties.rating.change,\n\t\t\t);\n\t\t\t// Color the ratingchange green or red, depending on its positivity\n\t\t\tif (usernamecontainer.properties.rating.change >= 0) {\n\t\t\t\teloChangeDiv.classList.add('positive');\n\t\t\t\teloChangeDiv.classList.remove('negative');\n\t\t\t} else {\n\t\t\t\teloChangeDiv.classList.add('negative');\n\t\t\t\teloChangeDiv.classList.remove('positive');\n\t\t\t}\n\t\t}\n\t}\n}\n\n// Animating Elo Changes ----------------------------------------------------------------------------------------\n\n/**\n * Returns a function that formats an elo value into a string for going into the `.elo` element's textContent.\n * This function goes into the {@link animateNumber} as the `valueFormatter` parameter.\n * @param confident - Whether the new rating is confident or not.\n * @returns A function that takes a numeric value and returns the formatted text content for the elo rating.\n */\nfunction createEloFormatter(confident: boolean): (_value: number) => string {\n\t// Create a text content generator\n\treturn (value: number): string => {\n\t\tconst rating: Rating = { value, confident };\n\t\tconst displayRating = metadatautil.getFormattedElo(rating);\n\t\treturn `(${displayRating})`;\n\t};\n}\n\n/**\n * Animate both the main Elo and its Δ for a given container.\n * @param container — the UsernameContainer whose elements we’ll animate\n * @param oldValue  — rating before the change\n * @param newValue  — rating after the change\n * @param change    — the Δ to display (can be positive or negative)\n * @param confident — whether the rating is “confident” (for formatting)\n */\nfunction animateRatingChange(\n\tcontainer: UsernameContainer,\n\toldValue: number,\n\tnewValue: number,\n\tchange: number,\n\tconfident: boolean,\n): void {\n\tconst DURATION = 1000; // ms for both animations\n\n\t// find our two elements\n\tconst eloElem = container.element.querySelector('.elo')! as HTMLElement;\n\tconst deltaElem = container.element.querySelector('.eloChange')! as HTMLElement;\n\n\t// tween the main rating\n\tconst mainAnim = animateNumber(\n\t\teloElem,\n\t\toldValue,\n\t\tnewValue,\n\t\tDURATION,\n\t\tundefined,\n\t\tcreateEloFormatter(confident),\n\t);\n\tcontainer.animationCancels.push(mainAnim.cancel);\n\n\t// tween the change Δ\n\tconst changeAnim = animateNumber(\n\t\tdeltaElem,\n\t\t0,\n\t\tchange,\n\t\tDURATION,\n\t\tundefined,\n\t\tmetadatautil.getWhiteBlackRatingDiff,\n\t);\n\tcontainer.animationCancels.push(changeAnim.cancel);\n}\n\n/**\n * Animate a numeric text value in an element from `start` to `end` over `duration` ms,\n * using a custom easing function and optional text content formatter.\n * @param element — the element whose `.textContent` will be updated\n * @param start — starting number\n * @param end — ending number\n * @param durationMillis — total time, in milliseconds, for the animation\n * @param easingFn — easing function (t from 0→1); defaults to an ease-out curve\n * @param valueFormatter — optional function that receives the current numeric value\n *     and returns the string to set as textContent; defaults to `v => v.toLocaleString()`\n * @returns An object with a `cancel()` method to stop the animation early.\n */\nfunction animateNumber(\n\telement: HTMLElement,\n\tstart: number,\n\tend: number,\n\tdurationMillis: number,\n\teasingFn: (_t: number) => number = (t) => 1 - Math.pow(1 - t, 2), // Default: ease-out\n\tvalueFormatter: (_value: number) => string = (v) => v.toLocaleString(),\n): { cancel(): void } {\n\tlet frameId: number | null = null;\n\tlet cancelled = false;\n\tconst range = end - start;\n\tconst startTime = performance.now();\n\n\t/**\n\t * Internal step function for requestAnimationFrame\n\t * @param now — high-resolution timestamp passed by rAF\n\t */\n\tfunction step(now: DOMHighResTimeStamp): void {\n\t\tif (cancelled) return;\n\t\tconst elapsed = now - startTime;\n\t\tconst progress = Math.min(elapsed / durationMillis, 1);\n\t\tconst eased = easingFn(progress);\n\t\tconst current = Math.round(start + range * eased);\n\t\telement.textContent = valueFormatter(current);\n\n\t\tif (progress < 1) frameId = requestAnimationFrame(step);\n\t}\n\n\tframeId = requestAnimationFrame(step);\n\n\treturn {\n\t\t/** Cancel the animation at its next opportunity */\n\t\tcancel(): void {\n\t\t\tcancelled = true;\n\t\t\tif (frameId !== null) cancelAnimationFrame(frameId);\n\t\t},\n\t};\n}\n\n// Exports ----------------------------------------------------------------------------------------\n\nexport default {\n\tcreateUsernameContainer,\n\textractPropertiesFromUsernameContainerElement,\n\tembedUsernameContainerDisplayIntoParent,\n\twasEventClickInsideUsernameContainer,\n\tcreateEloChangeItem,\n};\n\nexport type { UsernameContainer, UsernameItem, RatingItem };\n"
  },
  {
    "path": "src/client/scripts/esm/util/validatorama.ts",
    "content": "// src/client/scripts/esm/util/validatorama.ts\n\n// I called it validatorama because \"validator\" was already something\n// in the Node environment or somewhere and so jsdoc wasn't auto suggesting the right one\n\n/*\n * Fetches an access token and our username if we are logged in.\n *\n * If we are not logged in, the server will give us a browser-id\n * cookie to validate our identity in future requests.\n */\n\nimport tokenConfig from '../../../../shared/util/tokenConfig.js';\n\nimport docutil from './docutil.js';\n\n// Variables ----------------------------------------------------------------------------\n\n/** Cushion time in milliseconds before the access token expires, when we'll fetch a new one. */\nconst ACCESS_TOKEN_CUSHION_MILLIS: number = 10_000;\n\nlet reqIsOut: boolean = false;\nconst resolvers: (() => void)[] = [];\n/** The timeout ID for the timer to check session expiry. */\nlet sessionExpiryTimer: number | undefined;\n\nlet memberInfo: {\n\tsignedIn: boolean;\n\tuser_id?: number;\n\tusername?: string;\n\tissued?: number;\n\texpires?: number;\n} = {\n\tsignedIn: false,\n\tuser_id: undefined,\n\tusername: undefined,\n\tissued: undefined,\n\texpires: undefined,\n};\n\nlet tokenInfo: {\n\t/** Access token for authentication, if we are logged in AND have requested one! */\n\taccessToken?: string;\n\t/** Last refresh time of the access token, in milliseconds. */\n\tlastRefreshTime?: number;\n} = {\n\taccessToken: undefined,\n\tlastRefreshTime: undefined,\n};\n\n// Functions ----------------------------------------------------------------------------\n\n(function init(): void {\n\tinitListeners();\n\n\t// Sets our memberInfo properties if we are logged in\n\treadMemberInfoCookie();\n\t// Most of the time we don't need an immediate access token\n\t// refreshToken();\n})();\n\nfunction initListeners(): void {\n\tdocument.addEventListener('logout', resetMemberInfo);\n\tdocument.addEventListener('logout', onLogout);\n\twindow.addEventListener('pageshow', readMemberInfoCookie); // Fired on initial page load AND when hitting the back button to return.\n}\n\n/**\n * Checks if the access token is expired or near-expiring.\n * If expired, it calls `refreshToken()` to get a new one.\n *\n * If we're not signed in, the server will give/renew us a browser-id cookie for validating our identity.\n * @returns Resolves with the access token, or undefined if not logged in.\n */\nasync function getAccessToken(): Promise<string | undefined> {\n\tif (reqIsOut) await waitUntilInitialRequestBack();\n\n\tif (!memberInfo.signedIn) return;\n\n\tconst timeSinceLastRefresh = Date.now() - (tokenInfo.lastRefreshTime || 0);\n\n\t// Check if token is expired or near expiring\n\tif (\n\t\t!tokenInfo.accessToken ||\n\t\ttimeSinceLastRefresh > tokenConfig.ACCESS_TOKEN_EXPIRY_MILLIS - ACCESS_TOKEN_CUSHION_MILLIS\n\t) {\n\t\tawait refreshToken();\n\t}\n\n\treturn tokenInfo.accessToken;\n}\n\n/**\n * Inits the access token and our username if we are logged in.\n *\n * Reads the `memberInfo` cookie to get the member details (username).\n * If not signed in, the server will renew the browser-id cookie.\n *\n * @returns Resolves when the token refresh process is complete.\n */\n\nasync function refreshToken(): Promise<void> {\n\treqIsOut = true;\n\ttry {\n\t\tconst response = await fetch('/api/get-access-token', {\n\t\t\tmethod: 'POST', // Ensure it's a POST request\n\t\t\theaders: {\n\t\t\t\t'Content-Type': 'application/json',\n\t\t\t\t'is-fetch-request': 'true', // Custom header\n\t\t\t},\n\t\t});\n\n\t\tconst result = await response.json();\n\n\t\tif (response.ok) {\n\t\t\t// Session token (refresh token cookie) is valid!\n\t\t\tconst accessToken = docutil.getCookieValue('token'); // Read access token from cookie\n\t\t\tif (!accessToken) throw new Error('Token cookie not found!');\n\t\t\ttokenInfo = { accessToken, lastRefreshTime: Date.now() };\n\n\t\t\t// Delete the token cookie after reading it\n\t\t\tdocutil.deleteCookie('token');\n\n\t\t\t// It's possible the server renewed our session. Let's read the memberInfo cookie again!\n\t\t\treadMemberInfoCookie();\n\n\t\t\t// Dispatch event to inform other parts of the app that we are logged in.\n\t\t\t// document.dispatchEvent(new CustomEvent('login'));\n\t\t} else {\n\t\t\t// 403 or 500 error   Likely not signed in! Our session token (refresh token cookie) was invalid or not present.\n\t\t\tconsole.log(`Server: ${result.message}`);\n\t\t\tdeleteMemberInfoCookie();\n\t\t\t// Dispatch a custom logout event so our header code knows to update the navigation links\n\t\t\tdocument.dispatchEvent(new CustomEvent('logout'));\n\t\t}\n\t} catch (error) {\n\t\tconsole.error('Error occurred during token refresh:', error);\n\t\treadMemberInfoCookie();\n\t} finally {\n\t\treqIsOut = false;\n\t\t// Resolve all pending promises\n\t\twhile (resolvers.length > 0) {\n\t\t\tresolvers.shift()!(); // Get the first resolver and resolve it\n\t\t}\n\t}\n}\n\n/**\n * Read the memberInfo cookie, which will be present\n * if we have a refreshed token cookie, to grab our\n * username and user_id properties if we are signed in.\n */\nfunction readMemberInfoCookie(): void {\n\tresetMemberInfo();\n\n\t// Read the member info from the cookie\n\t// Get the URL-encoded cookie value\n\t// JSON objects can't be stringified into cookies because cookies can't hold special characters\n\tconst encodedMemberInfo = docutil.getCookieValue('memberInfo');\n\tif (!encodedMemberInfo) return; // No cookie, not signed in.\n\t// Decode the URL-encoded string\n\tconst memberInfoStringified = decodeURIComponent(encodedMemberInfo);\n\tmemberInfo = JSON.parse(memberInfoStringified); // { user_id, username, issued (timestamp), expires (timestamp) }\n\tmemberInfo.signedIn = true;\n\n\tscheduleSessionLogout();\n}\n\n/** Resets our member info variables as if we were logged out. */\nfunction resetMemberInfo(): void {\n\tclearTimeout(sessionExpiryTimer); // Prevent ghost logout events after we've manually reset\n\tmemberInfo = { signedIn: false };\n}\n\n/** Calculates time until session expiry and sets a timer to check session status. */\nfunction scheduleSessionLogout(): void {\n\tclearTimeout(sessionExpiryTimer);\n\tif (!memberInfo.signedIn || !memberInfo.expires) return;\n\n\tconst timeUntilExpiry = memberInfo.expires - Date.now();\n\tsessionExpiryTimer = window.setTimeout(() => checkSessionExpiry(), timeUntilExpiry);\n}\n\n/**\n * Callback for the session expiry timer.\n * Re-verifies cookie existence/expiry before deciding to dispatch logout event or reschedule.\n */\nfunction checkSessionExpiry(): void {\n\t// If a refresh request is currently out, trust that logic to handle the outcome instead of forcing a logout here.\n\tif (reqIsOut) return;\n\n\tconst encodedMemberInfo = docutil.getCookieValue('memberInfo');\n\n\t// If cookie is gone, or we can't parse it, we are definitely logged out.\n\tif (!encodedMemberInfo) {\n\t\t// Only dispatch logout if we thought we were signed in\n\t\tif (memberInfo.signedIn) {\n\t\t\tconsole.log('Detected session expired. Dispatching logout event. - 1');\n\t\t\tdocument.dispatchEvent(new CustomEvent('logout'));\n\t\t}\n\t\treturn;\n\t}\n\n\tconst info = JSON.parse(decodeURIComponent(encodedMemberInfo));\n\n\t// Final check: Is it actually in the future? (has since been renewed)\n\tif (info.expires && info.expires > Date.now()) {\n\t\t// It was renewed! Update our local state and reschedule.\n\t\treadMemberInfoCookie();\n\t} else {\n\t\t// Still expired. Dispatch logout.\n\t\tconsole.log('Detected session expired. Dispatching logout event. - 2');\n\t\tdocument.dispatchEvent(new CustomEvent('logout'));\n\t}\n}\n\nfunction deleteMemberInfoCookie(): void {\n\tdocutil.deleteCookie('memberInfo');\n\tresetMemberInfo();\n}\n\nfunction onLogout(): void {\n\tdeleteMemberInfoCookie();\n\ttokenInfo = {};\n}\n\n/**\n * Waits until the initial request for an access token is completed.\n */\nasync function waitUntilInitialRequestBack(): Promise<void> {\n\tif (!reqIsOut) return; // If no request is out, resolve immediately\n\t// console.log(\"Waiting until initial request for an access token is completed... (Delete later)\");\n\n\t// Create a promise that resolves when the request is completed\n\treturn new Promise<void>((resolve): void => {\n\t\tresolvers.push(resolve); // Add this resolver to the list\n\t});\n}\n\n/**\n * Whether we are logged in based on whether the memberInfo cookie is present.\n */\nfunction areWeLoggedIn(): boolean {\n\treturn memberInfo.signedIn;\n}\n\n/**\n * Retrieves our username if we are logged in.\n * @returns The username, or undefined if not logged in.\n */\nfunction getOurUsername(): string | undefined {\n\treturn memberInfo.signedIn ? memberInfo.username : undefined;\n}\n\n/**\n * Retrieves our user_id (base 10) if we are logged in.\n * @returns The user_id, or undefined if not logged in.\n */\nfunction getOurUserId(): number | undefined {\n\treturn memberInfo.signedIn ? memberInfo.user_id : undefined;\n}\n\n// --------------------------------------------------------------------------------\n\nexport default {\n\twaitUntilInitialRequestBack,\n\tareWeLoggedIn,\n\tgetOurUsername,\n\tgetOurUserId,\n\tgetAccessToken,\n\trefreshToken,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/views/admin.ts",
    "content": "// src/client/scripts/esm/views/admin.ts\n\nconst commandInput = document.getElementById('commandInput')! as HTMLInputElement;\nconst commandHistory = document.getElementById('commandHistory')! as HTMLTextAreaElement;\nconst sendCommandButton = document.getElementById('sendButton')! as HTMLButtonElement;\n\nasync function sendCommand(): Promise<void> {\n\tconst commandString: string = commandInput.value;\n\tif (commandString.length === 0) return; // Don't send command if the input box is empty\n\tcommandInput.value = '';\n\tconst response = await fetch('command/' + commandString);\n\tcommandHistory.textContent += commandString + '\\n' + (await response.text()) + '\\n\\n';\n\tscrollToBottom(commandHistory);\n}\n\nfunction clickSubmitIfReturnPressed(event: any): void {\n\t// 13 is the key code for Enter key\n\tif (event.keyCode === 13) sendCommandButton.click();\n}\n\n/**\n * Automatically scrolls to the bottom of the container.\n * @param container - The container to scroll.\n */\nfunction scrollToBottom(container: HTMLElement): void {\n\tcontainer.scrollTo({\n\t\ttop: container.scrollHeight,\n\t\tbehavior: 'smooth',\n\t});\n}\n\nsendCommandButton.addEventListener('click', sendCommand);\ncommandInput.addEventListener('keyup', clickSubmitIfReturnPressed);\n"
  },
  {
    "path": "src/client/scripts/esm/views/createaccount.ts",
    "content": "// src/client/scripts/esm/views/createaccount.ts\n\n// The script on the createaccount page\n\nimport validators from '../../../../shared/util/validators.js';\n\nimport languagedropdown from '../components/header/dropdowns/languagedropdown.js';\n\nconst element_usernameInput = document.getElementById('username') as HTMLInputElement;\nconst element_emailInput = document.getElementById('email') as HTMLInputElement;\nconst element_passwordInput = document.getElementById('password') as HTMLInputElement;\nconst element_submitButton = document.getElementById('submit') as HTMLButtonElement;\n\n/** Default fetch options */\nconst fetchOptions: RequestInit = {\n\theaders: {\n\t\t'is-fetch-request': 'true', // Custom header\n\t},\n};\n\nlet usernameHasError = false;\nelement_usernameInput.addEventListener('input', () => {\n\t// When username field changes...\n\n\t// Test if the value of the username input field won't be accepted.\n\n\t// 3-25 characters in length.\n\t// Accepted characters: A-Z 0-9\n\t// Doesn't contain existing/reserved usernames.\n\t// Doesn't contain profain words.\n\n\tlet usernameError = document.getElementById('usernameerror')!; // Does an error already exist?\n\n\tconst result = validators.validateUsername(element_usernameInput.value);\n\n\t// If ANY error, make sure errorElement is created\n\tif (result !== validators.UsernameValidationResult.Ok) {\n\t\tif (!usernameError) {\n\t\t\t// Create empty errorElement\n\t\t\tusernameHasError = true;\n\t\t\tcreateErrorElement('usernameerror', 'username-input-line');\n\t\t\t// Change input box to red outline\n\t\t\telement_usernameInput.style.outline = 'solid 1px red';\n\t\t\t// Reset variable because it now exists.\n\t\t\tusernameError = document.getElementById('usernameerror')!;\n\t\t}\n\t\tconst errorTranslation = validators.getUsernameErrorTranslation(result);\n\t\tif (errorTranslation) usernameError.textContent = translations[errorTranslation];\n\t\telse usernameError.textContent = 'Invalid username (BUG, please report!)'; // Fallback message if no translation is available for this error\n\t} else if (usernameError) {\n\t\t// No errors, delete that error element if it exists\n\t\tusernameHasError = false;\n\t\tusernameError.remove();\n\t\telement_usernameInput.removeAttribute('style');\n\t}\n\n\tupdateSubmitButton();\n});\nelement_usernameInput.addEventListener('focusout', () => {\n\t// Check username availability...\n\tif (element_usernameInput.value.length === 0 || usernameHasError) return;\n\n\tfetch(`/createaccount/username/${element_usernameInput.value}`, fetchOptions)\n\t\t.then((response) => response.json())\n\t\t.then((result) => {\n\t\t\t// { allowed, reason }\n\t\t\t// We've got the result back from the server,\n\t\t\t// Is this username available to use?\n\t\t\tif (result.allowed === true) return; // Not in use\n\n\t\t\t// ERROR! In use!\n\t\t\tusernameHasError = true;\n\t\t\tcreateErrorElement('usernameerror', 'username-input-line');\n\t\t\t// Change input box to red outline\n\t\t\telement_usernameInput.style.outline = 'solid 1px red';\n\t\t\t// Reset variable because it now exists.\n\t\t\tconst usernameError = document.getElementById('usernameerror')!;\n\n\t\t\t// translate the message from the server if a translation is available\n\t\t\tlet result_message = result.reason;\n\t\t\t// @ts-ignore\n\t\t\tif (translations[result_message]) result_message = translations[result_message];\n\t\t\tusernameError.textContent = result_message;\n\t\t\tupdateSubmitButton();\n\t\t});\n});\n\nlet emailHasError = false;\nelement_emailInput.addEventListener('input', () => {\n\t// When email field changes...\n\n\t// Test if the email is a valid email format\n\n\tlet emailError = document.getElementById('emailerror'); // Does an error already exist?\n\n\tconst result = validators.validateEmail(element_emailInput.value);\n\n\t// If ANY error, make sure errorElement is created\n\tif (result !== validators.EmailValidationResult.Ok) {\n\t\tif (!emailError) {\n\t\t\t// Create empty errorElement\n\t\t\temailHasError = true;\n\t\t\tcreateErrorElement('emailerror', 'emailinputline');\n\t\t\t// Change input box to red outline\n\t\t\telement_emailInput.style.outline = 'solid 1px red';\n\t\t\t// Reset variable because it now exists.\n\t\t\temailError = document.getElementById('emailerror')!;\n\t\t}\n\t\temailError.textContent = translations[validators.getEmailErrorTranslation(result)!];\n\t} else if (emailError) {\n\t\t// No errors, delete that error element if it exists\n\t\temailHasError = false;\n\t\temailError.remove();\n\t\telement_emailInput.removeAttribute('style');\n\t}\n\n\tupdateSubmitButton();\n});\nelement_emailInput.addEventListener('focusout', () => {\n\t// Check email availability and functionality...\n\t// If it's blank, all the server would send back is the createaccount.html again..\n\tif (element_emailInput.value.length > 1 && !emailHasError) {\n\t\tfetch(`/createaccount/email/${element_emailInput.value}`, fetchOptions)\n\t\t\t.then((response) => response.json())\n\t\t\t.then((result) => {\n\t\t\t\t// We've got the result back from the server,\n\t\t\t\t// Is anything wrong?\n\t\t\t\tif (result.valid === false) {\n\t\t\t\t\t// There has been an error\n\t\t\t\t\temailHasError = true;\n\n\t\t\t\t\t// We create the error text\n\t\t\t\t\tcreateErrorElement('emailerror', 'emailinputline');\n\n\t\t\t\t\t// Change input box to red outline\n\t\t\t\t\telement_emailInput.style.outline = 'solid 1px red';\n\n\t\t\t\t\t// Reset variable because it now exists.\n\t\t\t\t\tconst emailError = document.getElementById('emailerror')!;\n\n\t\t\t\t\t// The error message from the server is already language-localized\n\t\t\t\t\temailError.textContent = result.reason;\n\n\t\t\t\t\tupdateSubmitButton();\n\t\t\t\t} else {\n\t\t\t\t\temailHasError = false;\n\t\t\t\t\tupdateSubmitButton();\n\t\t\t\t}\n\t\t\t});\n\t}\n});\n\nlet passwordHasError = false;\nelement_passwordInput.addEventListener('input', () => {\n\t// When password field changes...\n\tlet passwordError = document.getElementById('passworderror');\n\n\tconst result = validators.validatePassword(element_passwordInput.value);\n\n\tif (result !== validators.PasswordValidationResult.Ok) {\n\t\tpasswordHasError = true;\n\t\tif (!passwordError) {\n\t\t\tpasswordError = createErrorElement('passworderror', 'password-input-line');\n\t\t\telement_passwordInput.style.outline = 'solid 1px red';\n\t\t}\n\t\tpasswordError.textContent = translations[validators.getPasswordErrorTranslation(result)!];\n\t} else {\n\t\tpasswordHasError = false;\n\t\tif (passwordError) {\n\t\t\tpasswordError.remove();\n\t\t}\n\t\telement_passwordInput.removeAttribute('style');\n\t}\n\n\tupdateSubmitButton();\n});\n\nelement_submitButton.addEventListener('click', (event) => {\n\tevent.preventDefault();\n\n\tif (\n\t\t!usernameHasError &&\n\t\t!emailHasError &&\n\t\t!passwordHasError &&\n\t\telement_usernameInput.value &&\n\t\telement_emailInput.value &&\n\t\telement_passwordInput.value\n\t)\n\t\tsendForm(\n\t\t\telement_usernameInput.value,\n\t\t\telement_emailInput.value,\n\t\t\telement_passwordInput.value,\n\t\t);\n});\n\n/** Sends our form data to the createaccount route. */\nfunction sendForm(username: string, email: string, password: string): void {\n\t// Disable the button and set its class to unavailable immediately.\n\telement_submitButton.disabled = true;\n\telement_submitButton.className = 'unavailable';\n\n\tlet OK = false;\n\tconst config: RequestInit = {\n\t\tmethod: 'POST',\n\t\theaders: {\n\t\t\t'Content-Type': 'application/json',\n\t\t\t'is-fetch-request': 'true', // Custom header\n\t\t},\n\t\tcredentials: 'same-origin', // Allows cookie to be set from this request\n\t\tbody: JSON.stringify({ username, email, password }),\n\t};\n\tfetch('/createaccount', config)\n\t\t.then((response) => {\n\t\t\tif (response.ok) OK = true;\n\t\t\treturn response.json();\n\t\t})\n\t\t.then((_result) => {\n\t\t\tif (OK) {\n\t\t\t\t// Account created!\n\t\t\t\t// We also received the refresh token cookie to start a session.\n\t\t\t\t// token = docutil.getCookieValue('token') // Cookie expires in 60s\n\t\t\t\twindow.location.href = languagedropdown.addLngQueryParamToLink(\n\t\t\t\t\t`/member/${username.toLowerCase()}`,\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\t// Conflict, unable to make account. 409 CONFLICT\n\t\t\t\twindow.location.href = languagedropdown.addLngQueryParamToLink('/409');\n\t\t\t}\n\t\t})\n\t\t// Re-enable the button after the fetch is done.\n\t\t// CURRENTLY ONLY RUNS WHEN a network error occurs, as for all server responses we redirect the page.\n\t\t.finally(() => {\n\t\t\telement_submitButton.disabled = false;\n\t\t\t// Call updateSubmitButton() to correctly set the class to 'ready' or 'unavailable'\n\t\t\t// based on the current state of the form fields.\n\t\t\tupdateSubmitButton();\n\t\t});\n}\n\nfunction createErrorElement(id: string, insertAfter: string): HTMLElement {\n\tconst errElement = document.createElement('div');\n\terrElement.className = 'error';\n\terrElement.id = id;\n\t// The element now looks like this:\n\t// <div class=\"error\" id=\"usernameerror\"></div>\n\tdocument.getElementById(insertAfter)!.insertAdjacentElement('afterend', errElement);\n\treturn errElement; // Return the created element\n}\n\n// Greys-out submit button if there's any errors.\n// The click-prevention is taken care of in the submit event listener.\nfunction updateSubmitButton(): void {\n\tif (\n\t\tusernameHasError ||\n\t\temailHasError ||\n\t\tpasswordHasError ||\n\t\t!element_usernameInput.value ||\n\t\t!element_emailInput.value ||\n\t\t!element_passwordInput.value\n\t) {\n\t\telement_submitButton.className = 'unavailable';\n\t} else {\n\t\t// No Errors\n\t\telement_submitButton.className = 'ready';\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/views/guide.ts",
    "content": "// src/client/scripts/esm/views/guide.ts\n\n/**\n * This script handles the Guide page fairy piece carousel.\n */\n\n/** The element that holds all fairy images and their descriptions. */\nconst element_FairyImg = document.getElementById('fairy-pieces')!;\n/** The element that holds all fairy descriptions. */\nconst element_FairyCard = document.getElementById('fairy-card')!;\nconst element_FairyBack = document.getElementById('fairy-back')!;\nconst element_FairyForward = document.getElementById('fairy-forward')!;\n\nlet fairyIndex: number = 0;\nconst maxFairyIndex = element_FairyImg.querySelectorAll('picture').length - 1;\n\nfunction initListeners(): void {\n\telement_FairyBack.addEventListener('click', callback_FairyBack);\n\telement_FairyForward.addEventListener('click', callback_FairyForward);\n}\n\nfunction callback_FairyBack(_event: Event): void {\n\tif (fairyIndex === 0) return;\n\thideCurrentFairy();\n\tfairyIndex--;\n\trevealCurrentFairy();\n\tupdateArrowTransparency();\n}\n\nfunction callback_FairyForward(_event: Event): void {\n\tif (fairyIndex === maxFairyIndex) return;\n\thideCurrentFairy();\n\tfairyIndex++;\n\trevealCurrentFairy();\n\tupdateArrowTransparency();\n}\n\nfunction hideCurrentFairy(): void {\n\tconst allFairyImgs = element_FairyImg.querySelectorAll('picture');\n\tconst targetFairyImg = allFairyImgs[fairyIndex]!;\n\ttargetFairyImg.classList.add('hidden');\n\n\tconst allFairyCards = element_FairyCard.querySelectorAll('.fairy-card-desc');\n\tconst targetFairyCard = allFairyCards[fairyIndex]!;\n\ttargetFairyCard.classList.add('hidden');\n}\n\nfunction revealCurrentFairy(): void {\n\tconst allFairyImgs = element_FairyImg.querySelectorAll('picture');\n\tconst targetFairyImg = allFairyImgs[fairyIndex]!;\n\ttargetFairyImg.classList.remove('hidden');\n\n\tconst allFairyCards = element_FairyCard.querySelectorAll('.fairy-card-desc');\n\tconst targetFairyCard = allFairyCards[fairyIndex]!;\n\ttargetFairyCard.classList.remove('hidden');\n}\n\nfunction updateArrowTransparency(): void {\n\tif (fairyIndex === 0) element_FairyBack.classList.add('opacity-0_25');\n\telse element_FairyBack.classList.remove('opacity-0_25');\n\n\tif (fairyIndex === maxFairyIndex) element_FairyForward.classList.add('opacity-0_25');\n\telse element_FairyForward.classList.remove('opacity-0_25');\n}\n\n// Initialize on page load\ninitListeners();\n"
  },
  {
    "path": "src/client/scripts/esm/views/icnvalidator.ts",
    "content": "// src/client/scripts/esm/views/icnvalidator.ts\n\nimport * as z from 'zod';\n\ninterface VariantStats {\n\ttotal: number;\n\ticn: number;\n\tformulator: number;\n\tillegal: number;\n\ttermination: number;\n}\n\ninterface ValidationResults {\n\ttotal: number;\n\tsuccessful: number;\n\ticnconverterErrors: number;\n\tformulatorErrors: number;\n\tillegalMoveErrors: number;\n\tterminationMismatchErrors: number;\n\terrors: ValidationError[];\n\tvariantErrors: Record<string, VariantStats>;\n}\n\ninterface ValidationError {\n\tgameIndex: number;\n\tphase: string;\n\terror: string;\n\tvariant?: string;\n\ticn: string;\n\ttermination?: string;\n\tresult?: string;\n\tgameConclusion?: string;\n}\n\n/** Result message from the ICN validator worker. */\ninterface WorkerResult {\n\ttype: 'done';\n\tchunkId: number;\n\tresults: {\n\t\tsuccess: boolean;\n\t\tsuccessfulCount: number;\n\t\ticnconverterErrors: number;\n\t\tformulatorErrors: number;\n\t\tillegalMoveErrors: number;\n\t\tterminationMismatchErrors: number;\n\t\terrors: any[];\n\t\tvariantErrors: Record<string, VariantStats>;\n\t};\n}\n\n/**\n * Progress message from the ICN validator worker.\n */\ninterface WorkerProgressMessage {\n\ttype: 'progress';\n\tchunkId: number;\n\tcount: number;\n}\n\ntype WorkerMessage = WorkerResult | WorkerProgressMessage;\n\ntype LogType = 'info' | 'success' | 'warning' | 'error';\n\nconst SPRTGamesSchema = z.array(z.string());\n\nlet gamesData: z.infer<typeof SPRTGamesSchema> | null = null;\n// Used for cancelling ongoing validation when a new file is selected\nlet currentValidationId = 0;\n// Track active workers to terminate them if user cancels\nlet activeWorkers: Worker[] = [];\n\n// File upload handling\nconst fileInput = document.getElementById('file-input')! as HTMLInputElement;\nconst fileName = document.getElementById('file-name')! as HTMLParagraphElement;\nconst uploadSection = document.getElementById('upload-section')! as HTMLDivElement;\nconst progressSection = document.getElementById('progress-section')! as HTMLDivElement;\nconst progressFill = document.getElementById('progress-fill')! as HTMLDivElement;\nconst progressText = document.getElementById('progress-text')! as HTMLParagraphElement;\n\n// Event Listeners\nfileInput.addEventListener('change', handleFileSelect);\nuploadSection.addEventListener('dragover', (e) => {\n\te.preventDefault();\n\tuploadSection.classList.add('drag-over');\n});\nuploadSection.addEventListener('dragleave', () => {\n\tuploadSection.classList.remove('drag-over');\n});\nuploadSection.addEventListener('drop', (e) => {\n\te.preventDefault();\n\tuploadSection.classList.remove('drag-over');\n\tif (e.dataTransfer?.files.length) {\n\t\tfileInput.files = e.dataTransfer.files;\n\t\thandleFileSelect();\n\t}\n});\n\nfunction handleFileSelect(): void {\n\tconst file = fileInput.files?.[0];\n\n\t// Reset the input so the 'change' event fires even if the same file is selected again\n\tfileInput.value = '';\n\n\tif (file) {\n\t\t// Cancel any existing validation loop immediately\n\t\tcurrentValidationId++;\n\t\tterminateWorkers(); // Kill any running threads\n\n\t\t// Reset UI: Hide progress bar and results from any previous run\n\t\tprogressSection.style.display = 'none';\n\t\tdocument.getElementById('summary-section')!.style.display = 'none';\n\t\tdocument.getElementById('variant-section')!.style.display = 'none';\n\t\tdocument.getElementById('errors-section')!.style.display = 'none';\n\n\t\tfileName.textContent = `Selected: ${file.name}`;\n\t\tfileName.style.color = 'var(--accent-color)';\n\t\taddLog(`File selected: ${file.name}`, 'info');\n\n\t\t// Read File\n\t\tconst reader = new FileReader();\n\t\treader.onload = (e) => {\n\t\t\tlet unvalidatedJSON: any;\n\t\t\ttry {\n\t\t\t\tconst result = e.target?.result;\n\t\t\t\tif (typeof result !== 'string') throw new Error('Failed to read file');\n\t\t\t\tunvalidatedJSON = JSON.parse(result);\n\t\t\t} catch (error) {\n\t\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\t\taddLog(`✗ Error parsing JSON: ${message}`, 'error');\n\t\t\t\tfileName.textContent = `❌ INVALID JSON SYNTAX: ${file.name}`;\n\t\t\t\tfileName.style.color = 'var(--danger-color)';\n\t\t\t\tgamesData = null;\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst parseResult = SPRTGamesSchema.safeParse(unvalidatedJSON);\n\t\t\tif (!parseResult.success) {\n\t\t\t\taddLog('✗ JSON schema validation failed', 'error');\n\t\t\t\tconst issues = parseResult.error.issues.map((i) => i.message).join(', ');\n\t\t\t\taddLog(`Details: ${issues}`, 'error');\n\t\t\t\tfileName.textContent = `❌ INVALID SCHEMA: ${file.name}`;\n\t\t\t\tfileName.style.color = 'var(--danger-color)';\n\t\t\t\tgamesData = null;\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tgamesData = parseResult.data;\n\t\t\taddLog(`✓ Loaded ${gamesData.length} game notation(s)`, 'success');\n\t\t\tvalidateGames();\n\t\t};\n\t\treader.readAsText(file);\n\t}\n}\n\nfunction terminateWorkers(): void {\n\tactiveWorkers.forEach((w) => w.terminate());\n\tactiveWorkers = [];\n}\n\nasync function validateGames(): Promise<void> {\n\tconst runId = currentValidationId;\n\tif (!gamesData) return;\n\n\t// -- Parallelization Setup --\n\t// Use hardware concurrency (logic cores), default to 4 if unavailable\n\tconst threadCount = navigator.hardwareConcurrency || 4;\n\tconst totalGames = gamesData.length;\n\n\t// Initialize Result Container\n\tconst globalResults: ValidationResults = {\n\t\ttotal: totalGames,\n\t\tsuccessful: 0,\n\t\ticnconverterErrors: 0,\n\t\tformulatorErrors: 0,\n\t\tillegalMoveErrors: 0,\n\t\tterminationMismatchErrors: 0,\n\t\terrors: [],\n\t\tvariantErrors: {},\n\t};\n\n\t// Reset UI displays\n\tdocument.getElementById('summary-section')!.style.display = 'none';\n\tdocument.getElementById('variant-section')!.style.display = 'none';\n\tdocument.getElementById('variant-stats')!.innerHTML = '';\n\tdocument.getElementById('errors-section')!.style.display = 'none';\n\tdocument.getElementById('error-list')!.innerHTML = '';\n\n\t// Reset progress UI immediately\n\tprogressFill.style.width = '0%';\n\tprogressFill.textContent = '0%';\n\tprogressText.textContent = `Processed 0 / ${totalGames}`;\n\tprogressSection.style.display = 'block';\n\n\taddLog(`Starting parallel validation with ${threadCount} workers...`, 'info');\n\n\tlet gamesProcessed = 0;\n\tlet workersDone = 0;\n\n\t// Determine chunk size\n\tconst chunkSize = Math.ceil(totalGames / threadCount);\n\n\tfor (let i = 0; i < threadCount; i++) {\n\t\t// Stop if cancelled during spawn loop\n\t\tif (runId !== currentValidationId) return;\n\n\t\tconst start = i * chunkSize;\n\t\tconst end = Math.min(start + chunkSize, totalGames);\n\n\t\t// If we ran out of games (e.g., 3 games, 4 threads), skip\n\t\tif (start >= totalGames) {\n\t\t\tworkersDone++; // Count as done so we don't hang\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Prepare data slice (Add index so we know which game is which)\n\t\tconst slice = gamesData.slice(start, end).map((game, idx) => ({\n\t\t\tindex: start + idx + 1, // 1-based index for UI\n\t\t\ticn: game,\n\t\t}));\n\n\t\t// Spawn Worker\n\t\tconst worker = new Worker('scripts/esm/workers/icnvalidator.worker.js', { type: 'module' });\n\t\tactiveWorkers.push(worker);\n\n\t\t// Handle Worker Loading Errors (e.g., 404, script syntax error)\n\t\tworker.onerror = (error) => {\n\t\t\tif (runId !== currentValidationId) return;\n\n\t\t\tconst msg = error.message || 'Failed to load worker script';\n\t\t\taddLog(`✗ System Error: Worker failed to start - ${msg}`, 'error');\n\n\t\t\t// Update UI to show critical failure\n\t\t\tfileName.textContent = `❌ SYSTEM ERROR: Worker Script Failed`;\n\t\t\tfileName.style.color = 'var(--danger-color)';\n\n\t\t\t// Abort the entire run\n\t\t\tterminateWorkers();\n\t\t\tcurrentValidationId++; // Invalidate runId to stop loop/other callbacks\n\n\t\t\tprogressSection.style.display = 'none';\n\t\t};\n\n\t\t// Track progress specific to this worker to avoid double-counting at the end\n\t\tlet itemsProcessedInChunk = 0;\n\n\t\t// Handle Messages\n\t\tworker.onmessage = (e: MessageEvent<WorkerMessage>) => {\n\t\t\tif (e.data.type === 'progress') {\n\t\t\t\t// Update counters\n\t\t\t\tconst count = e.data.count;\n\t\t\t\titemsProcessedInChunk += count;\n\t\t\t\tgamesProcessed += count;\n\n\t\t\t\t// Update UI immediately\n\t\t\t\tconst pct = ((gamesProcessed / totalGames) * 100).toFixed(1);\n\t\t\t\tprogressFill.style.width = pct + '%';\n\t\t\t\tprogressFill.textContent = pct + '%';\n\t\t\t\tprogressText.textContent = `Processed ${gamesProcessed} / ${totalGames}`;\n\t\t\t} else if (e.data.type === 'done') {\n\t\t\t\t// Worker finished its batch\n\t\t\t\tconst { results } = e.data;\n\n\t\t\t\t// Merge Counts\n\t\t\t\tglobalResults.successful += results.successfulCount;\n\t\t\t\tglobalResults.icnconverterErrors += results.icnconverterErrors;\n\t\t\t\tglobalResults.formulatorErrors += results.formulatorErrors;\n\t\t\t\tglobalResults.illegalMoveErrors += results.illegalMoveErrors;\n\t\t\t\tglobalResults.terminationMismatchErrors += results.terminationMismatchErrors;\n\n\t\t\t\t// Merge Arrays/Objects\n\t\t\t\tglobalResults.errors.push(...results.errors);\n\n\t\t\t\t// Merge Variant Stats\n\t\t\t\tfor (const [variant, stats] of Object.entries(\n\t\t\t\t\tresults.variantErrors as Record<string, VariantStats>,\n\t\t\t\t)) {\n\t\t\t\t\tif (!globalResults.variantErrors[variant]) {\n\t\t\t\t\t\tglobalResults.variantErrors[variant] = { ...stats };\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst existing = globalResults.variantErrors[variant]!;\n\t\t\t\t\t\texisting.total += stats.total;\n\t\t\t\t\t\texisting.icn += stats.icn;\n\t\t\t\t\t\texisting.formulator += stats.formulator;\n\t\t\t\t\t\texisting.illegal += stats.illegal;\n\t\t\t\t\t\texisting.termination += stats.termination;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Calculate remaining items (errors or final batch < 50) that weren't reported in progress\n\t\t\t\tconst chunkTotal = end - start;\n\t\t\t\tconst remainder = chunkTotal - itemsProcessedInChunk;\n\t\t\t\tgamesProcessed += remainder;\n\t\t\t\tworkersDone++;\n\n\t\t\t\t// Final UI update for this chunk\n\t\t\t\tconst pct = ((gamesProcessed / totalGames) * 100).toFixed(1);\n\t\t\t\tprogressFill.style.width = pct + '%';\n\t\t\t\tprogressFill.textContent = pct + '%';\n\t\t\t\tprogressText.textContent = `Processed ${gamesProcessed} / ${totalGames}`;\n\n\t\t\t\t// Check completion\n\t\t\t\tif (workersDone === threadCount) {\n\t\t\t\t\t// Sort errors by index so they appear in order\n\t\t\t\t\tglobalResults.errors.sort((a, b) => a.gameIndex - b.gameIndex);\n\n\t\t\t\t\tfinishValidation(globalResults, runId);\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\t// Start the worker\n\t\tworker.postMessage({ chunkId: i, games: slice });\n\t}\n}\n\nfunction finishValidation(results: ValidationResults, runId: number): void {\n\tif (runId !== currentValidationId) return;\n\n\tprogressSection.style.display = 'none';\n\tdisplayResults(results);\n\n\tconst pct = results.total > 0 ? (results.successful / results.total) * 100 : 0;\n\tlet logType: LogType = 'error';\n\tif (results.successful === results.total) logType = 'success';\n\telse if (pct >= 90) logType = 'warning';\n\n\taddLog(`✓ Validation complete: ${results.successful}/${results.total} successful`, logType);\n\tterminateWorkers(); // Clean up\n}\n\n// --- Display Logic ---\n\nfunction displayResults(results: ValidationResults): void {\n\t// Percentage Calculation\n\tconst percentage = results.total > 0 ? (results.successful / results.total) * 100 : 0;\n\tconst percentageStr = Number.isInteger(percentage)\n\t\t? percentage.toString() + '%'\n\t\t: percentage.toFixed(1) + '%';\n\n\t// Hero Stats\n\tconst ratioEl = document.getElementById('pass-ratio')!;\n\tconst percentEl = document.getElementById('pass-percentage')!;\n\tratioEl.textContent = `${results.successful} / ${results.total}`;\n\tpercentEl.textContent = percentageStr;\n\n\t// Set colors based on score\n\tratioEl.className = 'hero-value';\n\tpercentEl.className = 'hero-value';\n\n\tif (results.successful === results.total && results.total > 0) {\n\t\tratioEl.classList.add('perfect');\n\t\tpercentEl.classList.add('perfect');\n\t} else if (percentage >= 90) {\n\t\tratioEl.classList.add('good');\n\t\tpercentEl.classList.add('good');\n\t} else if (percentage >= 80) {\n\t\tratioEl.classList.add('bad');\n\t\tpercentEl.classList.add('bad');\n\t} else {\n\t\tratioEl.classList.add('terrible');\n\t\tpercentEl.classList.add('terrible');\n\t}\n\n\t// Update Grid\n\tconst updateStat = (id: string, count: number): void => {\n\t\tconst el = document.getElementById(id)!;\n\t\tel.textContent = String(count);\n\t\tel.className = 'stat-value';\n\t\tif (count === 0) el.classList.add('success');\n\t\telse if (count < 10) el.classList.add('warning');\n\t\telse el.classList.add('error');\n\t};\n\n\tupdateStat('icnconverter-errors', results.icnconverterErrors);\n\tupdateStat('formulator-errors', results.formulatorErrors);\n\tupdateStat('illegal-move-errors', results.illegalMoveErrors);\n\tupdateStat('termination-mismatch-errors', results.terminationMismatchErrors);\n\n\tdocument.getElementById('summary-section')!.style.display = 'block';\n\n\t// Variant Stats\n\tif (Object.keys(results.variantErrors).length > 0) {\n\t\tconst variantStats = document.getElementById('variant-stats')!;\n\t\tvariantStats.innerHTML = '';\n\t\tconst sortedVariants = Object.entries(results.variantErrors).sort(\n\t\t\t(a, b) => b[1].total - a[1].total,\n\t\t);\n\n\t\tfor (const [variant, stats] of sortedVariants) {\n\t\t\tconst variantItem = document.createElement('div');\n\t\t\tvariantItem.className = 'variant-item';\n\n\t\t\tconst buildStat = (\n\t\t\t\tlabel: string,\n\t\t\t\tcount: number,\n\t\t\t\tisAlwaysWarn: boolean = false,\n\t\t\t): string => {\n\t\t\t\tif (count === 0) return '';\n\t\t\t\tlet type = 'warn';\n\t\t\t\tif (!isAlwaysWarn && count > 3) type = 'err';\n\t\t\t\treturn `<div class=\"v-stat ${type} active\"><span>${count}</span> ${label}</div>`;\n\t\t\t};\n\n\t\t\tconst totalClass = stats.total > 4 ? 'err' : 'warn';\n\t\t\tvariantItem.innerHTML = `\n                <div class=\"variant-header\">\n                    <span class=\"variant-name\">${variant}</span>\n                    <span class=\"variant-errors ${totalClass}\">${stats.total} total error(s)</span>\n                </div>\n                <div class=\"variant-details\">\n                    ${buildStat('ICN', stats.icn, true)}\n                    ${buildStat('Formulator', stats.formulator)}\n                    ${buildStat('Illegal', stats.illegal)}\n                    ${buildStat('Mismatch', stats.termination)}\n                </div>\n            `;\n\t\t\tvariantStats.appendChild(variantItem);\n\t\t}\n\t\tdocument.getElementById('variant-section')!.style.display = 'block';\n\t}\n\n\t// Error List\n\tif (results.errors.length > 0) {\n\t\tconst errorList = document.getElementById('error-list')!;\n\t\terrorList.innerHTML = '';\n\t\tfor (const error of results.errors) {\n\t\t\tconst errorItem = document.createElement('div');\n\t\t\terrorItem.className = `error-item ${error.phase}`;\n\t\t\tlet metadataHtml = '';\n\t\t\tif (error.phase === 'termination-mismatch') {\n\t\t\t\tmetadataHtml = `\n\t\t\t\t\t<div style=\"margin-top: 0.5rem; font-size: 0.9em; color: var(--accent-color);\">\n\t\t\t\t\t\t<div><strong>Termination:</strong> ${error.termination || 'undefined'}</div>\n\t\t\t\t\t\t<div><strong>Result:</strong> ${error.result || 'undefined'}</div>\n\t\t\t\t\t\t<div><strong>Game Conclusion:</strong> ${JSON.stringify(error.gameConclusion) || 'undefined'}</div>\n\t\t\t\t\t</div>\n\t\t\t\t`;\n\t\t\t}\n\t\t\terrorItem.innerHTML = `\n                <div class=\"error-header\">\n                    <span>Game #${error.gameIndex}${error.variant ? ` - ${error.variant}` : ''}</span>\n                    <span class=\"error-type ${error.phase}\">${error.phase}</span>\n                </div>\n                <div class=\"error-message\">${error.error}</div>\n                ${metadataHtml}\n                <details style=\"margin-top: 0.5rem;\">\n                    <summary style=\"cursor: pointer; color: var(--accent-color);\">View ICN snippet</summary>\n                    <div class=\"error-message\" style=\"margin-top: 0.5rem;\">${error.icn}</div>\n                </details>\n            `;\n\t\t\terrorList.appendChild(errorItem);\n\t\t}\n\t\tdocument.getElementById('errors-section')!.style.display = 'block';\n\t}\n}\n\nfunction addLog(message: string, type: LogType = 'info'): void {\n\tconst logOutput = document.getElementById('log-output')!;\n\tconst entry = document.createElement('div');\n\tentry.className = `log-entry ${type}`;\n\tentry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;\n\tlogOutput.appendChild(entry);\n\tlogOutput.scrollTop = logOutput.scrollHeight;\n}\n"
  },
  {
    "path": "src/client/scripts/esm/views/index.ts",
    "content": "// src/client/scripts/esm/views/index.ts\n\n/**\n * Type definition for a contributor object.\n */\ninterface Contributor {\n\tname: string;\n\tcontributionCount: number;\n\tlinkUrl: string;\n\ticonUrl: string;\n}\n\n/**\n * Fetches GitHub contributors and appends them to the document.\n */\n(async function fetchGitHubContributors(): Promise<void> {\n\ttry {\n\t\tconst githubContributors = document.querySelector<HTMLElement>('.github-container');\n\t\tif (!githubContributors) {\n\t\t\tconsole.warn('GitHub contributors container not found.');\n\t\t\treturn;\n\t\t}\n\n\t\tconst response = await fetch('/api/contributors');\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`Failed to fetch contributors: ${response.statusText}`);\n\t\t}\n\n\t\tconst contributors: Contributor[] = await response.json();\n\t\tconst fragment = document.createDocumentFragment();\n\n\t\tcontributors.forEach((contributor) => {\n\t\t\tconst link = document.createElement('a');\n\t\t\tlink.href = contributor.linkUrl;\n\n\t\t\tconst iconImg = document.createElement('img');\n\t\t\ticonImg.src = contributor.iconUrl;\n\n\t\t\tconst githubStatsContainer = document.createElement('div');\n\t\t\tgithubStatsContainer.classList.add('github-stats');\n\n\t\t\tconst name = document.createElement('p');\n\t\t\tname.classList.add('name');\n\t\t\tname.innerText = contributor.name;\n\n\t\t\tconst paragraph = document.createElement('p');\n\t\t\tparagraph.classList.add('contribution-count');\n\t\t\tconst contributionCountTranslationName =\n\t\t\t\tcontributor.contributionCount === 1\n\t\t\t\t\t? 'contribution_count_singular'\n\t\t\t\t\t: 'contribution_count_plural';\n\t\t\tparagraph.innerText = `${translations[contributionCountTranslationName]?.[0] || ''}${contributor.contributionCount}${translations[contributionCountTranslationName]?.[1] || ''}`;\n\n\t\t\tgithubStatsContainer.appendChild(name);\n\t\t\tgithubStatsContainer.appendChild(paragraph);\n\t\t\tlink.appendChild(iconImg);\n\t\t\tlink.appendChild(githubStatsContainer);\n\t\t\tfragment.appendChild(link);\n\t\t});\n\n\t\tgithubContributors.appendChild(fragment);\n\t} catch (error) {\n\t\tconst errMsg = error instanceof Error ? error.message : String(error);\n\t\tconsole.error(`Error during loading of contributor list: ${errMsg}`);\n\t}\n})();\n"
  },
  {
    "path": "src/client/scripts/esm/views/leaderboard.ts",
    "content": "// src/client/scripts/esm/views/leaderboard.ts\n\n/*\n * This script:\n *\n * * Fetches the data of the leaderboard page we're viewing\n * so we can display that info.\n */\n\nimport type { VariantCode } from '../../../../shared/chess/variants/variantdictionary.js';\nimport type { UsernameItem } from '../util/usernamecontainer.js';\n\nimport {\n\tLeaderboards,\n\tVariantLeaderboards,\n} from '../../../../shared/chess/variants/validleaderboard.js';\n\nimport validatorama from '../util/validatorama.js';\nimport usernamecontainer from '../util/usernamecontainer.js';\n\n// --- DOM Element Selection ---\nconst element_LeaderboardContainer = document.getElementById('leaderboard-table')!;\nconst element_supportedVariants = document.getElementById('supported-variants')!;\nconst element_ShowMoreButton: HTMLButtonElement = document.getElementById(\n\t'show_more_button',\n)! as HTMLButtonElement;\nconst element_UserRankingText = document.getElementById('user_ranking_text')!;\nconst element_UserRanking = document.getElementById('user_ranking')!;\n\n// --- Variables ---\n\n/** Number of players to be shown on leaderboard page load */\nconst LEADERBOARD_LENGTH_ON_LOAD = 50;\n/** Number of players to be added on show more button press */\nconst LEADERBOARD_SHOW_MORE_BUTTON_INCREMENT = 50;\n/** Leaderboard to be displayed */\nconst leaderboard_id = Leaderboards.INFINITY;\n\n/** Body of leaderboard table, as created in createEmptyLeaderboardTable() */\nlet element_LeaderboardTableBody: HTMLTableSectionElement;\n/** Running start rank: highest leaderboard position not shown on leaderboard yet */\nlet running_start_rank = 1;\n/**\n * Username of the player, if he is logged in, else undefined,\n * AT THE TIME OF THE initial request for our world ranking.\n */\nlet loggedInAs: string | undefined;\n/** Whether the page has already been initialized once */\nlet initialized = false;\n\n// --- Initialization ---\n\n(async function loadLeaderboardData(): Promise<void> {\n\tsetSupportedVariantsDisplay();\n\tcreateEmptyLeaderboardTable();\n\n\t// On page load, we wait for validatorama to renew our session if needed,\n\t// as the server reads our session info to know who to return a global ranking for.\n\tawait validatorama.waitUntilInitialRequestBack();\n\tloggedInAs = validatorama.getOurUsername();\n\n\tawait populateTable(LEADERBOARD_LENGTH_ON_LOAD);\n\tinitialized = true;\n\n\telement_ShowMoreButton.addEventListener('click', showMorePlayers);\n})();\n\n// --- Functions ---\n\n/**\n * Set the text below the leaderboard table, explaining which variants belong to it\n */\nfunction setSupportedVariantsDisplay(): void {\n\tconst variantslist: string[] = [];\n\tObject.entries(VariantLeaderboards).forEach(([variant, leaderboard]) => {\n\t\tif (leaderboard !== leaderboard_id) return;\n\t\tvariantslist.push(variant in translations ? translations[variant as VariantCode] : variant);\n\t});\n\telement_supportedVariants.textContent = `${translations.supported_variants} ${variantslist.join(', ')}.`;\n}\n\n/**\n * Create an empty leaderboard table upon page initialization\n */\nfunction createEmptyLeaderboardTable(): void {\n\t// Create table\n\tconst table = document.createElement('table');\n\t// Create header of table\n\tconst thead = document.createElement('thead');\n\tthead.innerHTML = `\n\t\t<tr>\n\t\t<th>${translations.rank}</th>\n\t\t<th>${translations.player}</th>\n\t\t<th>${translations.rating}</th>\n\t\t</tr>\n\t`;\n\ttable.appendChild(thead);\n\n\t// Create body of table\n\telement_LeaderboardTableBody = document.createElement('tbody');\n\ttable.appendChild(element_LeaderboardTableBody);\n\telement_LeaderboardContainer.appendChild(table);\n}\n\n/**\n * Populate the leaderboard table for the chosen leaderboard by adding the next top n players.\n * If initialized === false, then this function also populates the \"global ranking\" element at the top\n * @param n_players - number of players to add to table\n */\nasync function populateTable(n_players: number): Promise<void> {\n\tconst config: RequestInit = {\n\t\tmethod: 'GET',\n\t\theaders: {\n\t\t\t'Content-Type': 'application/json',\n\t\t\t'is-fetch-request': 'true', // Custom header\n\t\t},\n\t};\n\n\ttry {\n\t\t// Make server request\n\t\t// We need to fetch n_players + 1 and only display n_players in order to know whether the \"Show more\" button needs to be hidden\n\t\t// If initialized === false and the player is logged in, we also set find_requester_rank to 1, if possible, in order to request his rank from the server on the first page load\n\t\tconst find_requester_rank = !initialized && loggedInAs !== undefined ? 1 : 0;\n\t\tconst response = await fetch(\n\t\t\t`/leaderboard/top/${leaderboard_id}/${running_start_rank}/${n_players + 1}/${find_requester_rank}`,\n\t\t\tconfig,\n\t\t);\n\n\t\tif (response.status === 404 || response.status === 500 || !response.ok) {\n\t\t\tconsole.error(\n\t\t\t\t'Failed to fetch leaderboard data:',\n\t\t\t\tresponse.status,\n\t\t\t\tresponse.statusText,\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tconst results = await response.json();\n\t\tconsole.log(results);\n\n\t\t// Now populate the \"your global rank\" text at the top if possible\n\t\tif (!initialized && results.requesterData?.rank_string !== undefined) {\n\t\t\telement_UserRankingText.classList.remove('hidden');\n\t\t\telement_UserRanking.textContent = results.requesterData.rank_string;\n\t\t}\n\n\t\t// Iterate through all results.leaderboardData and add a row to the table body for each of them\n\t\tlet rank = running_start_rank;\n\t\tresults.leaderboardData.forEach((player: { username: string; elo: string }) => {\n\t\t\tif (rank >= running_start_rank + n_players) return;\n\t\t\tconst row = document.createElement('tr');\n\n\t\t\t// Create and append <td> for rank\n\t\t\tconst rankCell = document.createElement('td');\n\t\t\trankCell.textContent = `${rank}`;\n\t\t\trow.appendChild(rankCell);\n\n\t\t\t// Create and append <td> for username\n\t\t\tconst usernameCell = document.createElement('td');\n\t\t\tconst username_item: UsernameItem = { value: player.username, openInNewWindow: false };\n\t\t\tconst usernameContainer = usernamecontainer.createUsernameContainer(\n\t\t\t\t'player',\n\t\t\t\tusername_item,\n\t\t\t);\n\t\t\tusernamecontainer.embedUsernameContainerDisplayIntoParent(\n\t\t\t\tusernameContainer.element,\n\t\t\t\tusernameCell,\n\t\t\t);\n\t\t\tusernameCell.classList.add('fade-element'); // Usernames fade out instead of overflowing their container\n\t\t\trow.appendChild(usernameCell);\n\n\t\t\t// Create and append <td> for elo\n\t\t\tconst eloCell = document.createElement('td');\n\t\t\teloCell.textContent = player.elo;\n\t\t\trow.appendChild(eloCell);\n\n\t\t\t// Append the completed row to the table body\n\t\t\telement_LeaderboardTableBody.appendChild(row);\n\n\t\t\t// Color row of logged in user\n\t\t\tif (loggedInAs === player.username) row.classList.add('logged_in_user_entry');\n\n\t\t\trank++;\n\t\t});\n\n\t\t// Update running_start_rank\n\t\trunning_start_rank += n_players;\n\n\t\t// Hide \"show more\" button if not enough players were returned by server\n\t\tif (results.leaderboardData.length < n_players + 1)\n\t\t\telement_ShowMoreButton.classList.add('hidden');\n\t\telse element_ShowMoreButton.classList.remove('hidden');\n\t} catch (error) {\n\t\tconsole.error('Error loading leaderboard data:', error);\n\t}\n}\n\n/**\n * Populate the leaderboard table with the next highest rated players\n */\nasync function showMorePlayers(): Promise<void> {\n\t// disable the button so it can’t be clicked again while we’re fetching\n\telement_ShowMoreButton.disabled = true;\n\ttry {\n\t\tawait populateTable(LEADERBOARD_SHOW_MORE_BUTTON_INCREMENT);\n\t} finally {\n\t\t// re-enable regardless of success or failure\n\t\telement_ShowMoreButton.disabled = false;\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/views/login.ts",
    "content": "// src/client/scripts/esm/views/login.ts\n\n/**\n * This script handles the client-side logic for the login and forgot-password forms.\n */\n\n// --- Element Selectors ---\nconst element_usernameInput = document.getElementById('username') as HTMLInputElement;\nconst element_passwordInput = document.getElementById('password') as HTMLInputElement;\nconst element_submitButton = document.getElementById('submit') as HTMLInputElement;\n\nconst element_forgotLink = document.getElementById('forgot-link') as HTMLAnchorElement;\nconst element_backToLoginLink = document.getElementById('back-to-login-link') as HTMLAnchorElement;\n\nconst element_forgotEmailInput = document.getElementById('forgot-email') as HTMLInputElement;\nconst element_forgotSubmitButton = document.getElementById('forgot-submit') as HTMLInputElement;\n\nconst element_loginForm = document.getElementById('login-form') as HTMLFormElement;\nconst element_forgotPasswordForm = document.getElementById(\n\t'forgot-password-form',\n) as HTMLFormElement;\n\nlet messageElement: HTMLElement | undefined = undefined;\n\n// --- Utility Functions ---\n\n/**\n * Reads a query‐param from the current URL.\n * @param name - The name of the parameter to read.\n * @returns The parameter's value, or null if not present.\n */\nfunction getQueryParam(name: string): string | null {\n\tconst urlParams = new URLSearchParams(window.location.search);\n\treturn urlParams.get(name);\n}\n\n/**\n * Toggles `.ready` vs `.unavailable` on a button depending on `isReady`.\n * @param btn - The button element to toggle classes on.\n * @param isReady - Boolean indicating if the button should be in a 'ready' state.\n */\nfunction toggleButtonState(btn: HTMLElement, isReady: boolean): void {\n\tbtn.classList.toggle('ready', isReady);\n\tbtn.classList.toggle('unavailable', !isReady);\n}\n\n// --- Core Logic ---\n\n/**\n * Creates a message <div> below a target element, with given classes.\n * Removes any existing message with the same ID first.\n * Sets ARIA attributes for accessibility.\n * @param id - The ID to assign to the new message element.\n * @param insertAfterId - The ID of an existing element to insert after.\n * @param initialClass - The CSS class to apply initially ('error' | 'success').\n * @returns The created HTMLElement or undefined on failure.\n */\nfunction createMessageElement(\n\tid: string,\n\tinsertAfterId: string,\n\tinitialClass: 'error' | 'success',\n\tmessage: string,\n): HTMLElement | undefined {\n\tconst existingMsg = document.getElementById(id);\n\tif (existingMsg) existingMsg.remove();\n\tif (messageElement && messageElement.id === id) messageElement = undefined;\n\n\tconst el = document.createElement('div');\n\tel.id = id;\n\tel.className = initialClass;\n\tel.setAttribute('role', 'alert');\n\tel.setAttribute('aria-live', initialClass === 'error' ? 'assertive' : 'polite');\n\tel.textContent = message;\n\n\tconst anchorElement = document.getElementById(insertAfterId);\n\tif (anchorElement && anchorElement.parentNode) {\n\t\tanchorElement.parentNode.insertBefore(el, anchorElement.nextSibling);\n\t} else {\n\t\tconsole.error(\n\t\t\t`[DOM Error] Anchor element with ID '${insertAfterId}' not found for message insertion.`,\n\t\t);\n\t\tconst visibleForm =\n\t\t\telement_loginForm && !element_loginForm.classList.contains('hidden')\n\t\t\t\t? element_loginForm\n\t\t\t\t: element_forgotPasswordForm;\n\t\tvisibleForm.appendChild(el);\n\t}\n\treturn el;\n}\n\n/**\n * Clears any currently displayed message element from the DOM.\n */\nfunction clearMessage(): void {\n\tif (messageElement) {\n\t\tmessageElement.remove();\n\t\tmessageElement = undefined;\n\t}\n}\n\n/**\n * Updates the login submit button's state (ready/unavailable).\n */\nfunction updateSubmitButton(): void {\n\tconst isMessageBlocking = messageElement && messageElement.id === 'login-error-message';\n\tconst isReady = !!(\n\t\telement_usernameInput.value.trim() &&\n\t\telement_passwordInput.value.trim() &&\n\t\t!isMessageBlocking\n\t);\n\ttoggleButtonState(element_submitButton, isReady);\n}\n\n/**\n * Updates the forgot-password submit button's state (ready/unavailable).\n */\nfunction updateForgotSubmitButton(): void {\n\tconst isMessageBlocking = messageElement && messageElement.id === 'forgot-message';\n\tconst isReady = !!(element_forgotEmailInput.value.trim() && !isMessageBlocking);\n\ttoggleButtonState(element_forgotSubmitButton, isReady);\n}\n\n/**\n * Handles user input on username/password/forgot inputs.\n * Clears messages and updates button states accordingly.\n */\nfunction handleInput(): void {\n\tclearMessage();\n\tupdateSubmitButton();\n\tupdateForgotSubmitButton();\n}\n\n/**\n * Shows the login form and hides the forgot-password form.\n * Manages ARIA attributes and focus.\n */\nfunction showLoginForm(): void {\n\tclearMessage();\n\n\telement_loginForm.classList.remove('hidden');\n\n\telement_forgotPasswordForm.classList.add('hidden');\n\n\telement_forgotLink.classList.remove('hidden');\n\telement_backToLoginLink.classList.add('hidden');\n\n\telement_forgotEmailInput.value = '';\n\n\telement_usernameInput.focus();\n\n\tupdateSubmitButton();\n\tupdateForgotSubmitButton();\n}\n\n/**\n * Shows the forgot-password form and hides the login form.\n * Manages ARIA attributes and focus.\n */\nfunction showForgotPasswordForm(): void {\n\tclearMessage();\n\n\telement_loginForm.classList.add('hidden');\n\n\telement_forgotPasswordForm.classList.remove('hidden');\n\n\telement_forgotLink.classList.add('hidden');\n\telement_backToLoginLink.classList.remove('hidden');\n\n\telement_usernameInput.value = '';\n\telement_passwordInput.value = '';\n\n\telement_forgotEmailInput.focus();\n\n\tupdateSubmitButton();\n\tupdateForgotSubmitButton();\n}\n\n/**\n * Sends a login request to the server.\n * @param username - The user's username (case preserved).\n * @param password - The user's plaintext password.\n */\nasync function sendLogin(username: string, password: string): Promise<void> {\n\telement_submitButton.disabled = true;\n\ttoggleButtonState(element_submitButton, false);\n\tclearMessage();\n\n\ttry {\n\t\tconst response = await fetch('/auth', {\n\t\t\tmethod: 'POST',\n\t\t\theaders: { 'Content-Type': 'application/json', 'is-fetch-request': 'true' },\n\t\t\tcredentials: 'same-origin',\n\t\t\tbody: JSON.stringify({ username, password }),\n\t\t});\n\n\t\tconst result = (await response.json()) as { message: string };\n\n\t\tif (response.ok) {\n\t\t\t// SUCCESS\n\t\t\tconst redirectTo = getQueryParam('redirectTo');\n\t\t\tif (redirectTo) window.location.href = redirectTo;\n\t\t\telse window.location.href = `/member/${username.toLowerCase()}`;\n\t\t} else {\n\t\t\t// NOT OK\n\t\t\tmessageElement = createMessageElement(\n\t\t\t\t'login-error-message',\n\t\t\t\t'password-input-line',\n\t\t\t\t'error',\n\t\t\t\tresult.message,\n\t\t\t);\n\t\t}\n\t} catch (e: unknown) {\n\t\tconsole.error('Login fetch/processing error:', e);\n\t\tmessageElement = createMessageElement(\n\t\t\t'login-error-message',\n\t\t\t'password-input-line',\n\t\t\t'error',\n\t\t\ttranslations['network-error'],\n\t\t);\n\t}\n\n\telement_submitButton.disabled = false;\n\tupdateSubmitButton();\n}\n\n/**\n * Sends a forgot-password request to the server.\n * @param email - The email address to send password-reset instructions to.\n */\nasync function sendForgotPasswordRequest(email: string): Promise<void> {\n\telement_forgotSubmitButton.disabled = true;\n\ttoggleButtonState(element_forgotSubmitButton, false);\n\tclearMessage();\n\n\ttry {\n\t\tconst response = await fetch('/forgot-password', {\n\t\t\tmethod: 'POST',\n\t\t\theaders: { 'Content-Type': 'application/json', 'is-fetch-request': 'true' },\n\t\t\tbody: JSON.stringify({ email }),\n\t\t});\n\n\t\tconst result = (await response.json()) as { message: string };\n\n\t\tif (response.ok)\n\t\t\tmessageElement = createMessageElement(\n\t\t\t\t'forgot-message',\n\t\t\t\t'email-input-line',\n\t\t\t\t'success',\n\t\t\t\tresult.message,\n\t\t\t);\n\t\telse\n\t\t\tmessageElement = createMessageElement(\n\t\t\t\t'forgot-message',\n\t\t\t\t'email-input-line',\n\t\t\t\t'error',\n\t\t\t\tresult.message,\n\t\t\t);\n\t} catch (e: unknown) {\n\t\tconst errorMessage = e instanceof Error ? e.message : String(e);\n\t\tconsole.error('Forgot password fetch/processing error:', errorMessage);\n\t\tmessageElement = createMessageElement(\n\t\t\t'forgot-message',\n\t\t\t'email-input-line',\n\t\t\t'error',\n\t\t\ttranslations['network-error'],\n\t\t);\n\t}\n\n\telement_forgotSubmitButton.disabled = false;\n\tupdateForgotSubmitButton();\n}\n\n// --- Script Entry Point ---\n\nif (\n\t!element_usernameInput ||\n\t!element_passwordInput ||\n\t!element_forgotEmailInput ||\n\t!element_loginForm ||\n\t!element_forgotPasswordForm ||\n\t!element_submitButton ||\n\t!element_forgotSubmitButton ||\n\t!element_forgotLink ||\n\t!element_backToLoginLink\n) {\n\tthrow Error('Required input elements are missing from the DOM.');\n}\n\n// --- Event Listener Setup ---\n\nelement_usernameInput.addEventListener('input', handleInput);\nelement_passwordInput.addEventListener('input', handleInput);\nelement_forgotEmailInput.addEventListener('input', handleInput);\n\nelement_forgotLink.addEventListener('click', (event: MouseEvent): void => {\n\tevent.preventDefault();\n\tshowForgotPasswordForm();\n});\n\nelement_backToLoginLink.addEventListener('click', (event: MouseEvent): void => {\n\tevent.preventDefault();\n\tshowLoginForm();\n});\nelement_loginForm.addEventListener('submit', (event: SubmitEvent): void => {\n\tevent.preventDefault();\n\tif (\n\t\telement_submitButton?.classList.contains('ready') &&\n\t\t(!messageElement || messageElement.id !== 'login-error-message')\n\t) {\n\t\tsendLogin(element_usernameInput.value, element_passwordInput.value);\n\t}\n});\nelement_forgotPasswordForm.addEventListener('submit', (event: SubmitEvent): void => {\n\tevent.preventDefault();\n\tif (element_forgotSubmitButton?.classList.contains('ready')) {\n\t\tif (element_forgotEmailInput.value.trim() !== '') {\n\t\t\tsendForgotPasswordRequest(element_forgotEmailInput.value);\n\t\t}\n\t}\n});\n\n// --- Initial Setup ---\n\nupdateSubmitButton();\nupdateForgotSubmitButton();\nelement_usernameInput.focus();\n"
  },
  {
    "path": "src/client/scripts/esm/views/member.ts",
    "content": "// src/client/scripts/esm/views/member.ts\n\n/*\n * This script:\n *\n * * Fetches the data of the member's page we're viewing\n * so we can display that info.\n *\n * * Dynamically adjusts the font-size of the username.\n * Resends confirmation emails upon clicking the button.\n *\n * * Deletes account when button clicked and password entered.\n */\n\nimport validcheckmates from '../../../../shared/chess/util/validcheckmates.js';\n\nimport docutil from '../util/docutil.js';\nimport validatorama from '../util/validatorama.js';\nimport languagedropdown from '../components/header/dropdowns/languagedropdown.js';\n\n// Types ---------------------------------------------------------------------------------\n\ninterface MemberData {\n\tjoined: string;\n\tseen: string;\n\tusername: string;\n\tcheckmates_beaten: string;\n\tranked_elo: string;\n\tinfinity_leaderboard_position: number | undefined;\n\tinfinity_leaderboard_rating_deviation: number | undefined;\n\t// Only present/relevant if viewing our own profile\n\temail?: string;\n\tverified?: boolean;\n\tverified_notified?: boolean; // True if they've seen the \"thank you\" message.\n}\n\n// Elements -----------------------------------------------------------------------\n\nconst element_verifyErrorElement = document.getElementById('verifyerror')!;\nconst element_verifyConfirmElement = document.getElementById('verifyconfirm')!;\nconst element_sendEmail = document.getElementById('sendemail') as HTMLAnchorElement;\n\nconst element_memberName = document.getElementById('membername')!;\n\nconst element_checkmateBadgeBronze = document.getElementById(\n\t'checkmate-badge-bronze',\n) as HTMLImageElement;\nconst element_checkmateBadgeSilver = document.getElementById(\n\t'checkmate-badge-silver',\n) as HTMLImageElement;\nconst element_checkmateBadgeGold = document.getElementById(\n\t'checkmate-badge-gold',\n) as HTMLImageElement;\n\nconst element_showAccountInfo = document.getElementById('show-account-info') as HTMLButtonElement;\nconst element_deleteAccount = document.getElementById('delete-account') as HTMLButtonElement;\nconst element_accountInfo = document.getElementById('accountinfo')!;\nconst element_email = document.getElementById('email')!;\n\n// --- Event Listeners Setup ---\n\nelement_sendEmail.addEventListener('click', resendConfirmEmail);\nelement_showAccountInfo.addEventListener('click', showAccountInfo);\n// Note: deleteAccount listener added later conditionally\n\n// --- State ---\n\nlet isOurProfile: boolean = false;\nconst member: string = docutil.getLastSegmentOfURL(); // Assuming returns string\n\n// --- Initialization ---\n\n(async function loadMemberData(): Promise<void> {\n\t// We have to wait for validatorama here because it might be attempting\n\t// to refresh our session in which case our session cookies will change\n\t// so our refresh token in this here fetch request here would then be invalid\n\tawait validatorama.waitUntilInitialRequestBack();\n\n\tconst config: RequestInit = {\n\t\tmethod: 'GET',\n\t\theaders: {\n\t\t\t'Content-Type': 'application/json',\n\t\t\t'is-fetch-request': 'true', // Custom header\n\t\t},\n\t};\n\t// Server reads refresh token cookie, no Authorization header needed here as per original comments\n\n\ttry {\n\t\tconst response = await fetch(`/member/${member}/data`, config);\n\n\t\tif (response.status === 404) {\n\t\t\twindow.location.href = languagedropdown.addLngQueryParamToLink('/404'); // Use href for navigation\n\t\t\treturn;\n\t\t}\n\t\tif (response.status === 500) {\n\t\t\twindow.location.href = languagedropdown.addLngQueryParamToLink('/500');\n\t\t\treturn;\n\t\t}\n\t\tif (!response.ok) {\n\t\t\t// Handle other potential errors if needed\n\t\t\tconsole.error('Failed to fetch member data:', response.status, response.statusText);\n\t\t\t// Potentially redirect to an error page or show a message\n\t\t\t// For now, let's assume it resolves to JSON even on error based on later code\n\t\t\t// but ideally, handle non-JSON error responses too.\n\t\t\treturn;\n\t\t}\n\n\t\tconst result: MemberData = await response.json();\n\t\tconsole.log(result); // { joined, seen, username, email, verified, checkmates_beaten, ranked_elo }\n\n\t\t// Change on-screen data of the member\n\t\telement_memberName.textContent = result.username;\n\t\tconst joinedElement = document.getElementById('joined')!;\n\t\tjoinedElement.textContent = result.joined;\n\t\tconst seenElement = document.getElementById('seen')!;\n\t\tseenElement.textContent = result.seen;\n\t\tupdateCompletedCheckmatesInformation(result.checkmates_beaten);\n\n\t\tconst eloElement = document.getElementById('ranked_elo')!;\n\t\teloElement.textContent = result.ranked_elo;\n\n\t\tconst infinityLeaderboardPositionElement = document.getElementById(\n\t\t\t'infinity_leaderboard_position',\n\t\t)!;\n\t\tinfinityLeaderboardPositionElement.textContent =\n\t\t\tresult.infinity_leaderboard_position === undefined\n\t\t\t\t? '?'\n\t\t\t\t: result.ranked_elo.slice(-1) !== '?'\n\t\t\t\t\t? '#' + String(result.infinity_leaderboard_position)\n\t\t\t\t\t: `#${result.infinity_leaderboard_position}?`;\n\n\t\tconst infinityLeaderboardRatingDeviationElement = document.getElementById(\n\t\t\t'infinity_leaderboard_rating_deviation',\n\t\t)!;\n\t\tinfinityLeaderboardRatingDeviationElement.textContent =\n\t\t\tresult.infinity_leaderboard_rating_deviation === undefined\n\t\t\t\t? '?'\n\t\t\t\t: String(result.infinity_leaderboard_rating_deviation);\n\n\t\tconst loggedInAs = validatorama.getOurUsername(); // Assuming returns string | null\n\n\t\t// Is it our own profile?\n\t\tif (loggedInAs && loggedInAs === result.username) {\n\t\t\tisOurProfile = true;\n\n\t\t\t// --- Verification Banner Logic (Runs ONLY for our own profile) ---\n\t\t\t// The API only sends these properties if we are viewing our own profile.\n\t\t\tif (result.verified === false) {\n\t\t\t\t// If they are not verified, show the \"Please Verify\" error banner.\n\t\t\t\telement_verifyErrorElement.classList.remove('hidden');\n\t\t\t} else if (result.verified === true && result.verified_notified === false) {\n\t\t\t\t// If they ARE verified, but have NOT been notified yet, show the \"Thank you\" confirmation banner.\n\t\t\t\t// The server will have now marked them as notified for all future page loads.\n\t\t\t\telement_verifyConfirmElement.classList.remove('hidden');\n\t\t\t}\n\n\t\t\t// --- Display elements specific to own profile ---\n\t\t\telement_showAccountInfo.classList.remove('hidden');\n\t\t\telement_deleteAccount.classList.remove('hidden');\n\t\t\telement_deleteAccount.addEventListener('click', () => removeAccount(true));\n\t\t\telement_email.textContent = result.email!;\n\t\t}\n\n\t\t// Change username text size depending on character count\n\t\trecalcUsernameSize();\n\t} catch (error) {\n\t\tconsole.error('Error loading member data:', error);\n\t\t// Redirect to a generic error page or display an error message\n\t\t// window.location.href = languagedropdown.addLngQueryParamToLink('/500'); // Example\n\t}\n})();\n\n/**\n * Updates the counter on your profile telling you how many total checkmate practices you have beaten.\n * Also updates the badges.\n * \"Practice Mode Progress: 3 / 33\"\n * @param checkmates_beaten - Comma-delimited string of beaten checkmate IDs.\n */\nfunction updateCompletedCheckmatesInformation(checkmates_beaten: string): void {\n\tconst practiceProgressElement = document.getElementById('practice_progress')!;\n\tconst completedCheckmates = checkmates_beaten ? checkmates_beaten.match(/[^,]+/g) || [] : []; // Handle empty/null string\n\tconst numCompleted = completedCheckmates.length;\n\tconst numTotal = Object.values(validcheckmates.validCheckmates).flat().length;\n\n\tpracticeProgressElement.textContent = `${numCompleted} / ${numTotal}`;\n\tlet shownBadge: HTMLImageElement | null = null;\n\tif (numCompleted >= numTotal) shownBadge = element_checkmateBadgeGold;\n\telse if (numCompleted >= 0.75 * numTotal) shownBadge = element_checkmateBadgeSilver;\n\telse if (numCompleted >= 0.5 * numTotal) shownBadge = element_checkmateBadgeBronze;\n\n\t// Ensure only the correct badge (or none) is shown\n\t[\n\t\telement_checkmateBadgeBronze,\n\t\telement_checkmateBadgeSilver,\n\t\telement_checkmateBadgeGold,\n\t].forEach((badge) => {\n\t\tif (badge === shownBadge) badge.classList.remove('hidden');\n\t\telse badge.classList.add('hidden');\n\t});\n}\n\n/** Reveals the account information section. */\nfunction showAccountInfo(): void {\n\t// Called from inside the html via event listener\n\telement_showAccountInfo.classList.add('hidden');\n\telement_accountInfo.classList.remove('hidden');\n}\n\n/**\n * Handles the account deletion process.\n * @param confirmation - Whether to show the initial confirmation dialog.\n */\nasync function removeAccount(confirmation: boolean): Promise<void> {\n\tif (confirmation) {\n\t\tif (!confirm(translations['js-confirm_delete'])) return; // User cancelled the initial confirmation\n\t}\n\n\tconst password = prompt(translations['js-enter_password']);\n\tconst cancelWasPressed = password === null;\n\tif (cancelWasPressed) return; // User pressed Cancel in the password prompt\n\n\t// Password entered (even if empty string), proceed with deletion attempt\n\tconst config: RequestInit = {\n\t\tmethod: 'DELETE',\n\t\theaders: {\n\t\t\t'Content-Type': 'application/json',\n\t\t\t'is-fetch-request': 'true', // Custom header\n\t\t},\n\t\tbody: JSON.stringify({ password }), // Send password in body\n\t\tcredentials: 'same-origin', // Allows cookies (like session/CSRF) to be sent\n\t};\n\n\ttry {\n\t\tconst response = await fetch(`/member/${member}/delete`, config);\n\n\t\tif (!response.ok) {\n\t\t\t// Probably incorrect password\n\t\t\t// Attempt to parse error message from server\n\t\t\tconst result: { message: string } = await response.json();\n\t\t\talert(result.message); // Show server error message\n\t\t\t// Call removeAccount(false) again to re-prompt for password\n\t\t\tremoveAccount(false); // Re-prompt without initial confirmation\n\t\t} else {\n\t\t\t// Deletion successful, redirect to homepage\n\t\t\twindow.location.href = languagedropdown.addLngQueryParamToLink('/');\n\t\t}\n\t} catch (error) {\n\t\tconsole.error('Network or other error during account deletion:', error);\n\t\talert('An error occurred while trying to delete the account. Please try again.');\n\t}\n}\n\n/** Sends a request to the server to resend the confirmation email. */\nfunction resendConfirmEmail(): void {\n\tif (!isOurProfile) return; // Only allow resend if viewing own profile\n\n\tconst config: RequestInit = {\n\t\tmethod: 'POST',\n\t\theaders: {\n\t\t\t'Content-Type': 'application/json',\n\t\t\t'is-fetch-request': 'true', // Custom header\n\t\t},\n\t\tcredentials: 'same-origin',\n\t};\n\n\tfetch(`/member/${member}/send-email`, config)\n\t\t.then((response) => {\n\t\t\tif (response.status === 401) {\n\t\t\t\twindow.location.href = languagedropdown.addLngQueryParamToLink('/401'); // Unauthorized\n\t\t\t\treturn Promise.reject(new Error('Unauthorized')); // Stop processing\n\t\t\t}\n\t\t\tif (!response.ok) {\n\t\t\t\t// Handle other errors (e.g., rate limiting, server issue)\n\t\t\t\tconsole.error('Failed to resend email:', response.status, response.statusText);\n\t\t\t\t// Optionally show an error message to the user\n\t\t\t\talert('Failed to resend confirmation email. Please try again later.');\n\t\t\t\treturn Promise.reject(new Error(`Server error: ${response.status}`));\n\t\t\t}\n\t\t\treturn response.json(); // Expecting a success message perhaps?\n\t\t})\n\t\t.then((result) => {\n\t\t\t// Email was resent! Reload the page so the user knows something happened.\n\t\t\tconsole.log('Resend email result:', result); // Log success indication from server if any\n\t\t\twindow.location.reload();\n\t\t})\n\t\t.catch((error) => {\n\t\t\t// Catch errors from fetch itself or Promise.reject calls\n\t\t\tif (error.message !== 'Unauthorized' && !error.message.startsWith('Server error:')) {\n\t\t\t\tconsole.error('Error resending confirmation email:', error);\n\t\t\t\talert('An error occurred while resending the email.');\n\t\t\t}\n\t\t\t// Errors like 401 or server errors are already handled/logged in the .then block\n\t\t});\n}\n\n/** Recalculates and sets the font size of the username based on window width and text length. */\nfunction recalcUsernameSize(): void {\n\tconst usernameText = element_memberName.textContent;\n\tif (!usernameText) return; // Exit if no username text\n\n\t// Estimate available width (adjust padding/margin values as needed based on actual layout)\n\tconst otherElementsWidth = 185; // Approximate width of other elements on the same line/area\n\tconst availableWidth = (window.innerWidth - otherElementsWidth) * 0.52; // Target width factor\n\n\t// Basic scaling - adjust the factor (3) based on desired look\n\tlet fontSize = availableWidth * (3 / usernameText.length);\n\n\t// Set limits for font size\n\tconst minFontSize = 12; // Minimum readable font size\n\tconst maxFontSize = 50; // Maximum desired font size\n\tfontSize = Math.max(minFontSize, Math.min(fontSize, maxFontSize)); // Clamp font size\n\n\telement_memberName.style.fontSize = `${fontSize}px`;\n}\n\n// --- Global Event Listeners ---\n\nwindow.addEventListener('resize', recalcUsernameSize);\n"
  },
  {
    "path": "src/client/scripts/esm/views/news.ts",
    "content": "// src/client/scripts/esm/views/news.ts\n\n/**\n * This script runs on the news page.\n * It marks news as read when the page is visited and adds \"NEW\" badges to unread posts.\n */\n\nimport validatorama from '../util/validatorama.js';\n\n/**\n * Marks all news as read for the current user\n */\nasync function markNewsAsRead(): Promise<void> {\n\t// Only mark as read if user is logged in\n\tconst username = validatorama.getOurUsername();\n\tif (!username) return;\n\n\ttry {\n\t\tconst response = await fetch('/api/news/mark-read', {\n\t\t\tmethod: 'POST',\n\t\t\theaders: {\n\t\t\t\t'Content-Type': 'application/json',\n\t\t\t\t'is-fetch-request': 'true',\n\t\t\t},\n\t\t});\n\n\t\tif (response.ok) {\n\t\t\t// Dispatch event to update header badge\n\t\t\tdocument.dispatchEvent(new CustomEvent('news-marked-read'));\n\t\t}\n\t} catch (error) {\n\t\tconsole.error('Error marking news as read:', error);\n\t}\n}\n\n/**\n * Fetches the list of unread news dates\n */\nasync function fetchUnreadNewsDates(): Promise<string[]> {\n\ttry {\n\t\tconst response = await fetch('/api/news/unread-dates', {\n\t\t\theaders: {\n\t\t\t\t'is-fetch-request': 'true',\n\t\t\t},\n\t\t});\n\n\t\tif (!response.ok) return [];\n\n\t\tconst data = await response.json();\n\t\treturn data.dates || [];\n\t} catch (error) {\n\t\tconsole.error('Error fetching unread news dates:', error);\n\t\treturn [];\n\t}\n}\n\n/**\n * Adds \"NEW\" badges to unread news posts\n */\nfunction addNewBadgesToUnreadPosts(unreadDates: string[]): void {\n\tif (unreadDates.length === 0) return;\n\n\t// Find all news post elements\n\tconst newsPosts = document.querySelectorAll('.news-post');\n\n\tnewsPosts.forEach((post) => {\n\t\tconst postDate = (post as HTMLElement).dataset['date'];\n\n\t\t// Check if this post's date is in the unread list\n\t\tif (postDate && unreadDates.includes(postDate)) {\n\t\t\taddNewBadge(post as HTMLElement);\n\t\t}\n\t});\n}\n\n/**\n * Creates and adds a \"NEW\" badge to a news post\n */\nfunction addNewBadge(postElement: HTMLElement): void {\n\t// Don't add if already exists\n\tif (postElement.querySelector('.new-badge')) return;\n\n\tconst badge = document.createElement('span');\n\tbadge.className = 'new-badge';\n\tbadge.textContent = 'NEW';\n\tbadge.style.cssText = `\n\t\tdisplay: inline-block;\n\t\tbackground-color: #ff4444;\n\t\tcolor: white;\n\t\tpadding: 2px 6px;\n\t\tborder-radius: 3px;\n\t\tfont-size: 0.75em;\n\t\tfont-weight: bold;\n\t\tbox-shadow: 0 1px 3px rgba(0,0,0,0.2);\n\t`;\n\n\t// Find the date span and wrap both date and badge in a flex container\n\tconst dateSpan = postElement.querySelector('.news-post-date');\n\tif (dateSpan && dateSpan.parentNode) {\n\t\t// Create a wrapper div with flexbox\n\t\tconst wrapper = document.createElement('div');\n\t\twrapper.style.cssText = `\n\t\t\tdisplay: inline-flex;\n\t\t\tjustify-content: flex-start;\n\t\t\talign-items: center;\n\t\t\tgap: 8px;\n\t\t\tmargin-top: 1em;\n\t\t`;\n\n\t\t// Remove margin-top from date span since wrapper now has it\n\t\t(dateSpan as HTMLElement).style.marginTop = '0';\n\n\t\t// Replace the date span with the wrapper\n\t\tdateSpan.parentNode.insertBefore(wrapper, dateSpan);\n\t\twrapper.appendChild(dateSpan);\n\t\twrapper.appendChild(badge);\n\t} else {\n\t\t// If no date span, add it at the top of the post\n\t\tpostElement.insertBefore(badge, postElement.firstChild);\n\t}\n}\n\n/**\n * Initializes the news page functionality\n */\nasync function init(): Promise<void> {\n\tconst username = validatorama.getOurUsername();\n\n\tif (username) {\n\t\t// Fetch unread news dates first\n\t\tconst unreadDates = await fetchUnreadNewsDates();\n\n\t\t// Add NEW badges to unread posts\n\t\tif (unreadDates.length > 0) {\n\t\t\taddNewBadgesToUnreadPosts(unreadDates);\n\t\t}\n\n\t\tmarkNewsAsRead();\n\t}\n}\n\ninit();\n\nexport {};\n"
  },
  {
    "path": "src/client/scripts/esm/views/resetpassword.ts",
    "content": "// src/client/scripts/esm/views/resetpassword.ts\n\n/**\n * This script handles the client-side logic for the password reset page.\n * It validates user input for a new password and sends it to the server.\n */\n\nimport validators from '../../../../shared/util/validators.js';\n\n// Types ----------------------------------------------------------------\n\ntype FormElements = {\n\tform: HTMLFormElement;\n\tnewPasswordInput: HTMLInputElement;\n\tconfirmPasswordInput: HTMLInputElement;\n\tsubmitButton: HTMLInputElement;\n};\n\n// --- Helper Functions (at module scope) ---\n\n/**\n * Extracts the password reset token from the page's URL.\n */\nfunction getTokenFromUrl(): string {\n\tconst pathSegments = window.location.pathname.split('/');\n\treturn pathSegments[pathSegments.length - 1] || '';\n}\n\n/**\n * Creates or updates a message element on the page.\n */\nfunction createErrorMessageElement(errorMessage: string): HTMLElement {\n\tconst id = 'error-message';\n\tconst insertAfterId = 'confirm-password-line';\n\n\tconst existingEl = document.getElementById(id);\n\tif (existingEl) existingEl.remove();\n\n\tconst el = document.createElement('div');\n\tel.id = id;\n\tel.className = 'error';\n\tel.textContent = errorMessage;\n\tdocument.getElementById(insertAfterId)?.insertAdjacentElement('afterend', el);\n\treturn el;\n}\n\n/**\n * The main setup function that attaches all logic and event listeners.\n * This function only runs if all required DOM elements are found.\n * @param elements - An object containing the verified DOM elements.\n */\nfunction initializeForm(elements: FormElements): void {\n\tconst { form, newPasswordInput, confirmPasswordInput, submitButton } = elements;\n\n\tlet messageElement: HTMLElement | null = null;\n\tlet isSubmitting: boolean = false;\n\tconst token = getTokenFromUrl();\n\n\t// --- Event Listeners & Initial Setup ---\n\tnewPasswordInput.addEventListener('input', updateSubmitButtonState);\n\tconfirmPasswordInput.addEventListener('input', updateSubmitButtonState);\n\tform.addEventListener('submit', handleResetSubmit);\n\tupdateSubmitButtonState();\n\n\tfunction clearMessage(): void {\n\t\tif (messageElement) {\n\t\t\tmessageElement.remove();\n\t\t\tmessageElement = null;\n\t\t}\n\t}\n\n\tfunction updateSubmitButtonState(): void {\n\t\tif (isSubmitting) return;\n\t\tclearMessage();\n\t\tif (newPasswordInput.value && confirmPasswordInput.value) {\n\t\t\tsubmitButton.disabled = false;\n\t\t\tsubmitButton.className = 'ready';\n\t\t} else {\n\t\t\tsubmitButton.className = 'unavailable';\n\t\t}\n\t}\n\n\tfunction validateForm(): boolean {\n\t\tconst password = newPasswordInput.value;\n\t\tconst confirmPassword = confirmPasswordInput.value;\n\n\t\tconst result = validators.validatePassword(password);\n\t\tif (result !== validators.PasswordValidationResult.Ok) {\n\t\t\tmessageElement = createErrorMessageElement(\n\t\t\t\ttranslations[validators.getPasswordErrorTranslation(result)!],\n\t\t\t);\n\t\t\tnewPasswordInput.focus();\n\t\t\treturn false;\n\t\t}\n\n\t\tif (password !== confirmPassword) {\n\t\t\tmessageElement = createErrorMessageElement(translations['js-pwd_no_match']);\n\t\t\tconfirmPasswordInput.focus();\n\t\t\treturn false;\n\t\t}\n\t\treturn true;\n\t}\n\n\tasync function handleResetSubmit(event: SubmitEvent): Promise<void> {\n\t\tevent.preventDefault();\n\t\tif (isSubmitting || !validateForm()) return;\n\t\tclearMessage();\n\n\t\tisSubmitting = true;\n\t\tsubmitButton.disabled = true;\n\t\tsubmitButton.className = 'unavailable';\n\t\tsubmitButton.value = translations.processing;\n\n\t\ttry {\n\t\t\tconst response = await fetch('/reset-password', {\n\t\t\t\tmethod: 'POST',\n\t\t\t\theaders: {\n\t\t\t\t\t'Content-Type': 'application/json',\n\t\t\t\t\t'is-fetch-request': 'true', // Custom header\n\t\t\t\t},\n\t\t\t\tbody: JSON.stringify({ token, password: newPasswordInput.value }),\n\t\t\t});\n\n\t\t\tconst result = await response.json();\n\t\t\tif (response.ok) {\n\t\t\t\t// SUCCESS\n\t\t\t\tform.innerHTML = `<div class=\"success\">${result.message}</div>`;\n\t\t\t\t// Redirect to login after a delay\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\twindow.location.href = '/login';\n\t\t\t\t}, 4000);\n\t\t\t} else {\n\t\t\t\t// NOT OKAY => ERROR\n\t\t\t\tonFetchError(result.message || 'An unknown error occurred.');\n\t\t\t}\n\t\t} catch (error: unknown) {\n\t\t\t// Likely a network error\n\t\t\tconsole.log(error instanceof Error ? error.message : String(error));\n\t\t\tonFetchError(translations['network-error']);\n\t\t}\n\t}\n\n\t/** Called when the fetch request errors, either NOT okay or network error */\n\tfunction onFetchError(errorMessage: string): void {\n\t\tmessageElement = createErrorMessageElement(errorMessage);\n\n\t\tisSubmitting = false;\n\t\tsubmitButton.disabled = false;\n\t\tsubmitButton.className = 'ready';\n\t\tsubmitButton.value = translations['reset-password'];\n\t}\n}\n\n// --- Script Entry Point ---\n// [FIX] Use instanceof for safe type checking instead of unsafe 'as' casting.\nconst formEl = document.getElementById('reset-form');\nconst newPasswordEl = document.getElementById('new-password');\nconst confirmPasswordEl = document.getElementById('confirm-password');\nconst submitButtonEl = document.getElementById('submit-reset');\n\nif (\n\tformEl instanceof HTMLFormElement &&\n\tnewPasswordEl instanceof HTMLInputElement &&\n\tconfirmPasswordEl instanceof HTMLInputElement &&\n\tsubmitButtonEl instanceof HTMLInputElement\n) {\n\t// If all elements are found and are of the correct type, initialize the form logic.\n\tinitializeForm({\n\t\tform: formEl,\n\t\tnewPasswordInput: newPasswordEl,\n\t\tconfirmPasswordInput: confirmPasswordEl,\n\t\tsubmitButton: submitButtonEl,\n\t});\n} else {\n\tconsole.error(\n\t\t'One or more required elements for the reset password form are missing or of the wrong type.',\n\t);\n}\n"
  },
  {
    "path": "src/client/scripts/esm/webgl/BufferUtil.ts",
    "content": "// src/client/scripts/esm/webgl/BufferUtil.ts\n\n/**\n * This script works with buffers. Creating them, assigning data, and modifying their indices.\n */\n\nimport { gl } from '../game/rendering/webgl.js';\nimport { TypedArray } from './Renderable.js';\n\n// Variables --------------------------------------------------------------------------------\n\n/** The draw hint when creating buffers on the gpu. Supposedly, dynamically\n * choosing which hint based on your needs offers very minor performance improvement.\n * Can choose between `gl.STATIC_DRAW`, `gl.DYNAMIC_DRAW`, or `gl.STREAM_DRAW` */\nconst DRAW_HINT = 'STATIC_DRAW';\n\n// Functions --------------------------------------------------------------------------------\n\n/**\n * Updates a buffer on the gpu with new data.\n * Can be used to modify meshes without having to create a new model.\n * @param buffer - The buffer to modify\n * @param data - The new data to put into the buffer.\n */\n// function updateBuffer(buffer: WebGLBuffer, data: Float32Array) {\n// \tgl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n// \t// gl.bufferData(gl.ARRAY_BUFFER, data, gl[DRAW_HINT]); // OLD. SLOW\n// \tgl.bufferSubData(gl.ARRAY_BUFFER, 0, data); // NEW. Sometimes faster? It stops being fast when I rewind & forward the game.\n// \tgl.bindBuffer(gl.ARRAY_BUFFER, null);\n// }\n\n/**\n * Updates only the provided indices of a buffer on the GPU with new data.\n * FAST. Use if only a part of the mesh has changed.\n * @param buffer - The WebGL buffer to update.\n * @param data - The typed array containing the new data (e.g., Float32Array, Uint16Array, etc.).\n * @param changedIndicesStart - The index in the vertex data marking the first value changed.\n * @param changedIndicesCount - The number of indices in the vertex data that were changed, beginning at {@link changedIndicesStart}.\n */\nfunction updateBufferIndices(\n\tbuffer: WebGLBuffer,\n\tdata: TypedArray,\n\tchangedIndicesStart: number,\n\tchangedIndicesCount: number,\n): void {\n\tconst endIndice = changedIndicesStart + changedIndicesCount - 1;\n\tif (endIndice > data.length - 1) {\n\t\treturn console.error(\n\t\t\t`Cannot update buffer indices when they overflow the data. Data length: ${data.length}, changedIndicesStart: ${changedIndicesStart}, changedIndicesCount: ${changedIndicesCount}, endIndice: ${endIndice}`,\n\t\t);\n\t}\n\n\t// Calculate the byte offset and length based on the changed indices\n\tconst offsetInBytes = changedIndicesStart * data.BYTES_PER_ELEMENT;\n\n\t// Update the specific portion of the buffer\n\tgl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n\tgl.bufferSubData(\n\t\tgl.ARRAY_BUFFER,\n\t\toffsetInBytes,\n\t\tdata.subarray(changedIndicesStart, changedIndicesStart + changedIndicesCount),\n\t);\n\tgl.bindBuffer(gl.ARRAY_BUFFER, null);\n}\n\n/**\n * Creates a WebGL buffer from the provided Float32Array data and binds it to the ARRAY_BUFFER target.\n * The buffer is populated with the data and then unbound.\n * @param data - The vertex data to be copied into the buffer.\n * @returns The created WebGL buffer.\n */\nfunction createBufferFromData(data: TypedArray): WebGLBuffer {\n\tconst buffer = gl.createBuffer()!; // Create an empty buffer for the model's vertex data.\n\tgl.bindBuffer(gl.ARRAY_BUFFER, buffer); // Bind the buffer before we work with it. This is pretty much instantaneous no matter the buffer size.\n\t// Copy our vertex data into the buffer.\n\t// When copying over massive amounts of data (like millions of floats),\n\t// this FREEZES the screen for a moment before unfreezing. Not good for user experience!\n\t// When this happens, work with smaller meshes.\n\t// And always modify the buffer data on the gpu directly when you can,\n\t// using updateBufferIndices(), to avoid having to create another model!\n\tgl.bufferData(gl.ARRAY_BUFFER, data, gl[DRAW_HINT]);\n\tgl.bindBuffer(gl.ARRAY_BUFFER, null); // Unbind the buffer\n\n\treturn buffer;\n}\n\nexport { updateBufferIndices, createBufferFromData };\n"
  },
  {
    "path": "src/client/scripts/esm/webgl/ProgramManager.ts",
    "content": "// src/client/scripts/esm/webgl/ProgramManager.ts\n\nimport vsSource_color from '../../../shaders/color/vertex.glsl';\nimport fsSource_color from '../../../shaders/color/fragment.glsl';\nimport fsSource_water from '../../../shaders/water/fragment.glsl';\nimport vsSource_arrows from '../../../shaders/arrows/vertex.glsl';\nimport fsSource_glitch from '../../../shaders/glitch/fragment.glsl'; // Import the new glitch fragment shader\nimport vsSource_texture from '../../../shaders/texture/vertex.glsl';\nimport fsSource_texture from '../../../shaders/texture/fragment.glsl';\nimport vsSource_postPass from '../../../shaders/post_pass/vertex.glsl';\nimport fsSource_postPass from '../../../shaders/post_pass/fragment.glsl';\nimport fsSource_vignette from '../../../shaders/vignette/fragment.glsl';\nimport fsSource_sineWave from '../../../shaders/sine_wave/fragment.glsl';\nimport fsSource_heatWave from '../../../shaders/heat_wave/fragment.glsl';\nimport { ShaderProgram } from './ShaderProgram';\nimport vsSource_starfield from '../../../shaders/starfield/vertex.glsl';\nimport vsSource_miniImages from '../../../shaders/mini_images/vertex.glsl';\nimport fsSource_miniImages from '../../../shaders/mini_images/fragment.glsl';\nimport vsSource_highlights from '../../../shaders/highlights/vertex.glsl';\nimport fsSource_colorGrade from '../../../shaders/color_grade/fragment.glsl';\nimport vsSource_arrowImages from '../../../shaders/arrow_images/vertex.glsl';\nimport fsSource_arrowImages from '../../../shaders/arrow_images/fragment.glsl';\nimport fsSource_waterRipple from '../../../shaders/water_ripple/fragment.glsl';\nimport vsSource_colorTexture from '../../../shaders/color_texture/vertex.glsl';\nimport fsSource_colorTexture from '../../../shaders/color_texture/fragment.glsl';\nimport vsSource_colorInstanced from '../../../shaders/color/instanced/vertex.glsl';\nimport vsSource_boardUberShader from '../../../shaders/board_uber_shader/vertex.glsl';\nimport fsSource_boardUberShader from '../../../shaders/board_uber_shader/fragment.glsl';\nimport vsSource_textureInstanced from '../../../shaders/texture/instanced/vertex.glsl';\nimport fsSource_voronoiDistortion from '../../../shaders/voronoi_distortion/fragment.glsl';\n\n// =============================== Type Definitions ===============================\n\n// Attribute and Uniform Union Types for each ShaderProgram\n\n// Generic Shaders\ntype Attributes_Color = 'a_position' | 'a_color';\ntype Uniforms_Color = 'u_transformmatrix';\ntype Attributes_ColorInstanced = 'a_position' | 'a_color' | 'a_instanceposition';\ntype Uniforms_ColorInstanced = 'u_transformmatrix';\ntype Attributes_Texture = 'a_position' | 'a_texturecoord';\ntype Uniforms_Texture = 'u_transformmatrix' | 'u_sampler';\ntype Attributes_TextureInstanced = 'a_position' | 'a_texturecoord' | 'a_instanceposition';\ntype Uniforms_TextureInstanced = 'u_transformmatrix' | 'u_sampler';\ntype Attributes_ColorTexture = 'a_position' | 'a_texturecoord' | 'a_color';\ntype Uniforms_ColorTexture = 'u_transformmatrix' | 'u_sampler';\n// Specialized Shaders\ntype Attributes_MiniImages = 'a_position' | 'a_texturecoord' | 'a_color' | 'a_instanceposition';\ntype Uniforms_MiniImages = 'u_transformmatrix' | 'u_sampler' | 'u_size';\ntype Attributes_Highlights = 'a_position' | 'a_color' | 'a_instanceposition';\ntype Uniforms_Highlights = 'u_transformmatrix' | 'u_size';\ntype Attributes_Arrows =\n\t| 'a_position'\n\t| 'a_instanceposition'\n\t| 'a_instancecolor'\n\t| 'a_instancerotation';\ntype Uniforms_Arrows = 'u_transformmatrix';\ntype Attributes_ArrowImages =\n\t| 'a_position'\n\t| 'a_texturecoord'\n\t| 'a_instanceposition'\n\t| 'a_instancecolor';\ntype Uniforms_ArrowImages = 'u_transformmatrix' | 'u_sampler';\ntype Attributes_Starfield =\n\t| 'a_position'\n\t| 'a_instanceposition'\n\t| 'a_instancecolor'\n\t| 'a_instancesize';\ntype Uniforms_Starfield = 'u_transformmatrix';\n// Surface Level Effects\ntype Attributes_BoardUberShader = 'a_position' | 'a_texturecoord' | 'a_color';\ntype Uniforms_BoardUberShader =\n\t// Global Uniforms\n\t| 'u_colorTexture'\n\t| 'u_maskTexture'\n\t| 'u_perlinNoiseTexture'\n\t| 'u_whiteNoiseTexture'\n\t| 'u_resolution'\n\t| 'u_pixelDensity'\n\t// Uber-Shader Logic\n\t| 'u_effectTypeA'\n\t| 'u_effectTypeB'\n\t| 'u_transitionProgress'\n\t// \"Spectral Edge\" Uniforms (Effect Type 4)\n\t| 'u4_flowDistance'\n\t| 'u4_flowDirectionVec'\n\t| 'u4_gradientRepeat'\n\t| 'u4_maskOffset'\n\t| 'u4_strength'\n\t| 'u4_color1'\n\t| 'u4_color2'\n\t| 'u4_color3'\n\t| 'u4_color4'\n\t| 'u4_color5'\n\t| 'u4_color6'\n\t// \"Iridescence\" Uniforms (Effect Type 5)\n\t| 'u5_flowDistance'\n\t| 'u5_flowDirectionVec'\n\t| 'u5_gradientRepeat'\n\t| 'u5_maskOffset'\n\t| 'u5_strength'\n\t| 'u5_color1'\n\t| 'u5_color2'\n\t| 'u5_color3'\n\t| 'u5_color4'\n\t| 'u5_color5'\n\t| 'u5_color6'\n\t// \"Dusty Wastes\" Uniforms (Effect Type 6)\n\t| 'u6_strength'\n\t| 'u6_noiseTiling'\n\t| 'u6_uvOffset1'\n\t| 'u6_uvOffset2'\n\t// \"Static Zone\" Uniforms (Effect Type 7)\n\t| 'u7_strength'\n\t| 'u7_uvOffset'\n\t| 'u7_pixelWidth'\n\t| 'u7_pixelSize';\n// Post Processing Shaders\ntype Attributes_PostPass = never;\ntype Uniforms_PostPass = 'u_sceneTexture';\ntype Attributes_ColorGrade = never;\ntype Uniforms_ColorGrade =\n\t| 'u_sceneTexture'\n\t| 'u_masterStrength'\n\t| 'u_brightness'\n\t| 'u_contrast'\n\t| 'u_gamma'\n\t| 'u_saturation'\n\t| 'u_tintColor'\n\t| 'u_hueOffset';\n// type Attributes_Posterize = never; // Moved to dev-utils\n// type Uniforms_Posterize = 'u_sceneTexture' | 'u_masterStrength' | 'u_levels'; // Moved to dev-utils\ntype Attributes_Vignette = never;\ntype Uniforms_Vignette =\n\t| 'u_sceneTexture'\n\t| 'u_masterStrength'\n\t| 'u_radius'\n\t| 'u_softness'\n\t| 'u_intensity';\ntype Attributes_SineWave = never;\ntype Uniforms_SineWave =\n\t| 'u_sceneTexture'\n\t| 'u_masterStrength'\n\t| 'u_amplitude'\n\t| 'u_frequency'\n\t| 'u_time'\n\t| 'u_angle';\ntype Attributes_Water = never;\ntype Uniforms_Water =\n\t| 'u_sceneTexture'\n\t| 'u_masterStrength'\n\t| 'u_sourceCount'\n\t| 'u_centers'\n\t| 'u_time'\n\t| 'u_resolution'\n\t| 'u_strength'\n\t| 'u_oscillationSpeed'\n\t| 'u_frequency';\ntype Attributes_WaterRipple = never;\ntype Uniforms_WaterRipple =\n\t| 'u_sceneTexture'\n\t| 'u_centers'\n\t| 'u_times'\n\t| 'u_dropletCount'\n\t| 'u_strength'\n\t| 'u_propagationSpeed'\n\t| 'u_oscillationSpeed'\n\t| 'u_frequency'\n\t| 'u_glintIntensity'\n\t| 'u_glintExponent'\n\t| 'u_falloff'\n\t| 'u_resolution';\ntype Attributes_HeatWave = never;\ntype Uniforms_HeatWave =\n\t| 'u_sceneTexture'\n\t| 'u_masterStrength'\n\t| 'u_noiseTexture'\n\t| 'u_time'\n\t| 'u_strength'\n\t| 'u_resolution';\ntype Attributes_VoronoiDistortion = never;\ntype Uniforms_VoronoiDistortion =\n\t| 'u_sceneTexture'\n\t| 'u_masterStrength'\n\t| 'u_time'\n\t| 'u_density'\n\t| 'u_strength'\n\t| 'u_ridgeThickness'\n\t| 'u_ridgeStrength'\n\t| 'u_resolution';\ntype Attributes_Glitch = never; // Glitch pass does not use attributes\ntype Uniforms_Glitch =\n\t| 'u_sceneTexture'\n\t| 'u_masterStrength'\n\t| 'u_aberrationStrength'\n\t| 'u_aberrationOffset'\n\t| 'u_tearStrength'\n\t| 'u_tearResolution'\n\t| 'u_tearMaxDisplacement'\n\t| 'u_time'\n\t| 'u_resolution'\n\t| 'u_devicePixelRatio';\n\n/** The Super Union of all possible attributes. */\nexport type Attributes_All =\n\t| Attributes_Color\n\t| Attributes_ColorInstanced\n\t| Attributes_Texture\n\t| Attributes_TextureInstanced\n\t| Attributes_ColorTexture\n\t| Attributes_MiniImages\n\t| Attributes_Highlights\n\t| Attributes_Arrows\n\t| Attributes_ArrowImages\n\t| Attributes_Starfield\n\t| Attributes_BoardUberShader\n\t| Attributes_PostPass\n\t| Attributes_ColorGrade\n\t// | Attributes_Posterize // Moved to dev-utils\n\t| Attributes_Vignette\n\t| Attributes_SineWave\n\t| Attributes_Water\n\t| Attributes_WaterRipple\n\t| Attributes_HeatWave\n\t| Attributes_VoronoiDistortion\n\t| Attributes_Glitch;\n\n// Each ShaderProgram type\n\n// Generic Shaders\ntype Program_Color = ShaderProgram<Attributes_Color, Uniforms_Color>;\ntype Program_ColorInstanced = ShaderProgram<Attributes_ColorInstanced, Uniforms_ColorInstanced>;\ntype Program_Texture = ShaderProgram<Attributes_Texture, Uniforms_Texture>;\ntype Program_TextureInstanced = ShaderProgram<\n\tAttributes_TextureInstanced,\n\tUniforms_TextureInstanced\n>;\ntype Program_ColorTexture = ShaderProgram<Attributes_ColorTexture, Uniforms_ColorTexture>;\n// Specialized Shaders\ntype Program_MiniImages = ShaderProgram<Attributes_MiniImages, Uniforms_MiniImages>;\ntype Program_Highlights = ShaderProgram<Attributes_Highlights, Uniforms_Highlights>;\ntype Program_Arrows = ShaderProgram<Attributes_Arrows, Uniforms_Arrows>;\ntype Program_ArrowImages = ShaderProgram<Attributes_ArrowImages, Uniforms_ArrowImages>;\ntype Program_Starfield = ShaderProgram<Attributes_Starfield, Uniforms_Starfield>;\n// Surface Level Effects\ntype Program_BoardUberShader = ShaderProgram<Attributes_BoardUberShader, Uniforms_BoardUberShader>;\n// Post Processing Shaders\ntype Program_PostPass = ShaderProgram<Attributes_PostPass, Uniforms_PostPass>;\ntype Program_ColorGrade = ShaderProgram<Attributes_ColorGrade, Uniforms_ColorGrade>;\n// type Program_Posterize = ShaderProgram<Attributes_Posterize, Uniforms_Posterize>; // Moved to dev-utils\ntype Program_Vignette = ShaderProgram<Attributes_Vignette, Uniforms_Vignette>;\ntype Program_SineWave = ShaderProgram<Attributes_SineWave, Uniforms_SineWave>;\ntype Program_Water = ShaderProgram<Attributes_Water, Uniforms_Water>;\ntype Program_WaterRipple = ShaderProgram<Attributes_WaterRipple, Uniforms_WaterRipple>;\ntype Program_HeatWave = ShaderProgram<Attributes_HeatWave, Uniforms_HeatWave>;\ntype Program_VoronoiDistortion = ShaderProgram<\n\tAttributes_VoronoiDistortion,\n\tUniforms_VoronoiDistortion\n>;\ntype Program_Glitch = ShaderProgram<Attributes_Glitch, Uniforms_Glitch>;\n\nexport interface ProgramMap {\n\t// ======= Generic Shaders =======\n\n\t/** Renders meshes with colored vertices. */\n\tcolor: Program_Color;\n\t/** Instance renders a mesh with colored vertices. */\n\tcolorInstanced: Program_ColorInstanced;\n\t/** Renders a textured mesh. */\n\ttexture: Program_Texture;\n\t/** Instance renders a textured mesh. */\n\ttextureInstanced: Program_TextureInstanced;\n\t/** Renders a textured mesh with colored vertices. */\n\tcolorTexture: Program_ColorTexture;\n\n\t// ======= Specialized Shaders =======\n\n\t/** Renders mini images. */\n\tminiImages: Program_MiniImages;\n\t/** Renders mini images. Instance renders square highlights of a given size. */\n\thighlights: Program_Highlights;\n\t/** Renders arrows (not the images, but tha arrow tip). */\n\tarrows: Program_Arrows;\n\t/** Renders arrow images. */\n\tarrowImages: Program_ArrowImages;\n\t/** Renders the starfield squares. */\n\tstarfield: Program_Starfield;\n\n\t// ====== Surface Level Effects =======\n\n\t/** Renders textured surfaces with a masked noise texture animated behind them. */\n\tboard_uber_shader: Program_BoardUberShader;\n\n\t// ======= Post Processing Shaders =======\n\n\t/** Post Processing Pass-Through Shader. Zero effects. */\n\tpost_pass: Program_PostPass;\n\t/** Post Processing Color Grading Shader. Several color effects. */\n\tcolor_grade: Program_ColorGrade;\n\t// /** Post Processing Posterize Shader. */ // Moved to dev-utils\n\t// posterize: Program_Posterize; // Moved to dev-utils\n\t/** Post Processing Vignette Effect. */\n\tvignette: Program_Vignette;\n\t/** Post Processing Dual Axis Sine Wave Distortion Effect. */\n\tsine_wave: Program_SineWave;\n\t/** Post Processing Water Pond Distortion Effect. */\n\twater: Program_Water;\n\t/** Post Processing Water Ripple Distortion Effect. */\n\twater_ripple: Program_WaterRipple;\n\t/** Post Processing Heat Wave Distortion Effect. */\n\theat_wave: Program_HeatWave;\n\t/** Post Processing Voronoi Cellular Noise Distortion Effect. */\n\tvoronoi_distortion: Program_VoronoiDistortion;\n\t/** Post Processing Glitch Effect. */\n\tglitch: Program_Glitch;\n}\n\n/** The vertex and fragment shader source codes for a shader. */\ntype ShaderSource = {\n\t/** The vertex shader source code. */\n\tvertex: string;\n\t/** The fragment shader source code. */\n\tfragment: string;\n};\n\n// =============================== Implementation ===============================\n\n/** A mapping from program names to their corresponding shader sources. */\nconst shaderSources: Record<keyof ProgramMap, ShaderSource> = {\n\t// Generic Shaders\n\tcolor: { vertex: vsSource_color, fragment: fsSource_color },\n\tcolorInstanced: { vertex: vsSource_colorInstanced, fragment: fsSource_color },\n\ttexture: { vertex: vsSource_texture, fragment: fsSource_texture },\n\ttextureInstanced: { vertex: vsSource_textureInstanced, fragment: fsSource_texture },\n\tcolorTexture: { vertex: vsSource_colorTexture, fragment: fsSource_colorTexture },\n\t// Specialized Shaders\n\tminiImages: { vertex: vsSource_miniImages, fragment: fsSource_miniImages },\n\thighlights: { vertex: vsSource_highlights, fragment: fsSource_color },\n\tarrows: { vertex: vsSource_arrows, fragment: fsSource_color },\n\tarrowImages: { vertex: vsSource_arrowImages, fragment: fsSource_arrowImages },\n\tstarfield: { vertex: vsSource_starfield, fragment: fsSource_color },\n\t// Surface Level Effects\n\tboard_uber_shader: { vertex: vsSource_boardUberShader, fragment: fsSource_boardUberShader },\n\t// Post Processing Shaders\n\tpost_pass: { vertex: vsSource_postPass, fragment: fsSource_postPass },\n\tcolor_grade: { vertex: vsSource_postPass, fragment: fsSource_colorGrade },\n\t// posterize: { vertex: vsSource_postPass, fragment: fsSource_posterize }, // Moved to dev-utils\n\tvignette: { vertex: vsSource_postPass, fragment: fsSource_vignette },\n\tsine_wave: { vertex: vsSource_postPass, fragment: fsSource_sineWave },\n\twater: { vertex: vsSource_postPass, fragment: fsSource_water },\n\twater_ripple: { vertex: vsSource_postPass, fragment: fsSource_waterRipple },\n\theat_wave: { vertex: vsSource_postPass, fragment: fsSource_heatWave },\n\tvoronoi_distortion: { vertex: vsSource_postPass, fragment: fsSource_voronoiDistortion },\n\tglitch: { vertex: vsSource_postPass, fragment: fsSource_glitch },\n};\n\n/**\n * A factory and cache for creating and managing ShaderProgram instances.\n * Ensures that each shader program is only compiled and linked once.\n */\nexport class ProgramManager {\n\tprivate readonly gl: WebGL2RenderingContext;\n\t// The cache stores programs by their key from ProgramMap. We use a base\n\t// ShaderProgram type here internally, but the public `get` method provides full type safety.\n\tprivate programCache: Map<keyof ProgramMap, ShaderProgram<any, any>> = new Map();\n\n\tconstructor(gl: WebGL2RenderingContext) {\n\t\tthis.gl = gl;\n\t}\n\n\t/**\n\t * Retrieves a compiled and linked ShaderProgram from the cache, or creates it if it doesn't exist.\n\t *\n\t * @template K - The key (name) of the program to retrieve.\n\t * @param programName - The name of the shader program (e.g., 'phong', 'unlit').\n\t * @returns The fully-typed ShaderProgram instance.\n\t */\n\tpublic get<K extends keyof ProgramMap>(programName: K): ProgramMap[K] {\n\t\t// 1. Check if the program is already in the cache.\n\t\tif (this.programCache.has(programName)) {\n\t\t\t// We use a type assertion `as ProgramMap[K]` because we trust that the\n\t\t\t// internal cache is consistent with our public interface.\n\t\t\treturn this.programCache.get(programName)! as ProgramMap[K];\n\t\t}\n\n\t\t// 2. If not, get the source code for the requested program.\n\t\tconst sources = shaderSources[programName];\n\t\tif (!sources) throw Error(`Shader sources for program \"${programName}\" not found.`);\n\n\t\t// 3. Create a new ShaderProgram instance.\n\t\t// console.log(`Compiling and linking shader program: ${programName}`);\n\t\tconst program = new ShaderProgram(this.gl, sources.vertex, sources.fragment);\n\n\t\t// 4. Store it in the cache for future requests.\n\t\tthis.programCache.set(programName, program);\n\n\t\treturn program as ProgramMap[K];\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/webgl/Renderable.ts",
    "content": "// src/client/scripts/esm/webgl/Renderable.ts\n\n/**\n * This script contains all the functions used to generate renderable buffer models of the\n * game objects that the shader programs can use. It receives the object's vertex data to do so,\n * the desired shader to use, the textures along with their uniform names, and the attribute\n * information, if applicable, such as how many components of the vertex data\n * are dedicated to position, color, texture coordinates, etc.\n *\n * It is also capable of instanced rendering.\n */\n\nimport type { Vec3 } from '../../../../shared/util/math/vectors.js';\n\nimport mat4 from '../game/rendering/gl-matrix.js';\nimport camera, { Mat4 } from '../game/rendering/camera.js';\nimport { ShaderProgram } from './ShaderProgram.js';\nimport { createBufferFromData, updateBufferIndices } from './BufferUtil.js';\nimport { Attributes_All, ProgramManager, ProgramMap } from './ProgramManager.js';\n\n// Types ----------------------------------------------------------------------------------\n\n/**\n * Any kind of array that may be passed to the constructors\n * to be used as vertex or instance data for a buffer model.\n *\n * Each of these are subsequently converted into aFloat32Array,\n * which have a max safe integer of 16,777,215 (16 million),\n * and a max value of 3.4e38. so beware of precision loss!\n *\n * number[] => Double precision (64-bit). Max safe integer of 9,007,199,254,740,991 (9 quadrillion). Max value of 1.8e+308.\n */\ntype InputArray = number[] | TypedArray;\n\n/**\n * All signed type arrays compatible with WebGL, that can be used as vertex data.\n *\n * Float32Array => Max safe integer: 16,777,215. Max value: 3.4e+38\n * Int32Array => Max integer: 2,147,483,647\n * Int16Array => Max integer: 32,767\n * Int8Array => Max integer: 127\n */\ntype TypedArray = Float32Array | Int32Array | Int16Array | Int8Array;\n\n/** All valid primitive shapes we can render with */\ntype PrimitiveType =\n\t| 'TRIANGLES'\n\t| 'TRIANGLE_STRIP'\n\t| 'TRIANGLE_FAN'\n\t| 'POINTS'\n\t| 'LINE_LOOP'\n\t| 'LINE_STRIP'\n\t| 'LINES';\n\n/** An object describing a single attribute inside our vertex data, and how many components it has per stride/vertex. */\ninterface Attribute {\n\t/** The name of the attribute. */\n\tname: Attributes_All;\n\t/** How many values the attribute has in a single stride/vertex of our data array. */\n\tnumComponents: number;\n}\n\n/** An object containing all attributes that some vertex data contains. */\ntype AttributeInfo = Attribute[];\n\n/** An object containing the attribute info of both our vertex data and instance data. */\ntype AttributeInfoInstanced = {\n\tvertexDataAttribInfo: AttributeInfo;\n\tinstanceDataAttribInfo: AttributeInfo;\n};\n\n/** A texture, along with its given uniform name in the desired shader. */\ninterface TextureInfo {\n\ttexture: WebGLTexture;\n\t/** e.g., 'u_sampler', 'u_noiseTexture' */\n\tuniformName: string;\n}\n\n/**\n * **Call this** when you update specific vertex data within the source Float32Array!\n * FAST. Prevents you having to create a whole new model!\n * For example, when a single piece in the mesh moves.\n * @param {number} changedIndicesStart - The index in the vertex data marking the first value changed.\n * @param {number} changedIndicesCount - The number of indices in the vertex data that were changed, beginning at {@link changedIndicesStart}.\n */\ntype UpdateBufferIndicesFunc = (_changedIndicesStart: number, _changedIndicesCount: number) => void;\n\n/** Contains the properties that both the {@link Renderable} and {@link RenderableInstanced} types share. */\ninterface BaseRenderable {\n\t/**\n\t * **Renders** the buffer model! Translates and scales according to the provided arguments.\n\t * Applies any custom uniform values before rendering.\n\t * @param [position] - The positional translation, default [0,0,0]\n\t * @param [scale] - The scaling transformation, default [1,1,1]\n\t * @param uniforms - Custom uniform values, for example, 'u_size'.\n\t */\n\trender: (_position?: Vec3, _scale?: Vec3, _uniforms?: Record<string, any>) => void;\n}\n\n/** A renderable model. */\ninterface Renderable extends BaseRenderable {\n\t/** A reference to the vertex data, stored in a Float32Array, that went into this model's buffer.\n\t * If this is modified, we can use updateBufferIndices() to pass those changes\n\t * on to the gpu, without having to create a new buffer model! */\n\tdata: TypedArray;\n\tupdateBufferIndices: UpdateBufferIndicesFunc;\n}\n\n/** A renderable model that uses instanced rendering! */\ninterface RenderableInstanced extends BaseRenderable {\n\t/** A reference to the vertex data of a SINGLE INSTANCE, stored in a Float32Array, that went into this model's buffer.\n\t * If this is modified, we can use updateBufferIndices() to pass those changes\n\t * on to the gpu, without having to create a new buffer model! */\n\tvertexData: TypedArray;\n\t/** A reference to the vertex data OF EACH INSTANCE, stored in a Float32Array, that went into this model's buffer.\n\t * If this is modified, we can use updateBufferIndices() to pass those changes\n\t * on to the gpu, without having to create a new buffer model! */\n\tinstanceData: TypedArray;\n\tupdateBufferIndices_VertexBuffer: UpdateBufferIndicesFunc;\n\tupdateBufferIndices_InstanceBuffer: UpdateBufferIndicesFunc;\n}\n\n// Variables ----------------------------------------------------------------------------------\n\n/** The global WebGL2 rendering context. */\nlet gl: WebGL2RenderingContext;\n\n/** The global program manager, used to get shader programs for rendering models. */\nlet programManager: ProgramManager;\n\n// Functions ----------------------------------------------------------------------------------\n\n/** Initializes the script with the WebGL2 rendering context and ProgramManager. */\nfunction init(context: WebGL2RenderingContext, program_manager: ProgramManager): void {\n\tgl = context;\n\tprogramManager = program_manager;\n}\n\n/**\n * The universal function for creating a renderable model,\n * given the vertex data, attribute information,\n * primitive rendering mode, and texture.\n */\nfunction createRenderable(\n\t/** The array of vertex data of the mesh to be rendered. */\n\tdata: InputArray,\n\t/** The number of position components for a single vertex: x,y,z */\n\tnumPositionComponents: 2 | 3,\n\t/** What drawing primitive to use. */\n\tmode: PrimitiveType,\n\tshader: keyof ProgramMap,\n\t/** Whether the vertex data contains color attributes. */\n\tusingColor: boolean,\n\t/** If applicable, a texture to be bound when rendering (vertex data should contain texcoord attributes). */\n\ttexture?: WebGLTexture,\n): Renderable {\n\tconst usingTexture = texture !== undefined;\n\tconst attribInfo = getAttribInfo(numPositionComponents, usingColor, usingTexture);\n\tconst textureInfo: TextureInfo[] = [];\n\tif (texture) textureInfo.push({ texture, uniformName: 'u_sampler' }); // Most models with a single texture use the 'u_sampler' uniform\n\treturn createRenderable_GivenInfo(data, attribInfo, mode, shader, textureInfo);\n}\n\n/**\n * The universal function for creating a renderable model THAT USES INSTANCED RENDERING,\n * given the vertex data and instance data, both attribute informations, primitive rendering mode, and texture!\n */\nfunction createRenderable_Instanced(\n\t/** The array of vertex data of a single instance of the mesh. */\n\tvertexData: InputArray,\n\t/** The instance-specific vertex data of the mesh. */\n\tinstanceData: InputArray,\n\t/** What drawing primitive to use. */\n\tmode: PrimitiveType,\n\tshader: keyof ProgramMap,\n\t/** Whether the vertex data of a single instance contains color attributes, NOT THE INSTANCE-SPECIFIC DATA. */\n\tusingColor: boolean,\n\t/** If applicable, a texture to be bound when rendering (instance data should contain texcoord attributes). */\n\ttexture?: WebGLTexture,\n): RenderableInstanced {\n\tconst usingTexture = texture !== undefined;\n\tconst attribInfoInstanced = getAttribInfo_Instanced(usingColor, usingTexture);\n\tconst textureInfo: TextureInfo[] = [];\n\tif (texture) textureInfo.push({ texture, uniformName: 'u_sampler' }); // Most models with a single texture use the 'u_sampler' uniform\n\treturn createRenderable_Instanced_GivenInfo(\n\t\tvertexData,\n\t\tinstanceData,\n\t\tattribInfoInstanced,\n\t\tmode,\n\t\tshader,\n\t\ttextureInfo,\n\t);\n}\n\n/**\n * Returns the attribute information object for some vertex data,\n * given the number of position components, and whether we're using\n * color and/or texture components.\n */\nfunction getAttribInfo(\n\tnumPositionComponents: 2 | 3,\n\tusingColor: boolean,\n\tusingTexture: boolean,\n): AttributeInfo {\n\tif (usingColor && usingTexture) {\n\t\treturn [\n\t\t\t{ name: 'a_position', numComponents: numPositionComponents },\n\t\t\t{ name: 'a_texturecoord', numComponents: 2 },\n\t\t\t{ name: 'a_color', numComponents: 4 },\n\t\t];\n\t} else if (usingColor) {\n\t\treturn [\n\t\t\t{ name: 'a_position', numComponents: numPositionComponents },\n\t\t\t{ name: 'a_color', numComponents: 4 },\n\t\t];\n\t} else if (usingTexture) {\n\t\treturn [\n\t\t\t{ name: 'a_position', numComponents: numPositionComponents },\n\t\t\t{ name: 'a_texturecoord', numComponents: 2 },\n\t\t];\n\t} else\n\t\tthrow new Error(\n\t\t\t'Well we must be using ONE of either color or texcoord in our vertex data..',\n\t\t);\n}\n\n/**\n * Returns the attribute information for the vertex and instance data arrays,\n * provided whether the vertex data contains color and/or texture coordinate information.\n */\nfunction getAttribInfo_Instanced(\n\tusingColor: boolean,\n\tusingTexture: boolean,\n): AttributeInfoInstanced {\n\tconst vertexDataAttribInfo: AttributeInfo = [{ name: 'a_position', numComponents: 2 }];\n\tif (usingTexture) vertexDataAttribInfo.push({ name: 'a_texturecoord', numComponents: 2 });\n\tif (usingColor) vertexDataAttribInfo.push({ name: 'a_color', numComponents: 4 });\n\treturn {\n\t\tvertexDataAttribInfo,\n\t\tinstanceDataAttribInfo: [{ name: 'a_instanceposition', numComponents: 2 }],\n\t};\n}\n\n/**\n * Creates a renderable model, given the AttributeInfo object.\n */\nfunction createRenderable_GivenInfo<K extends keyof ProgramMap>(\n\tdata: InputArray,\n\tattribInfo: AttributeInfo,\n\tmode: PrimitiveType,\n\tshader: K,\n\ttextures: TextureInfo[] = [],\n): Renderable {\n\tconst stride = getStrideFromAttributeInfo(attribInfo);\n\tif (data.length % stride !== 0)\n\t\tthrow new Error(\n\t\t\t'Data length is not divisible by stride when creating a buffer model. Check to make sure the specified attribInfo is correct.',\n\t\t);\n\n\tdata = ensureTypedArray(data); // Ensure the data is a Float32Array\n\tconst BYTES_PER_ELEMENT = data.BYTES_PER_ELEMENT;\n\n\tconst vertexCount = data.length / stride;\n\n\tconst buffer = createBufferFromData(data);\n\n\tconst shaderProgram = programManager.get(shader);\n\n\t// Generate the VAO that stores the attribute configuration.\n\n\tconst vao = gl.createVertexArray();\n\tif (!vao) throw new Error('Could not create Vertex Array Object');\n\n\tgl.bindVertexArray(vao);\n\tconfigureAttributes(shaderProgram, buffer, attribInfo, stride, BYTES_PER_ELEMENT, false);\n\tgl.bindVertexArray(null); // Unbind VAO. The configuration is now saved inside the 'vao' object.\n\tgl.bindBuffer(gl.ARRAY_BUFFER, null); // Unbind the buffer next (configureAttributes() binds it)\n\n\treturn {\n\t\tdata,\n\t\tupdateBufferIndices: (changedIndicesStart: number, changedIndicesCount: number): void =>\n\t\t\tupdateBufferIndices(buffer, data, changedIndicesStart, changedIndicesCount),\n\t\trender: (\n\t\t\tposition: Vec3 = [0, 0, 0],\n\t\t\tscale: Vec3 = [1, 1, 1],\n\t\t\tuniforms: Record<string, any> = {},\n\t\t): void =>\n\t\t\tprepareAndExecuteRender(shaderProgram, vao, position, scale, uniforms, textures, () =>\n\t\t\t\tgl.drawArrays(gl[mode], 0, vertexCount),\n\t\t\t),\n\t};\n}\n\n/**\n * Creates a renderable model that uses instanced rendering,\n * given the AttributeInfo objects of both the vertex data and instance data arrays.\n */\nfunction createRenderable_Instanced_GivenInfo<K extends keyof ProgramMap>(\n\tvertexData: InputArray,\n\tinstanceData: InputArray,\n\tattribInfoInstanced: AttributeInfoInstanced,\n\tmode: PrimitiveType,\n\tshader: K,\n\ttextures: TextureInfo[] = [],\n): RenderableInstanced {\n\tconst vertexDataStride = getStrideFromAttributeInfo(attribInfoInstanced.vertexDataAttribInfo);\n\tconst instanceDataStride = getStrideFromAttributeInfo(\n\t\tattribInfoInstanced.instanceDataAttribInfo,\n\t);\n\tif (vertexData.length % vertexDataStride !== 0)\n\t\tthrow new Error(\n\t\t\t'Vertex data length is not divisible by stride when creating an instanced buffer model. Check to make sure the specified attribInfo is correct.',\n\t\t);\n\tif (instanceData.length % instanceDataStride !== 0)\n\t\tthrow new Error(\n\t\t\t`Instance data length (${instanceData.length}) is not divisible by stride (${instanceDataStride}) when creating an instanced buffer model. Check to make sure the specified attribInfo is correct.`,\n\t\t);\n\n\tvertexData = ensureTypedArray(vertexData);\n\tinstanceData = ensureTypedArray(instanceData);\n\tconst BYTES_PER_ELEMENT_VData = vertexData.BYTES_PER_ELEMENT;\n\tconst BYTES_PER_ELEMENT_IData = instanceData.BYTES_PER_ELEMENT;\n\n\tconst vertexCount = vertexData.length / vertexDataStride; // The vertex count of our vertex data of one single instance\n\tconst instanceCount = instanceData.length / instanceDataStride;\n\n\tconst vertexBuffer = createBufferFromData(vertexData);\n\tconst instanceBuffer = createBufferFromData(instanceData);\n\n\tconst shaderProgram = programManager.get(shader);\n\n\t// Generate the VAO that stores the attribute configuration.\n\n\tconst vao = gl.createVertexArray();\n\tif (!vao) throw new Error('Could not create Vertex Array Object');\n\n\tgl.bindVertexArray(vao);\n\tconfigureAttributes(\n\t\tshaderProgram,\n\t\tvertexBuffer,\n\t\tattribInfoInstanced.vertexDataAttribInfo,\n\t\tvertexDataStride,\n\t\tBYTES_PER_ELEMENT_VData,\n\t\tfalse,\n\t);\n\tconfigureAttributes(\n\t\tshaderProgram,\n\t\tinstanceBuffer,\n\t\tattribInfoInstanced.instanceDataAttribInfo,\n\t\tinstanceDataStride,\n\t\tBYTES_PER_ELEMENT_IData,\n\t\ttrue,\n\t);\n\tgl.bindVertexArray(null); // Unbind VAO. The configuration is now saved inside the 'vao' object.\n\tgl.bindBuffer(gl.ARRAY_BUFFER, null); // Unbind the buffer next (configureAttributes() binds it)\n\n\treturn {\n\t\tvertexData,\n\t\tinstanceData,\n\t\tupdateBufferIndices_VertexBuffer: (\n\t\t\tchangedIndicesStart: number,\n\t\t\tchangedIndicesCount: number,\n\t\t): void =>\n\t\t\tupdateBufferIndices(vertexBuffer, vertexData, changedIndicesStart, changedIndicesCount),\n\t\tupdateBufferIndices_InstanceBuffer: (\n\t\t\tchangedIndicesStart: number,\n\t\t\tchangedIndicesCount: number,\n\t\t): void =>\n\t\t\tupdateBufferIndices(\n\t\t\t\tinstanceBuffer,\n\t\t\t\tinstanceData,\n\t\t\t\tchangedIndicesStart,\n\t\t\t\tchangedIndicesCount,\n\t\t\t),\n\t\trender: (\n\t\t\tposition: Vec3 = [0, 0, 0],\n\t\t\tscale: Vec3 = [1, 1, 1],\n\t\t\tuniforms: Record<string, any> = {},\n\t\t): void =>\n\t\t\tprepareAndExecuteRender(shaderProgram, vao, position, scale, uniforms, textures, () =>\n\t\t\t\tgl.drawArraysInstanced(gl[mode], 0, vertexCount, instanceCount),\n\t\t\t),\n\t};\n}\n\n/**\n * Accumulates the stride from the provided attribute info object.\n * Each attribute tells us how many components it uses.\n */\nfunction getStrideFromAttributeInfo(attribInfo: AttributeInfo): number {\n\treturn attribInfo.reduce((totalElements, currentAttrib) => {\n\t\treturn totalElements + currentAttrib.numComponents;\n\t}, 0);\n}\n\n/**\n * Ensures the input is a Float32Array. If the input is already a typed array,\n * it is returned as-is. If it's a number array, a new Float32Array is created.\n * @param data - The input data, which can be either a number array or a typed array.\n * @returns A Float32Array representation of the input data.\n */\nfunction ensureTypedArray(data: InputArray): TypedArray {\n\tif (!Array.isArray(data)) return data; // If it's already a TypedArray, return it.\n\n\tif (data.length > 1_000_000) {\n\t\tconsole.warn(\n\t\t\t'Performance Warning: Float32Array generated from a very large number array (over 1 million in length). It is suggested to start with a Float32Array when computing your data!',\n\t\t);\n\t}\n\treturn new Float32Array(data);\n}\n\n/**\n * Renders a model, preparing the GPU state beforehand and cleaning up afterwards.\n * @param shaderProgram - The shader program to render with.\n * @param vao - The Vertex Array Object that stores the attribute configuration.\n * @param position - The positional translation of the object: `[x,y,z]`\n * @param scale - The scale transformation of the object: `[x,y,z]`\n * @param uniforms - An object with custom uniform names for the keys, and their value for the values. A custom uniform example is 'u_size'. Uniforms that are NOT custom are transformmatrix, and all texture samplers.\n * @param textures - The textures to bind.\n * @param drawCallback - A function that executes the actual draw call, e.g. drawArrays() or drawArraysInstanced().\n */\nfunction prepareAndExecuteRender<A extends string, U extends string>(\n\tshaderProgram: ShaderProgram<A, U>,\n\tvao: WebGLVertexArrayObject,\n\tposition: Vec3,\n\tscale: Vec3,\n\tuniforms: Record<string, any>,\n\ttextures: TextureInfo[],\n\tdrawCallback: () => void,\n): void {\n\t// Switch to the program\n\tshaderProgram.use();\n\t// Bind the VAO. ONE call to restore all attribute configuration.\n\tgl.bindVertexArray(vao);\n\t// Prepare the uniforms.\n\tsetUniforms(shaderProgram, position, scale, uniforms, textures);\n\n\t// Call the draw function!\n\tdrawCallback();\n\n\t// Unbind the VAO.\n\tgl.bindVertexArray(null);\n\t// Unbind textures from all units that were used.\n\t// HAS TO BE AFTER THE DRAW CALL, or the render won't work.\n\t// We can't put it at the end of setUniforms()\n\ttextures.forEach((texInfo, i) => {\n\t\tgl.activeTexture(gl.TEXTURE0 + i);\n\t\tgl.bindTexture(gl.TEXTURE_2D, null);\n\t});\n}\n\n/**\n * Configures the attributes for a shader program.\n * Tells the gpu how it will extract the data from the vertex data buffer.\n * BINDS THE BUFFER FOR YOU.\n * @param shaderProgram - The currently bound shader program, and the one we'll be rendering with.\n * @param buffer - The buffer that we have passed the vertex data into.\n * @param attribInfo - The AttributeInfo object, storing what attributes are in a single stride of the vertex data, and how many components they use.\n * @param stride - The vertex data's stride per vertex.\n * @param BYTES_PER_ELEMENT - How many bytes each element in the vertex data array take up (usually Float32Array.BYTES_PER_ELEMENT).\n * @param instanced - Whether the provided attributes to enable are instance-specific attributes (only updated once per instance instead of once per vertex)\n */\nfunction configureAttributes<A extends string, U extends string>(\n\tshaderProgram: ShaderProgram<A, U>,\n\tbuffer: WebGLBuffer,\n\tattribInfo: AttributeInfo,\n\tstride: number,\n\tBYTES_PER_ELEMENT: number,\n\tinstanced: boolean,\n): void {\n\tgl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n\n\tconst stride_bytes = stride * BYTES_PER_ELEMENT; // # bytes in each vertex/line.\n\tconst vertexAttribDivisor = instanced ? 1 : 0; // 0 = attribs updated once per vertex   1 = updated once per instance\n\tlet currentOffsetBytes = 0; // how many bytes inside the buffer to start from.\n\n\tfor (const attrib of attribInfo) {\n\t\tconst attribLoc = shaderProgram.getAttributeLocation(attrib.name as A)!;\n\t\t// Tell WebGL how to pull out the values from the vertex data and into the attribute in the shader code...\n\t\tgl.vertexAttribPointer(\n\t\t\tattribLoc,\n\t\t\tattrib.numComponents,\n\t\t\tgl.FLOAT,\n\t\t\tfalse,\n\t\t\tstride_bytes,\n\t\t\tcurrentOffsetBytes,\n\t\t);\n\t\tgl.enableVertexAttribArray(attribLoc); // Enable the attribute for use\n\t\t// Be sure to set this every time, even if it's to 0!\n\t\t// If another shader set the same attribute index to be\n\t\t// used for instanced rendering, it would otherwise never be reset!\n\t\tgl.vertexAttribDivisor(attribLoc, vertexAttribDivisor); // 0 = attrib updated once per vertex   1 = updated once per instance\n\n\t\t// Adjust our offset for the next attribute\n\t\tcurrentOffsetBytes += attrib.numComponents * BYTES_PER_ELEMENT;\n\t}\n}\n\n/**\n * Sets the uniforms, preparing them before a draw call.\n * The transformmatrix uniform is updated with EVERY draw call!\n * @param shaderProgram - The currently bound shader program, and the one we'll be rendering with.\n * @param position - The positional translation of the object: `[x,y,z]`\n * @param scale - The scale transformation of the object: `[x,y,z]`\n * @param uniforms - An object with custom uniform names for the keys, and their value for the values. A custom uniform example is 'u_size'. Uniforms that are NOT custom are [transformMatrix, uSampler]\n * @param texture - The texture to bind, if applicable (we should be using the texcoord attribute).\n */\nfunction setUniforms<A extends string, U extends string>(\n\tshaderProgram: ShaderProgram<A, U>,\n\tposition: Vec3,\n\tscale: Vec3,\n\tuniforms: Record<string, any>,\n\ttextures: TextureInfo[],\n): void {\n\t// Calculate the final Model-View-Projection matrix for this object\n\tconst transformMatrix = genTransformMatrix(position, scale);\n\t// Send the transformMatrix to the gpu (every shader has this uniform)\n\tgl.uniformMatrix4fv(\n\t\tshaderProgram.getUniformLocation('u_transformmatrix' as U),\n\t\tfalse,\n\t\ttransformMatrix,\n\t);\n\n\t// Bind and set all textures\n\ttextures.forEach((texInfo, i) => {\n\t\tconst uLoc = shaderProgram.getUniformLocation(texInfo.uniformName as U);\n\t\t// Skip if the shader doesn't use this uniform. Useful for using the same model with different shaders?\n\t\tif (uLoc === null) {\n\t\t\tconsole.warn(\n\t\t\t\t`Uniform \"${texInfo.uniformName}\" not found in shader when trying to set texture. Skipping...`,\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\t// Activate a unique texture unit for each texture.\n\t\tgl.activeTexture(gl.TEXTURE0 + i);\n\t\t// Bind the texture to that unit.\n\t\tgl.bindTexture(gl.TEXTURE_2D, texInfo.texture);\n\t\t// Tell the sampler uniform to use the texture unit we just activated.\n\t\tgl.uniform1i(uLoc, i);\n\t});\n\n\t// Handle custom uniforms provided in the render call.\n\tfor (const [name, value] of Object.entries(uniforms)) {\n\t\tconst uLoc = shaderProgram.getUniformLocation(name as U);\n\n\t\t// It's common for game logic to pass uniforms that a specific shader might not use, so we just skip them.\n\t\tif (uLoc === null) {\n\t\t\tconsole.warn(\n\t\t\t\t`Uniform \"${name}\" not found in shader when trying to set custom uniform. Skipping...`,\n\t\t\t);\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Infer the correct uniform function from the value's type and structure\n\t\tif (Array.isArray(value)) {\n\t\t\t// Value is an array, treat it as a vector (e.g., vec2, vec3, vec4)\n\t\t\tswitch (value.length) {\n\t\t\t\tcase 2:\n\t\t\t\t\tgl.uniform2fv(uLoc, value);\n\t\t\t\t\tbreak;\n\t\t\t\tcase 3:\n\t\t\t\t\tgl.uniform3fv(uLoc, value);\n\t\t\t\t\tbreak;\n\t\t\t\tcase 4:\n\t\t\t\t\tgl.uniform4fv(uLoc, value);\n\t\t\t\t\tbreak;\n\t\t\t\tdefault:\n\t\t\t\t\tconsole.warn(\n\t\t\t\t\t\t`Unsupported array length for uniform \"${name}\". Expected 2, 3, or 4.`,\n\t\t\t\t\t);\n\t\t\t}\n\t\t} else if (typeof value === 'number') {\n\t\t\t// Value is a number, treat it as a float\n\t\t\tgl.uniform1f(uLoc, value);\n\t\t} else if (typeof value === 'boolean') {\n\t\t\t// Value is a boolean, treat it as an integer (0 or 1)\n\t\t\tgl.uniform1i(uLoc, value ? 1 : 0);\n\t\t} else {\n\t\t\tthrow new Error(`Unsupported data type \"${typeof value}\" for uniform \"${name}\".`);\n\t\t}\n\t}\n}\n\n/**\n * Calculates the final Model-View-Projection matrix for a given object.\n * This combines the camera's view and projection with the object's model matrix.\n */\nfunction genTransformMatrix(position: Vec3, scale: Vec3): Mat4 {\n\tconst { projMatrix, viewMatrix } = camera.getProjAndViewMatrixes();\n\tconst modelMatrix = genModelMatrix(position, scale);\n\n\t// Multiply the matrices in order: projection * view * model (world)\n\tconst transformMatrix = mat4.create();\n\tmat4.multiply(transformMatrix, projMatrix, viewMatrix);\n\tmat4.multiply(transformMatrix, transformMatrix, modelMatrix);\n\n\treturn transformMatrix;\n}\n\n/**\n * Generates a model matrix given a position and scale to transform it by!\n * The gpu works with matrices REALLY FAST, so this is the most optimal way\n * to translate our models into position.\n */\nfunction genModelMatrix(position: Vec3, scale: Vec3): Mat4 {\n\tconst modelMatrix = mat4.create();\n\tmat4.scale(modelMatrix, modelMatrix, scale);\n\tmat4.translate(modelMatrix, modelMatrix, position);\n\treturn modelMatrix;\n}\n\nexport {\n\tcreateRenderable,\n\tcreateRenderable_GivenInfo,\n\tcreateRenderable_Instanced,\n\tcreateRenderable_Instanced_GivenInfo,\n};\n\nexport default {\n\tinit,\n};\n\nexport type {\n\tRenderable,\n\tRenderableInstanced,\n\tTypedArray,\n\tAttributeInfo,\n\tAttributeInfoInstanced,\n\tTextureInfo,\n};\n"
  },
  {
    "path": "src/client/scripts/esm/webgl/ShaderProgram.ts",
    "content": "// src/client/scripts/esm/webgl/ShaderProgram.ts\n\n/**\n * A wrapper around a WebGLProgram that handles the boilerplate of\n * compiling, linking, and providing a clean interface for attributes and uniforms.\n * @template Attribute - A union of string literals representing the attribute names.\n * @template Uniform - A union of string literals representing the uniform names.\n */\nexport class ShaderProgram<Attribute extends string, Uniform extends string> {\n\tprivate readonly program: WebGLProgram;\n\tprivate readonly gl: WebGL2RenderingContext;\n\n\t// Caches for attribute and uniform locations to avoid expensive lookups\n\tprivate attributeLocations: Map<string, number> = new Map();\n\tprivate uniformLocations: Map<string, WebGLUniformLocation> = new Map();\n\n\t/**\n\t * Creates, compiles, and links a WebGL program from vertex and fragment shader source.\n\t * This constructor will throw an error if the shaders fail to compile or link.\n\t * @param gl - The WebGL rendering context.\n\t * @param vertexSource - The GLSL source code for the vertex shader.\n\t * @param fragmentSource - The GLSL source code for the fragment shader.\n\t */\n\tconstructor(gl: WebGL2RenderingContext, vertexSource: string, fragmentSource: string) {\n\t\tthis.gl = gl;\n\t\tconst vertexShader = this.compileShader(gl.VERTEX_SHADER, vertexSource);\n\t\tconst fragmentShader = this.compileShader(gl.FRAGMENT_SHADER, fragmentSource);\n\n\t\tthis.program = this.createProgram(vertexShader, fragmentShader);\n\t}\n\n\t/** Activates this shader program for use in rendering. */\n\tpublic use(): void {\n\t\tthis.gl.useProgram(this.program);\n\t}\n\n\t/** Looks up and caches the location of a vertex attribute. */\n\tpublic getAttributeLocation(name: Attribute): number {\n\t\tif (this.attributeLocations.has(name)) return this.attributeLocations.get(name)!; // Pre-cached location\n\t\t// Manually fetch location (more expensive)\n\t\tconst location = this.gl.getAttribLocation(this.program, name);\n\t\t// It's common for unused attributes to be optimized out, so this isn't\n\t\t// always an error. We'll warn but not throw.\n\t\tif (location === -1) console.warn(`Attribute \"${name}\" not found in shader program.`);\n\t\tthis.attributeLocations.set(name, location); // Cache the location\n\t\treturn location;\n\t}\n\n\t/** Looks up and caches the location of a uniform. */\n\tpublic getUniformLocation(name: Uniform): WebGLUniformLocation | null {\n\t\tif (this.uniformLocations.has(name)) return this.uniformLocations.get(name)!; // Pre-cached location\n\t\t// Manually fetch location (more expensive)\n\t\tconst location = this.gl.getUniformLocation(this.program, name);\n\t\t// Unused uniforms are also common.\n\t\tif (location === null) console.warn(`Uniform \"${name}\" not found in shader program.`);\n\t\tthis.uniformLocations.set(name, location!); // Cache the location\n\t\treturn location;\n\t}\n\n\t// Private Helper Methods -----------------------------------------------------------\n\n\t/**\n\t * Creates an actual program from the provided vertex shader and fragment shader\n\t * in which our webgl context can switch to via gl.useProgram() before rendering.\n\t */\n\tprivate createProgram(vertexShader: WebGLShader, fragmentShader: WebGLShader): WebGLProgram {\n\t\t// Create the shader program\n\t\tconst program = this.gl.createProgram();\n\t\tif (!program) throw Error('Failed to create WebGL program.');\n\n\t\tthis.gl.attachShader(program, vertexShader);\n\t\tthis.gl.attachShader(program, fragmentShader);\n\t\tthis.gl.linkProgram(program);\n\n\t\t// Check if it was created successfully\n\t\tif (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) {\n\t\t\tconst info = this.gl.getProgramInfoLog(program);\n\t\t\tthrow Error(`Failed to link WebGL program: ${info}`);\n\t\t}\n\t\treturn program;\n\t}\n\n\t/**\n\t * Creates a shader of the given type, from the specified source code.\n\t * @param type - `gl.VERTEX_SHADER` or `gl.FRAGMENT_SHADER`\n\t * @param sourceText - The shader source code, in GLSL version 1.00\n\t */\n\tprivate compileShader(type: number, source: string): WebGLShader {\n\t\tconst shader = this.gl.createShader(type);\n\t\tif (!shader) throw Error(`Failed to create shader (type: ${type})`);\n\n\t\tthis.gl.shaderSource(shader, source); // Send the source to the shader object\n\t\tthis.gl.compileShader(shader); // Compile the shader program\n\n\t\t// Check if it compiled successfully\n\t\tif (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {\n\t\t\tconst info = this.gl.getShaderInfoLog(shader);\n\t\t\tconst typeName = type === this.gl.VERTEX_SHADER ? 'VERTEX' : 'FRAGMENT';\n\t\t\tthis.gl.deleteShader(shader);\n\t\t\tthrow Error(`Failed to compile ${typeName} shader: ${info}`);\n\t\t}\n\t\treturn shader;\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/webgl/TextureLoader.ts",
    "content": "// src/client/scripts/esm/webgl/TextureLoader.ts\n\ninterface Options {\n\t/** Whether to generate and use mipmaps for the texture. Default is false. */\n\tmipmaps?: boolean;\n}\n\nclass TextureLoader {\n\t/** Default options if none are provided. */\n\tprivate static defaultOptions: Required<Options> = {\n\t\tmipmaps: false,\n\t};\n\n\t/**\n\t * Loads a WebGL texture from an HTMLImageElement.\n\t * @param gl - The WebGL2 rendering context.\n\t * @param img - The HTMLImageElement from which to create the texture.\n\t * @param options - Optional settings for texture creation.\n\t * @returns The created WebGLTexture.\n\t */\n\tpublic static loadTexture(\n\t\tgl: WebGL2RenderingContext,\n\t\timg: HTMLImageElement,\n\t\toptions: Options = {},\n\t): WebGLTexture {\n\t\tconst settings: Required<Options> = { ...this.defaultOptions, ...options };\n\n\t\tif (!isPowerOfTwo(img.naturalWidth) || !isPowerOfTwo(img.naturalHeight)) {\n\t\t\tthrow new Error(\n\t\t\t\t`Image dimensions are not a power of two! Cannot use REPEAT wrapping mode. ${img.naturalWidth}x${img.naturalHeight}`,\n\t\t\t);\n\t\t}\n\n\t\tconst texture = gl.createTexture();\n\n\t\t// Upload the image to the GPU\n\t\tgl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); // Flip image pixels into the bottom-to-top order that WebGL expects.\n\t\tgl.bindTexture(gl.TEXTURE_2D, texture);\n\t\tgl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);\n\n\t\t// Set filtering and mipmaps\n\t\tif (settings.mipmaps) {\n\t\t\tgl.generateMipmap(gl.TEXTURE_2D);\n\n\t\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); // Smooth edges, mipmap interpollation (half-blurry all the time, EXCEPT with LOD bias of +0.5)\n\t\t\t// gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST_MIPMAP_LINEAR); // DEFAULT if not set. Jagged edges, mipmap interpollation (never blurry, though always jaggy)\n\t\t\t// gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST); // Smooth edges, mipmap snapping (clear on some zoom levels, full blurry at others)\n\t\t\t// gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST_MIPMAP_NEAREST); // Jagged edges, mipmap snapping (jagged all the time)\n\n\t\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); // Magnification, smooth edges (noticeable when zooming in)\n\t\t} else {\n\t\t\t// No mipmaps. Set wrapping\n\t\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); // Minification, smooth edges (not very noticeable)\n\t\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); // Magnification, hard edges. Gives that pixelated look required for low-resolution board tiles texture.\n\t\t}\n\n\t\t// Not needed since it's the default, but adds clarity.\n\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);\n\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);\n\n\t\tgl.bindTexture(gl.TEXTURE_2D, null);\n\n\t\treturn texture;\n\t}\n}\n\nfunction isPowerOfTwo(value: number): boolean {\n\treturn (value & (value - 1)) === 0;\n}\n\nexport default TextureLoader;\n"
  },
  {
    "path": "src/client/scripts/esm/webgl/maskedDraw.ts",
    "content": "// src/client/scripts/esm/webgl/maskedDraw.ts\n\n/**\n * This module manages stencil-masked rendering.\n * Both \"inclusion\" and \"exclusion\" masks are supported.\n */\n\nimport { gl } from '../game/rendering/webgl.js';\nimport { ProgramManager } from './ProgramManager.js';\n\n// Variables -------------------------------------------------------------------------------\n\nlet programManager: ProgramManager;\n\n/**\n * Tracks how many times {@link execute} has been called this frame.\n * Each call gets its own isolated bit pair in the 8-bit stencil buffer (2 bits per call, 4\n * calls max), so old stencil values from one call can never contaminate a later call.\n *\n * When the budget is exhausted, {@link resetStencilBuffer} is called to zero all bits via a\n * full-screen draw call (safe on TBDR GPUs — stays in the render pass), then the index recycles.\n */\nlet stencilCallIndex: number = 0;\n\n// Functions -------------------------------------------------------------------------------\n\n/**\n * Must be called once after WebGL is initialized and the\n * ProgramManager is created, before any call to {@link execute}.\n */\nfunction init(pm: ProgramManager): void {\n\tprogramManager = pm;\n}\n\n/**\n * Must be called once at the start of every frame, when clearing the screen.\n * Resets the stencil bit-pair index so each call gets a fresh pair this frame.\n */\nfunction onFrameStart(): void {\n\tstencilCallIndex = 0;\n}\n\n/**\n * Resets all stencil bits to 0 using a full-screen draw call.\n *\n * Using `gl.clear(STENCIL_BUFFER_BIT)` mid-frame yields partial/torn frames on Chrome mobile.\n * This is believed to be because it forces a render-pass boundary on TBDR GPUs, causing tile\n * memory to be flushed to DRAM, which Chrome's compositor can read as a partial frame.\n * A full-screen draw call, by contrast, stays within the current render pass and tile memory, avoiding this issue.\n *\n * This is only called when the 4-call bit-pair budget is exhausted.\n */\nfunction resetStencilBuffer(): void {\n\tprogramManager.get('post_pass').use();\n\n\tgl.enable(gl.STENCIL_TEST);\n\tgl.colorMask(false, false, false, false);\n\tgl.depthMask(false);\n\tgl.stencilMask(0xff);\n\tgl.stencilFunc(gl.ALWAYS, 0, 0xff);\n\tgl.stencilOp(gl.REPLACE, gl.REPLACE, gl.REPLACE);\n\n\tgl.drawArrays(gl.TRIANGLES, 0, 6); // Full-screen quad via gl_VertexID (no VBO needed)\n\n\tgl.disable(gl.STENCIL_TEST);\n\tgl.colorMask(true, true, true, true);\n\tgl.depthMask(true);\n\n\tstencilCallIndex = 0; // Reset the call index since all bits are now zeroed\n}\n\n/**\n * Renders content using a flexible stencil mask.\n * Handles all stencil buffer state changes internally, ensuring a clean state before and after.\n * @param drawInclusionMaskFunc - A function that renders the INCLUSION ZONE MASK. The main scene will appear inside this zone.\n * @param drawExclusionMaskFunc - A function that renders the EXCLUSION ZONE MASK. The main scene will NOT appear inside this zone.\n * @param drawContentFunc - A function that renders the main scene content. Will be masked.\n * @param intersectionMode - Determines the behavior for intersections of the two mask types:\n * \t\t\t\t\t\t\t'and' => Main scene will only be drawn where the inclusion mask and inversion of the exclusion mask intersect.\n * \t\t\t\t\t\t\t'or' => Main scene will be drawn inside the inclusion mask and inversion of the exclusion mask.\n * \t\t\t\t\t\t\tHas no effect if only one mask type is provided.\n */\nfunction execute(\n\tdrawInclusionMaskFunc: Function | undefined,\n\tdrawExclusionMaskFunc: Function | undefined,\n\tdrawContentFunc: Function,\n\tintersectionMode: 'and' | 'or',\n): void {\n\tif (!drawExclusionMaskFunc && !drawInclusionMaskFunc)\n\t\tthrow Error('No mask functions provided.');\n\n\t/**\n\t * Assign this call its own isolated bit pair in the 8-bit stencil buffer.\n\t *\n\t * We use 2 bits per call (supporting up to 4 calls/frame — we currently use up to 3).\n\t * The bit pairs from different calls never overlap, so leftover stencil values from\n\t * earlier calls are invisible to us because we only test/write bits within our own mask.\n\t *\n\t * Example  callIndex 0 → bitMask=0x03, exclusionBit=0x01, inclusionBit=0x02\n\t *          callIndex 1 → bitMask=0x0C, exclusionBit=0x04, inclusionBit=0x08\n\t *          callIndex 2 → bitMask=0x30, exclusionBit=0x10, inclusionBit=0x20\n\t *\n\t * If all 4 bit pairs are exhausted, {@link resetStencilBuffer} zeros the buffer via a\n\t * full-screen draw call and the index recycles from 0.\n\t */\n\n\tif (stencilCallIndex >= 4) resetStencilBuffer();\n\n\tconst callIndex = stencilCallIndex++;\n\tconst exclusionBit = 1 << (callIndex * 2); // e.g. 0x01, 0x04, 0x10\n\tconst inclusionBit = 1 << (callIndex * 2 + 1); // e.g. 0x02, 0x08, 0x20\n\tconst bitMask = exclusionBit | inclusionBit; // e.g. 0x03, 0x0C, 0x30\n\n\t// Enable the stencil test.\n\tgl.enable(gl.STENCIL_TEST);\n\t// We don't want the mask to be affected by depth.\n\t// WITHOUT THIS, sometimes the mask doesn't do its masking, because it\n\t// initially failed the depth test if something else is rendered in front of it!\n\tgl.disable(gl.DEPTH_TEST);\n\n\ttry {\n\t\t// We want to write to the stencil buffer, but make the mask itself invisible.\n\t\tgl.colorMask(false, false, false, false); // Disable writing to the color buffer\n\t\tgl.depthMask(false); // Disable writing to the depth buffer\n\t\t// Only write to our assigned bit pair; bits from other calls are preserved.\n\t\tgl.stencilMask(bitMask);\n\t\tgl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);\n\n\t\t// Draw the Masks\n\n\t\tif (intersectionMode === 'and') {\n\t\t\tdrawInclusion();\n\t\t\tdrawExclusion();\n\t\t} else {\n\t\t\tdrawExclusion();\n\t\t\tdrawInclusion();\n\t\t}\n\n\t\tfunction drawInclusion(): void {\n\t\t\tif (!drawInclusionMaskFunc) return;\n\t\t\t// Writes inclusionBit into our bit pair. The readMask in stencilFunc is\n\t\t\t// irrelevant here (ALWAYS passes regardless), but ref is what REPLACE stores.\n\t\t\tgl.stencilFunc(gl.ALWAYS, inclusionBit, bitMask);\n\t\t\tdrawInclusionMaskFunc();\n\t\t}\n\t\tfunction drawExclusion(): void {\n\t\t\tif (!drawExclusionMaskFunc) return;\n\t\t\t// Writes exclusionBit into our bit pair.\n\t\t\tgl.stencilFunc(gl.ALWAYS, exclusionBit, bitMask);\n\t\t\tdrawExclusionMaskFunc();\n\t\t}\n\n\t\t// Draw the Main Content\n\n\t\t// Re-enable drawing to the screen.\n\t\tgl.colorMask(true, true, true, true);\n\t\tgl.depthMask(true);\n\t\t// During content draw, don't write to the stencil; only test against our bit pair.\n\t\tgl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);\n\n\t\tif (drawExclusionMaskFunc && drawInclusionMaskFunc) {\n\t\t\t// Case: COMPOSITE MASK (both exclusion and inclusion masks provided)\n\t\t\tif (intersectionMode === 'or') {\n\t\t\t\t// Draw where our bit pair is NOT set to exclusionBit (i.e. 0 or inclusionBit).\n\t\t\t\tgl.stencilFunc(gl.NOTEQUAL, exclusionBit, bitMask);\n\t\t\t} else {\n\t\t\t\t// Draw where our bit pair is exactly inclusionBit (not 0, not exclusionBit).\n\t\t\t\tgl.stencilFunc(gl.EQUAL, inclusionBit, bitMask);\n\t\t\t}\n\t\t} else if (drawExclusionMaskFunc) {\n\t\t\t// Case: EXCLUSION ONLY. Draw where our bit pair is not exclusionBit (i.e. 0).\n\t\t\tgl.stencilFunc(gl.NOTEQUAL, exclusionBit, bitMask);\n\t\t} else if (drawInclusionMaskFunc) {\n\t\t\t// Case: INCLUSION ONLY. Draw where our bit pair is inclusionBit.\n\t\t\tgl.stencilFunc(gl.EQUAL, inclusionBit, bitMask);\n\t\t} else throw Error('Unexpected!');\n\n\t\tdrawContentFunc();\n\t} finally {\n\t\t// Return to a normal state.\n\t\tgl.disable(gl.STENCIL_TEST);\n\t\tgl.enable(gl.DEPTH_TEST);\n\t}\n}\n\n// Exports -----------------------------------------------------------------\n\nexport default { init, onFrameStart, execute };\n"
  },
  {
    "path": "src/client/scripts/esm/webgl/post_processing/PostProcessingPipeline.ts",
    "content": "// src/client/scripts/esm/webgl/post_processing/PostProcessingPipeline.ts\n\nimport { ShaderProgram } from '../ShaderProgram';\nimport { ProgramManager } from '../ProgramManager';\nimport { PassThroughPass } from './passes/PassThroughPass';\n\n/** A Post Processing Effect applied to the whole screen after rendering the scene. */\nexport interface PostProcessPass {\n\t/** The shader program this pass uses. */\n\treadonly program: ShaderProgram<string, string>;\n\n\t/** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */\n\tmasterStrength: number;\n\n\t/**\n\t * Executes the render pass.\n\t * This method is responsible for activating the shader and setting its uniforms.\n\t * @param gl The WebGL2 rendering context.\n\t * @param inputTexture The texture to read from (the result of the previous pass).\n\t */\n\trender(_gl: WebGL2RenderingContext, _inputTexture: WebGLTexture): void;\n}\n\n/**\n * Manages the post-processing pipeline for a raw WebGL2 application.\n * This class handles FBO creation, resizing, and the \"ping-pong\" technique\n * for chaining multiple effects.\n */\nexport class PostProcessingPipeline {\n\tprivate gl: WebGL2RenderingContext;\n\tprivate passes: PostProcessPass[] = [];\n\tprivate maxSamples: number; // For MSAA\n\n\t// --- Multisampled FBO for the main scene render ---\n\tprivate sceneFBO: WebGLFramebuffer;\n\tprivate sceneColorBuffer: WebGLRenderbuffer;\n\tprivate sceneDepthStencilBuffer: WebGLRenderbuffer;\n\n\t// --- Ping-Pong Framebuffers for post-processing ---\n\t// We use two FBOs to read from one while writing to the other.\n\tprivate readFBO: WebGLFramebuffer;\n\tprivate writeFBO: WebGLFramebuffer;\n\tprivate readTexture: WebGLTexture;\n\tprivate writeTexture: WebGLTexture;\n\n\t// This will hold the default shader for the \"zero effects\" case.\n\tprivate passThroughPass: PassThroughPass;\n\n\tconstructor(gl: WebGL2RenderingContext, programManager: ProgramManager) {\n\t\tthis.gl = gl;\n\n\t\t// Get the pass-through shader from your manager.\n\t\tthis.passThroughPass = new PassThroughPass(programManager);\n\n\t\t// Get the max MSAA samples supported by the hardware.\n\t\tthis.maxSamples = gl.getParameter(gl.MAX_SAMPLES);\n\n\t\tconst initialWidth = gl.canvas.width;\n\t\tconst initialHeight = gl.canvas.height;\n\n\t\t// --- Create Framebuffers and Textures for Post-Processing ---\n\t\tconst { fbo: fboA, texture: textureA } = this.createFBO(initialWidth, initialHeight);\n\t\tconst { fbo: fboB, texture: textureB } = this.createFBO(initialWidth, initialHeight);\n\t\tthis.readFBO = fboA;\n\t\tthis.readTexture = textureA;\n\t\tthis.writeFBO = fboB;\n\t\tthis.writeTexture = textureB;\n\n\t\t// --- Create Multisampled FBO and Renderbuffers for Scene ---\n\t\tthis.sceneFBO = gl.createFramebuffer()!;\n\t\tthis.sceneColorBuffer = gl.createRenderbuffer()!;\n\t\tthis.sceneDepthStencilBuffer = gl.createRenderbuffer()!;\n\n\t\t// --- Initial sizing ---\n\t\tthis.resize(gl.canvas.width, gl.canvas.height);\n\t}\n\n\t/**\n\t * Creates a single Framebuffer Object and its corresponding color texture.\n\t * This is for the single-sampled post-processing passes.\n\t */\n\tprivate createFBO(\n\t\twidth: number,\n\t\theight: number,\n\t): { fbo: WebGLFramebuffer; texture: WebGLTexture } {\n\t\tconst gl = this.gl;\n\n\t\tconst texture = gl.createTexture();\n\t\tif (!texture) throw new Error('Could not create texture');\n\t\tgl.bindTexture(gl.TEXTURE_2D, texture);\n\n\t\t// Allocate storage for the texture IMMEDIATELY upon creation.\n\t\t// FIXES MOBILE BUG. Previousy we were attaching sizeless (0x0) textures\n\t\t// to framebuffers. Strict mobile drivers permanently mark these as invalid,\n\t\t// while lenient desktop drivers allow it. This line allocates the texture's\n\t\t// storage with the correct dimensions before attaching it, ensuring the\n\t\t// framebuffer is valid from the start on all platforms.\n\t\tgl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);\n\n\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n\n\t\tconst fbo = gl.createFramebuffer();\n\t\tif (!fbo) throw new Error('Could not create framebuffer');\n\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, fbo);\n\t\tgl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);\n\n\t\t// Unbind to be clean\n\t\tgl.bindTexture(gl.TEXTURE_2D, null);\n\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, null);\n\n\t\treturn { fbo, texture };\n\t}\n\n\t/**\n\t * Updates the entire list of post processing effect passes.\n\t */\n\tpublic setPasses(passes: PostProcessPass[]): void {\n\t\tthis.passes = passes;\n\t}\n\n\t/**\n\t * Call this BEFORE rendering your main 3D scene.\n\t * It binds the FBO, redirecting all subsequent draw calls to an off-screen texture.\n\t */\n\tpublic begin(): void {\n\t\tconst gl = this.gl;\n\n\t\t// Bind the MULTISAMPLED FBO we will write the 3D scene into.\n\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, this.sceneFBO);\n\n\t\t// Check if the framebuffer is complete.\n\t\tconst status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);\n\t\tif (status !== gl.FRAMEBUFFER_COMPLETE) {\n\t\t\tconsole.error(`Scene FBO is not complete: ${status}`);\n\t\t}\n\n\t\t// Set the viewport to the FBO size and clear it.\n\t\tgl.viewport(0, 0, gl.canvas.width, gl.canvas.height);\n\t\tgl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);\n\n\t\t// Enable blending if your main scene needs it.\n\t\tgl.enable(gl.BLEND);\n\t\tgl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);\n\t\tgl.enable(gl.DEPTH_TEST);\n\t}\n\n\t/**\n\t * Call this AFTER your main 3D scene has been rendered.\n\t * It executes the post-processing passes and draws the final result to the canvas.\n\t */\n\tpublic end(): void {\n\t\tconst gl = this.gl;\n\n\t\t// --- RESOLVE MSAA ---\n\t\t// Copy (blit) the anti-aliased scene from the multisampled FBO\n\t\t// to the first single-sampled FBO in our ping-pong chain.\n\t\tgl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.sceneFBO);\n\t\tgl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.readFBO);\n\t\t// prettier-ignore\n\t\tgl.blitFramebuffer(\n\t\t\t0, 0, gl.canvas.width, gl.canvas.height, // source rect\n\t\t\t0, 0, gl.canvas.width, gl.canvas.height, // destination rect\n\t\t\tgl.COLOR_BUFFER_BIT, // buffer to copy\n\t\t\tgl.NEAREST, // filter (must be NEAREST for MSAA resolve)\n\t\t);\n\t\t// The anti-aliased scene is now in `readTexture`.\n\n\t\t// Unbind framebuffers and prepare for 2D post-processing passes.\n\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, null);\n\t\tgl.disable(gl.DEPTH_TEST);\n\t\tgl.disable(gl.BLEND);\n\n\t\t// If we have no added no passes, we'll use our pass-through shader.\n\t\t// This creates a unified code path for all scenarios.\n\t\tconst activePasses: PostProcessPass[] =\n\t\t\tthis.passes.length > 0 ? this.passes : [this.passThroughPass];\n\n\t\t// 1. PING-PONG PASSES: Loop through all but the very last pass.\n\t\t// These passes all render to the next FBO.\n\t\tfor (let i = 0; i < activePasses.length - 1; i++) {\n\t\t\tconst pass = activePasses[i]!;\n\t\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, this.writeFBO); // Target the off-screen buffer\n\n\t\t\tpass.render(gl, this.readTexture);\n\n\t\t\tgl.drawArrays(gl.TRIANGLES, 0, 6); // 6 vertices (2 triangles)\n\n\t\t\tthis.swapFBOs(); // The FBO we just wrote to becomes the read FBO for the next pass\n\t\t}\n\n\t\t// 2. FINAL PASS: Render the last effect directly to the screen.\n\t\tconst lastPass = activePasses[activePasses.length - 1]!;\n\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, null); // Target the canvas\n\t\tgl.viewport(0, 0, gl.canvas.width, gl.canvas.height);\n\t\tgl.clear(gl.COLOR_BUFFER_BIT); // Clear canvas before drawing final result\n\n\t\tlastPass.render(gl, this.readTexture);\n\n\t\tgl.drawArrays(gl.TRIANGLES, 0, 6); // 6 vertices (2 triangles)\n\n\t\t// RESTORE THE STATE of the gl context before ever using the pipeline!\n\t\t// This prevents texture alpha issues on frames where the pipeline is not used.\n\t\tgl.enable(gl.DEPTH_TEST);\n\t\tgl.enable(gl.BLEND);\n\t\tgl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);\n\t}\n\n\t/**\n\t * Swaps the read and write FBOs for the ping-pong technique.\n\t */\n\tprivate swapFBOs(): void {\n\t\tconst tempFBO = this.readFBO;\n\t\tthis.readFBO = this.writeFBO;\n\t\tthis.writeFBO = tempFBO;\n\n\t\tconst tempTexture = this.readTexture;\n\t\tthis.readTexture = this.writeTexture;\n\t\tthis.writeTexture = tempTexture;\n\t}\n\n\t/**\n\t * Must be called whenever the canvas is resized to update the FBO textures\n\t * and the depth/stencil buffer.\n\t * @param width The new width of the canvas.\n\t * @param height The new height of the canvas.\n\t */\n\t// prettier-ignore\n\tpublic resize(width: number, height: number): void {\n\t\tconst gl = this.gl;\n\n\t\t// Resize the single-sampled color textures for post-processing\n\t\tconst textures = [this.readTexture, this.writeTexture];\n\t\tfor (const texture of textures) {\n\t\t\tgl.bindTexture(gl.TEXTURE_2D, texture);\n\t\t\t// Use RGBA8 for standard dynamic range. You could use RGBA16F for HDR.\n\t\t\tgl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);\n\t\t}\n\n\t\t// --- Resize the MULTISAMPLED renderbuffers for the main scene ---\n\t\t// Color buffer\n\t\tgl.bindRenderbuffer(gl.RENDERBUFFER, this.sceneColorBuffer);\n\t\tgl.renderbufferStorageMultisample(gl.RENDERBUFFER, this.maxSamples, gl.RGBA8, width, height);\n\n\t\t// Depth/stencil renderbuffer\n\t\tgl.bindRenderbuffer(gl.RENDERBUFFER, this.sceneDepthStencilBuffer);\n\t\tgl.renderbufferStorageMultisample(gl.RENDERBUFFER, this.maxSamples, gl.DEPTH24_STENCIL8, width, height);\n\n\t\t// Attach the multisampled renderbuffers to the scene FBO\n\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, this.sceneFBO);\n\t\tgl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.RENDERBUFFER, this.sceneColorBuffer);\n\t\tgl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, this.sceneDepthStencilBuffer);\n\n\t\t// Unbind to be clean\n\t\tgl.bindTexture(gl.TEXTURE_2D, null);\n\t\tgl.bindRenderbuffer(gl.RENDERBUFFER, null);\n\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, null);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/webgl/post_processing/passes/ColorGradePass.ts",
    "content": "// src/client/scripts/esm/webgl/post_processing/passes/ColorGradePass.ts\n\nimport type { PostProcessPass } from '../PostProcessingPipeline';\nimport type { ProgramManager, ProgramMap } from '../../ProgramManager';\n\n/**\n * A post-processing pass for applying a full suite of color grading effects.\n */\nexport class ColorGradePass implements PostProcessPass {\n\treadonly program: ProgramMap['color_grade'];\n\n\t// --- Public Properties to Control the Effect ---\n\n\t/** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */\n\tpublic masterStrength: number = 1.0;\n\n\t/** Adjusts overall brightness. 0.0 is no change. */\n\tpublic brightness: number = 0.0;\n\n\t/** Adjusts contrast. 1.0 is no change. */\n\tpublic contrast: number = 1.0;\n\n\t/**\n\t * Adjusts mid-tones. 1.0 is no change.\n\t * MUST BE > 0!\n\t */\n\tpublic gamma: number = 1.0;\n\n\t/** Adjusts color intensity. 1.0 is no change, 0.0 is grayscale. */\n\tpublic saturation: number = 1.0;\n\n\t/** Tints the scene with a color. [1, 1, 1] is no change. */\n\tpublic tint: [number, number, number] = [1.0, 1.0, 1.0];\n\n\t/** Rotates all colors. 0.0 is no change, wraps at 1.0. */\n\tpublic hueOffset: number = 0.0;\n\n\tconstructor(programManager: ProgramManager) {\n\t\tthis.program = programManager.get('color_grade');\n\t}\n\n\trender(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void {\n\t\tthis.program.use();\n\n\t\t// Bind the input texture to texture unit 0\n\t\tgl.activeTexture(gl.TEXTURE0);\n\t\tgl.bindTexture(gl.TEXTURE_2D, inputTexture);\n\n\t\t// Set all the uniforms\n\t\tgl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_masterStrength'), this.masterStrength);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_brightness'), this.brightness);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_contrast'), this.contrast);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_gamma'), this.gamma);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_saturation'), this.saturation);\n\t\tgl.uniform3fv(this.program.getUniformLocation('u_tintColor'), this.tint);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_hueOffset'), this.hueOffset);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/webgl/post_processing/passes/GlitchPass.ts",
    "content": "// src/client/scripts/esm/webgl/post_processing/passes/GlitchPass.ts\n\nimport type { PostProcessPass } from '../PostProcessingPipeline';\nimport type { ProgramManager, ProgramMap } from '../../ProgramManager';\n\n/**\n * A post-processing pass that applies a glitch effect,\n * combining horizontal tearing and chromatic aberration.\n */\nexport class GlitchPass implements PostProcessPass {\n\treadonly program: ProgramMap['glitch'];\n\n\t// --- Public Properties to Control the Effect ---\n\n\t/** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */\n\tpublic masterStrength: number = 1.0;\n\n\t/** The strength of the chromatic aberration. */\n\tpublic aberrationStrength: number = 0.0;\n\t/** The direction and magnitude of the color channel separation for chromatic aberration in virtual CSS pixels. */\n\tpublic aberrationOffsetPixels: [number, number] = [10.0, 0.0];\n\n\t/** The strength of the horizontal tearing. */\n\tpublic tearStrength: number = 0.0;\n\t/** The height of individual tear lines in virtual CSS pixels. */\n\tpublic tearResolution: number = 16.0;\n\t/** The maximum horizontal displacement for a tear in virtual CSS pixels. */\n\tpublic tearMaxDisplacement: number = 20.0;\n\n\t/** The current time, used to animate the glitch patterns. Increment this each frame. */\n\tpublic time: number = 0.0;\n\n\tconstructor(programManager: ProgramManager) {\n\t\tthis.program = programManager.get('glitch');\n\t}\n\n\t// prettier-ignore\n\trender(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void {\n\t\tthis.program.use();\n\n\t\t// Bind the scene texture from the pipeline to TEXTURE UNIT 0\n\t\tgl.activeTexture(gl.TEXTURE0);\n\t\tgl.bindTexture(gl.TEXTURE_2D, inputTexture);\n\n\t\t// Set all the uniforms\n\t\tgl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_masterStrength'), this.masterStrength);\n\n\t\t// Chromatic Aberration Uniforms\n\t\tgl.uniform1f(this.program.getUniformLocation('u_aberrationStrength'), this.aberrationStrength);\n\n\t\t// Convert the aberration offset to UV space\n\t\tconst uvAberrationOffset: [number, number] = [\n\t\t\tthis.aberrationOffsetPixels[0] * window.devicePixelRatio / gl.canvas.width,\n\t\t\tthis.aberrationOffsetPixels[1] * window.devicePixelRatio / gl.canvas.height,\n\t\t];\n\t\tgl.uniform2fv(this.program.getUniformLocation('u_aberrationOffset'), uvAberrationOffset);\n\n\t\t// Horizontal Tearing Uniforms\n\t\tgl.uniform1f(this.program.getUniformLocation('u_tearStrength'), this.tearStrength);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_tearResolution'), this.tearResolution);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_tearMaxDisplacement'), this.tearMaxDisplacement);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_time'), this.time);\n\t\tgl.uniform2f(this.program.getUniformLocation('u_resolution'), gl.canvas.width, gl.canvas.height);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_devicePixelRatio'), window.devicePixelRatio);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/webgl/post_processing/passes/HeatWavePass.ts",
    "content": "// src/client/scripts/esm/webgl/post_processing/passes/HeatWavePass.ts\n\nimport type { PostProcessPass } from '../PostProcessingPipeline';\nimport type { ProgramManager, ProgramMap } from '../../ProgramManager';\n\n/**\n * A post-processing pass that applies a rising, shimmering heat distortion effect.\n */\nexport class HeatWavePass implements PostProcessPass {\n\treadonly program: ProgramMap['heat_wave'];\n\tprivate noiseTexture: WebGLTexture;\n\n\t// --- Public Properties to Control the Effect ---\n\n\t/** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */\n\tpublic masterStrength: number = 1.0;\n\n\t/** The strength of the distortion effect. */\n\tpublic strength: number = 0.04; // Default: 0.04\n\n\t/** The current time, used to animate the waves. Increment this each frame. */\n\tpublic time: number = 0.0;\n\n\tconstructor(programManager: ProgramManager, noiseTexture: WebGLTexture) {\n\t\tthis.program = programManager.get('heat_wave');\n\t\tthis.noiseTexture = noiseTexture;\n\t}\n\n\t// prettier-ignore\n\trender(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void {\n\t\tthis.program.use();\n\n\t\t// 1. Bind the scene texture from the pipeline to TEXTURE UNIT 0\n\t\tgl.activeTexture(gl.TEXTURE0);\n\t\tgl.bindTexture(gl.TEXTURE_2D, inputTexture);\n\n\t\t// 2. Bind our own noise texture to TEXTURE UNIT 1\n\t\tgl.activeTexture(gl.TEXTURE1);\n\t\tgl.bindTexture(gl.TEXTURE_2D, this.noiseTexture);\n\n\t\t// 3. Set the uniforms, telling the shader which texture unit to use for each\n\t\tgl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0); // Use unit 0\n\t\tgl.uniform1f(this.program.getUniformLocation('u_masterStrength'), this.masterStrength);\n\t\tgl.uniform1i(this.program.getUniformLocation('u_noiseTexture'), 1); // Use unit 1\n\t\tgl.uniform1f(this.program.getUniformLocation('u_time'), this.time);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_strength'), this.strength);\n\t\tgl.uniform2f(this.program.getUniformLocation('u_resolution'), gl.canvas.width, gl.canvas.height);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/webgl/post_processing/passes/PassThroughPass.ts",
    "content": "// src/client/scripts/esm/webgl/post_processing/passes/PassThroughPass.ts\n\nimport type { PostProcessPass } from '../PostProcessingPipeline';\nimport type { ProgramManager, ProgramMap } from '../../ProgramManager';\n\n/**\n * A Post Processing Pass Through Effect, with zero effects.\n * Only required if we have no other effects.\n */\nexport class PassThroughPass implements PostProcessPass {\n\treadonly program: ProgramMap['post_pass'];\n\n\t/**\n\t * A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect.\n\t * HAS NO EFFECT IN THE PASS THROUGH PASS.\n\t */\n\tpublic masterStrength: number = 1.0;\n\n\tconstructor(programManager: ProgramManager) {\n\t\tthis.program = programManager.get('post_pass');\n\t}\n\n\trender(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void {\n\t\tthis.program.use();\n\n\t\tgl.activeTexture(gl.TEXTURE0);\n\t\tgl.bindTexture(gl.TEXTURE_2D, inputTexture);\n\n\t\tgl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/webgl/post_processing/passes/SineWavePass.ts",
    "content": "// src/client/scripts/esm/webgl/post_processing/passes/SineWavePass.ts\n\nimport type { PostProcessPass } from '../PostProcessingPipeline';\nimport type { ProgramManager, ProgramMap } from '../../ProgramManager';\n\n/**\n * A post-processing pass that applies a double-axis sine wave distortion to the image.\n */\nexport class SineWavePass implements PostProcessPass {\n\treadonly program: ProgramMap['sine_wave'];\n\n\t// --- Public Properties to Control the Effect ---\n\n\t/** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */\n\tpublic masterStrength: number = 1.0;\n\n\t/** The strength of the wave on the [x, y] axes. */\n\tpublic amplitude: [number, number] = [0.003, 0.003];\n\n\t/** The number of full waves across the screen on the [x, y] axes. */\n\tpublic frequency: [number, number] = [2.0, 2.0];\n\n\t/** The angle of the primary wave axis in degrees. The second wave is perpendicular. */\n\tpublic angle: number = 0.0;\n\n\t/** The current time, used to animate the waves. Increment this each frame. */\n\tpublic time: number = 0.0;\n\n\tconstructor(programManager: ProgramManager) {\n\t\tthis.program = programManager.get('sine_wave');\n\t}\n\n\trender(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void {\n\t\tthis.program.use();\n\n\t\t// Bind the scene texture from the pipeline to TEXTURE UNIT 0\n\t\tgl.activeTexture(gl.TEXTURE0);\n\t\tgl.bindTexture(gl.TEXTURE_2D, inputTexture);\n\n\t\t// Convert angle from degrees to radians for the shader\n\t\tconst angleInRadians = this.angle * (Math.PI / 180.0);\n\n\t\t// Set all the uniforms\n\t\tgl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_masterStrength'), this.masterStrength);\n\t\tgl.uniform2fv(this.program.getUniformLocation('u_amplitude'), this.amplitude);\n\t\tgl.uniform2fv(this.program.getUniformLocation('u_frequency'), this.frequency);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_time'), this.time);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_angle'), angleInRadians);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/webgl/post_processing/passes/VignettePass.ts",
    "content": "// src/client/scripts/esm/webgl/post_processing/passes/VignettePass.ts\n\nimport type { PostProcessPass } from '../PostProcessingPipeline';\nimport type { ProgramManager, ProgramMap } from '../../ProgramManager';\n\n/**\n * A post-processing pass for applying a vignette effect,\n * darkening the corners of the image.\n */\nexport class VignettePass implements PostProcessPass {\n\treadonly program: ProgramMap['vignette'];\n\n\t// --- Public Properties to Control the Effect ---\n\n\t/** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */\n\tpublic masterStrength: number = 1.0;\n\n\t/** The inner radius of the vignette, where darkening begins. Default is 0.3. */\n\tpublic radius: number = 0.3;\n\n\t/** The softness of the vignette's edge. Default is 0.5. */\n\tpublic softness: number = 0.5;\n\n\t/** The strength of the darkening effect. 1.0 is fully black. Default is 0.8. */\n\tpublic intensity: number = 0.8;\n\n\tconstructor(programManager: ProgramManager) {\n\t\tthis.program = programManager.get('vignette');\n\t}\n\n\trender(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void {\n\t\tthis.program.use();\n\n\t\tgl.activeTexture(gl.TEXTURE0);\n\t\tgl.bindTexture(gl.TEXTURE_2D, inputTexture);\n\n\t\t// Set all the uniforms\n\t\tgl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_masterStrength'), this.masterStrength);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_radius'), this.radius);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_softness'), this.softness);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_intensity'), this.intensity);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/webgl/post_processing/passes/VoronoiDistortionPass.ts",
    "content": "// src/client/scripts/esm/webgl/post_processing/passes/VoronoiDistortionPass.ts\n\nimport type { PostProcessPass } from '../PostProcessingPipeline';\nimport type { ProgramManager, ProgramMap } from '../../ProgramManager';\n\n/**\n * A post-processing pass that distorts the image based on an animated\n * Voronoi cellular noise pattern.\n */\nexport class VoronoiDistortionPass implements PostProcessPass {\n\treadonly program: ProgramMap['voronoi_distortion'];\n\n\t// --- Public Properties to Control the Effect ---\n\n\t/** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */\n\tpublic masterStrength: number = 1.0;\n\n\t/** The current time, used to animate the cells. Increment this each frame. */\n\tpublic time: number = 0.0;\n\n\t/** The density of the Voronoi cells. */\n\tpublic density: number = 3.5;\n\n\t/** The strength of the cells' distortion. */\n\tpublic strength: number = 0.007;\n\n\t/** The thickness of the ridges between cells. */\n\tpublic ridgeThickness = 0.02;\n\n\t/** The strength of the ridges' lensing effect. */\n\tpublic ridgeStrength = 0.04;\n\n\tconstructor(programManager: ProgramManager) {\n\t\tthis.program = programManager.get('voronoi_distortion');\n\t}\n\n\t// prettier-ignore\n\trender(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void {\n\t\tthis.program.use();\n\n\t\tgl.activeTexture(gl.TEXTURE0);\n\t\tgl.bindTexture(gl.TEXTURE_2D, inputTexture);\n\n\t\t// Set all the uniforms\n\t\tgl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_masterStrength'), this.masterStrength);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_time'), this.time);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_strength'), this.strength);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_density'), this.density);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_ridgeThickness'), this.ridgeThickness);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_ridgeStrength'), this.ridgeStrength);\n\t\tgl.uniform2f(this.program.getUniformLocation('u_resolution'), gl.canvas.width, gl.canvas.height);\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/webgl/post_processing/passes/WaterPass.ts",
    "content": "// src/client/scripts/esm/webgl/post_processing/passes/WaterPass.ts\n\nimport type { PostProcessPass } from '../PostProcessingPipeline';\nimport type { ProgramManager, ProgramMap } from '../../ProgramManager';\n\n/** Defines a single ripple's source point. */\nexport interface RippleSource {\n\t/** The center of the source in UV coordinates [0-1, 0-1]. */\n\tcenter: [number, number];\n}\n\n/**\n * A post-processing pass that simulates a pond-like surface with ripples\n * emanating from various source points. The ripples are radial sine waves\n * with constant intensity.\n */\nexport class WaterPass implements PostProcessPass {\n\treadonly program: ProgramMap['water'];\n\tprivate static readonly MAX_SOURCES = 10; // MUST match the shader constant\n\n\t// --- Public Properties to Control the Effect ---\n\n\t/** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */\n\tpublic masterStrength: number = 1.0;\n\n\t/** The overall strength and visibility of the distortion. */\n\tpublic strength: number = 0.001;\n\t/** How fast the waves oscillate or \"bob\" up and down, in cycles per second. */\n\tpublic oscillationSpeed: number = 8.0;\n\t/** The density of the rings in the ripple, in waves per UV unit. */\n\tpublic frequency: number = 40.0;\n\t/** The current time, used to animate the waves. Should be updated each frame. */\n\tpublic time: number = 0.0;\n\n\t// --- Internal State ---\n\tprivate activeSources: RippleSource[] = [];\n\tprivate resolution: [number, number] = [1, 1];\n\t// Pre-allocated array for performance to avoid creating new arrays every frame\n\tprivate centersArray: Float32Array = new Float32Array(WaterPass.MAX_SOURCES * 2);\n\n\t/**\n\t * Creates a new PondPass.\n\t * @param programManager - The ProgramManager instance to retrieve the shader program.\n\t * @param width - The current width of the canvas.\n\t * @param height - The current height of the canvas.\n\t */\n\tconstructor(programManager: ProgramManager, width: number, height: number) {\n\t\tthis.program = programManager.get('water');\n\t\tthis.setResolution(width, height);\n\t}\n\n\t/**\n\t * Updates the pass with the current list of active ripple sources.\n\t * Call this every frame.\n\t * @param sources An array of active source points.\n\t */\n\tpublic updateSources(sources: RippleSource[]): void {\n\t\t// Clamp the number of sources to the maximum allowed by the shader\n\t\tconst count = Math.min(sources.length, WaterPass.MAX_SOURCES);\n\t\tthis.activeSources = sources.slice(0, count);\n\t}\n\n\t/**\n\t * Informs the pass of the current rendering resolution.\n\t * This is crucial for correcting aspect ratio distortion.\n\t * Call this whenever the canvas is resized.\n\t * @param width The width of the canvas.\n\t * @param height The height of the canvas.\n\t */\n\tpublic setResolution(width: number, height: number): void {\n\t\tthis.resolution[0] = width;\n\t\tthis.resolution[1] = height;\n\t}\n\n\t// prettier-ignore\n\trender(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void {\n\t\t// --- 1. Prepare uniform data ---\n\t\tconst sourceCount = this.activeSources.length;\n\t\tfor (let i = 0; i < sourceCount; i++) {\n\t\t\tconst source = this.activeSources[i]!;\n\t\t\tthis.centersArray[i * 2 + 0] = source.center[0];\n\t\t\tthis.centersArray[i * 2 + 1] = source.center[1];\n\t\t}\n\n\t\t// --- 2. Set uniforms and render ---\n\t\tthis.program.use();\n\t\tgl.activeTexture(gl.TEXTURE0);\n\t\tgl.bindTexture(gl.TEXTURE_2D, inputTexture);\n\n\t\tgl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_masterStrength'), this.masterStrength);\n\t\tgl.uniform1i(this.program.getUniformLocation('u_sourceCount'), sourceCount);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_time'), this.time / 1000); // Convert ms to seconds\n\t\tgl.uniform2fv(this.program.getUniformLocation('u_resolution'), this.resolution);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_strength'), this.strength);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_frequency'), this.frequency);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_oscillationSpeed'), this.oscillationSpeed);\n\n\t\tif (sourceCount > 0) {\n\t\t\t// Use subarray to only send data for active sources\n\t\t\tgl.uniform2fv(this.program.getUniformLocation('u_centers'), this.centersArray.subarray(0, sourceCount * 2));\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/webgl/post_processing/passes/WaterRipplePass.ts",
    "content": "// src/client/scripts/esm/webgl/post_processing/passes/WaterRipplePass.ts\n\nimport type { PostProcessPass } from '../PostProcessingPipeline';\nimport type { ProgramManager, ProgramMap } from '../../ProgramManager';\n\n/** A simple structure to define a single droplet's state. */\nexport interface RippleState {\n\t/** The center of the droplet in UV coordinates [0-1, 0-1]. */\n\tcenter: [number, number];\n\t/** The time snapshot in millseconds the ripple was created. */\n\ttimeCreated: number;\n}\n\n/**\n * A post-processing pass that simulates multiple water droplet ripples on the screen.\n */\nexport class WaterRipplePass implements PostProcessPass {\n\treadonly program: ProgramMap['water_ripple'];\n\tprivate static readonly MAX_DROPLETS = 20; // MUST match the shader constant\n\n\t// --- Global Effect Controls ---\n\n\t/**\n\t * A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect.\n\t * HAS NO EFFECT ON THE WATER RIPPLE PASS.\n\t */\n\tpublic masterStrength: number = 1.0;\n\n\t/** The overall strength and visibility of the distortion. */\n\tpublic strength: number = 0.06; // Default: 0.06\n\t/** How fast the ripple's leading edge expands outwards, in UV units per second. */\n\tpublic propagationSpeed: number = 2.0;\n\t/** How fast the internal waves oscillate or \"bob\" up and down. */\n\tpublic oscillationSpeed: number = 40.0;\n\t/** The density of the rings in the ripple, in waves per UV unit. */\n\tpublic frequency: number = 50.0;\n\t/** How sharply the trailing waves decay. Hhigher values create a shorter tail. */\n\tpublic falloff: number = 200.0;\n\n\t/** The brightness of the white glow on the wave crests. */\n\tpublic glintIntensity: number = 0.5;\n\t/** The sharpness of the glint; higher values create a smaller, tighter highlight. */\n\tpublic glintExponent: number = 7.0;\n\n\t// --- Internal State ---\n\tprivate activeDroplets: RippleState[] = [];\n\tprivate resolution: [number, number] = [1, 1];\n\t// Pre-allocated arrays for performance to avoid creating new arrays every frame\n\tprivate centersArray: Float32Array = new Float32Array(WaterRipplePass.MAX_DROPLETS * 2);\n\tprivate timesArray: Float32Array = new Float32Array(WaterRipplePass.MAX_DROPLETS);\n\n\t/**\n\t * Creates a new WaterRipplePass.\n\t * @param programManager - The ProgramManager instance to retrieve shader programs.\n\t * @param width - The current width of the canvas.\n\t * @param height - The current height of the canvas.\n\t */\n\tconstructor(programManager: ProgramManager, width: number, height: number) {\n\t\tthis.program = programManager.get('water_ripple');\n\t\tthis.setResolution(width, height);\n\t}\n\n\t/**\n\t * Updates the pass with the current list of active droplets.\n\t * Call this every frame from your main application loop.\n\t * @param droplets An array of active droplet states.\n\t */\n\tpublic updateDroplets(droplets: RippleState[]): void {\n\t\t// Clamp the number of droplets to the maximum allowed by the shader\n\t\tconst count = Math.min(droplets.length, WaterRipplePass.MAX_DROPLETS);\n\t\tthis.activeDroplets = droplets.slice(-count); // Keep the most recent droplets\n\t}\n\n\t/**\n\t * Informs the pass of the current rendering resolution.\n\t * This is crucial for correcting aspect ratio distortion,\n\t * preventing ripples from not being circular on non-square screens.\n\t * Call this whenever the canvas is resized.\n\t * @param width The width of the canvas.\n\t * @param height The height of the canvas.\n\t */\n\tpublic setResolution(width: number, height: number): void {\n\t\tthis.resolution[0] = width;\n\t\tthis.resolution[1] = height;\n\t}\n\n\t// prettier-ignore\n\trender(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void {\n\t\tconst now = Date.now();\n\n\t\t// --- 1. Prepare uniform data ---\n\t\tconst dropletCount = this.activeDroplets.length;\n\t\tfor (let i = 0; i < dropletCount; i++) {\n\t\t\tconst droplet = this.activeDroplets[i]!;\n\t\t\tthis.centersArray[i * 2 + 0] = droplet.center[0];\n\t\t\tthis.centersArray[i * 2 + 1] = droplet.center[1];\n\t\t\tthis.timesArray[i] = (now - droplet.timeCreated) / 1000; // Convert to seconds\n\t\t}\n\n\t\t// --- 2. Set uniforms and render ---\n\t\tthis.program.use();\n\t\tgl.activeTexture(gl.TEXTURE0);\n\t\tgl.bindTexture(gl.TEXTURE_2D, inputTexture);\n\n\t\tgl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0);\n\t\tgl.uniform1i(this.program.getUniformLocation('u_dropletCount'), dropletCount);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_strength'), this.strength);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_propagationSpeed'), this.propagationSpeed);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_oscillationSpeed'), this.oscillationSpeed);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_frequency'), this.frequency);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_falloff'), this.falloff);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_glintIntensity'), this.glintIntensity);\n\t\tgl.uniform1f(this.program.getUniformLocation('u_glintExponent'), this.glintExponent);\n\t\tgl.uniform2fv(this.program.getUniformLocation('u_resolution'), this.resolution);\n\n\t\tif (dropletCount > 0) {\n\t\t\t// Use subarray to only send data for active droplets\n\t\t\tgl.uniform2fv(this.program.getUniformLocation('u_centers'), this.centersArray.subarray(0, dropletCount * 2));\n\t\t\tgl.uniform1fv(this.program.getUniformLocation('u_times'), this.timesArray.subarray(0, dropletCount));\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/client/scripts/esm/workers/icnvalidator.worker.ts",
    "content": "// src/client/scripts/esm/workers/icnvalidator.worker.ts\n\n/**\n * The web worker script for the ICN Validator Tool.\n */\n\nimport type { GameConclusion } from '../../../../shared/chess/util/winconutil.js';\n\nimport icnconverter from '../../../../shared/chess/logic/icn/icnconverter.js';\nimport { players as p, Player } from '../../../../shared/chess/util/typeutil.js';\n\nimport gameformulator from '../game/chess/gameformulator.js';\n\n// Define types\ninterface WorkerMessage {\n\tchunkId: number;\n\tgames: { index: number; icn: string }[];\n}\n\n// Listen for the main thread to send data\nself.onmessage = (e: MessageEvent<WorkerMessage>) => {\n\tconst { chunkId, games } = e.data;\n\n\tconst localResults = {\n\t\tsuccess: true,\n\t\tsuccessfulCount: 0,\n\t\ticnconverterErrors: 0,\n\t\tformulatorErrors: 0,\n\t\tillegalMoveErrors: 0,\n\t\tterminationMismatchErrors: 0,\n\t\terrors: [] as any[],\n\t\tvariantErrors: {} as Record<string, any>,\n\t};\n\n\t// Helper for variant stats\n\tconst incrementVariantError = (variantName: string, type: string): void => {\n\t\tif (!localResults.variantErrors[variantName]) {\n\t\t\tlocalResults.variantErrors[variantName] = {\n\t\t\t\ttotal: 0,\n\t\t\t\ticn: 0,\n\t\t\t\tformulator: 0,\n\t\t\t\tillegal: 0,\n\t\t\t\ttermination: 0,\n\t\t\t};\n\t\t}\n\t\tlocalResults.variantErrors[variantName].total++;\n\t\tlocalResults.variantErrors[variantName][type]++;\n\t};\n\n\t// Process the batch\n\tfor (const item of games) {\n\t\tconst { index, icn: gameICN } = item;\n\t\ttry {\n\t\t\t// Stage 1: Convert ICN to long format\n\t\t\tlet longFormat: any;\n\t\t\ttry {\n\t\t\t\tlongFormat = icnconverter.ShortToLong_Format(gameICN);\n\t\t\t} catch (error) {\n\t\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\t\tlocalResults.icnconverterErrors++;\n\t\t\t\tlocalResults.errors.push({\n\t\t\t\t\tgameIndex: index,\n\t\t\t\t\tphase: 'icnconverter',\n\t\t\t\t\terror: message,\n\t\t\t\t\ticn: gameICN,\n\t\t\t\t});\n\t\t\t\tincrementVariantError('Unknown (ICN Parse Failed)', 'icn');\n\t\t\t\tcontinue; // Move to next game\n\t\t\t}\n\n\t\t\t// Extract metadata\n\t\t\tconst variant = longFormat.metadata?.Variant || 'Unknown';\n\t\t\tconst termination = longFormat.metadata?.Termination;\n\t\t\tconst result = longFormat.metadata?.Result;\n\n\t\t\t// Stage 2: Formulate (No validation)\n\t\t\tlet game: any;\n\t\t\ttry {\n\t\t\t\tgame = gameformulator.formulateGame(longFormat);\n\t\t\t} catch (error) {\n\t\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\t\tlocalResults.formulatorErrors++;\n\t\t\t\tlocalResults.errors.push({\n\t\t\t\t\tgameIndex: index,\n\t\t\t\t\tphase: 'formulator',\n\t\t\t\t\terror: message,\n\t\t\t\t\tvariant: variant,\n\t\t\t\t\ticn: gameICN,\n\t\t\t\t});\n\t\t\t\tincrementVariantError(variant, 'formulator');\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Stage 3: Validate Moves\n\t\t\ttry {\n\t\t\t\tgameformulator.formulateGame(longFormat, true);\n\t\t\t} catch (error) {\n\t\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\t\tlocalResults.illegalMoveErrors++;\n\t\t\t\tlocalResults.errors.push({\n\t\t\t\t\tgameIndex: index,\n\t\t\t\t\tphase: 'illegal-move',\n\t\t\t\t\terror: message,\n\t\t\t\t\tvariant: variant,\n\t\t\t\t\ticn: gameICN,\n\t\t\t\t});\n\t\t\t\tincrementVariantError(variant, 'illegal');\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Stage 4: Termination Check\n\t\t\ttry {\n\t\t\t\tvalidateTermination(termination, result, game.basegame.gameConclusion);\n\t\t\t} catch (error) {\n\t\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\t\tlocalResults.terminationMismatchErrors++;\n\t\t\t\tlocalResults.errors.push({\n\t\t\t\t\tgameIndex: index,\n\t\t\t\t\tphase: 'termination-mismatch',\n\t\t\t\t\terror: message,\n\t\t\t\t\tvariant: variant,\n\t\t\t\t\ttermination: termination,\n\t\t\t\t\tresult: result,\n\t\t\t\t\tgameConclusion: game.basegame.gameConclusion,\n\t\t\t\t\ticn: gameICN,\n\t\t\t\t});\n\t\t\t\tincrementVariantError(variant, 'termination');\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// If we got here, game is valid\n\t\t\tlocalResults.successfulCount++;\n\t\t} catch (error) {\n\t\t\t// Unexpected\n\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\tlocalResults.formulatorErrors++;\n\t\t\tlocalResults.errors.push({\n\t\t\t\tgameIndex: index,\n\t\t\t\tphase: 'unknown',\n\t\t\t\terror: message,\n\t\t\t\ticn: gameICN,\n\t\t\t});\n\t\t}\n\n\t\t// Report progress every 50 games (optional optimization to keep UI responsive)\n\t\tif (localResults.successfulCount % 10 === 0) {\n\t\t\tself.postMessage({ type: 'progress', chunkId, count: 10 });\n\t\t}\n\t}\n\n\t// Send final results for this chunk\n\tself.postMessage({ type: 'done', chunkId, results: localResults });\n};\n\n// --- Helper Logic ---\n\nfunction validateTermination(\n\ttermination: string | undefined,\n\tresult: string | undefined,\n\tgameConclusion: GameConclusion | undefined,\n): void {\n\tif (termination === 'Maximum moves reached') {\n\t\tif (gameConclusion !== undefined)\n\t\t\tthrow new Error(\n\t\t\t\t`Termination is \"Maximum moves reached\" but game is over: ${JSON.stringify(gameConclusion)}`,\n\t\t\t);\n\t\treturn;\n\t}\n\tif (termination && termination.startsWith('Material adjudication')) {\n\t\tif (gameConclusion !== undefined)\n\t\t\tthrow new Error(\n\t\t\t\t`Termination is Material Adjudication, but game is over: ${JSON.stringify(gameConclusion)}`,\n\t\t\t);\n\t\treturn;\n\t}\n\tif (gameConclusion === undefined) {\n\t\tif (termination)\n\t\t\tthrow new Error(`Game isn't over, but Termination is specified: \"${termination}\"`);\n\t\treturn;\n\t}\n\n\tconst { victor, condition } = gameConclusion;\n\n\tconst conditionMappings: Record<string, string> = {\n\t\tCheckmate: 'checkmate',\n\t\t'All pieces captured': 'allpiecescaptured',\n\t\tStalemate: 'stalemate',\n\t\t'Threefold repetition': 'repetition',\n\t\t'50-move rule': 'moverule',\n\t\t'Insufficient material': 'insuffmat',\n\t};\n\n\tif (termination && termination in conditionMappings) {\n\t\tif (condition !== conditionMappings[termination])\n\t\t\tthrow new Error(`Game is over by ${condition}, but Termination is \"${termination}\"`);\n\t} else if (termination) {\n\t\tthrow new Error(`Disallowed Termination metadata: \"${termination}\"`);\n\t}\n\n\tif (victor !== undefined && result) {\n\t\tconst resultMappings: Record<string, Player | null> = {\n\t\t\t'1-0': p.WHITE,\n\t\t\t'0-1': p.BLACK,\n\t\t\t'1/2-1/2': null,\n\t\t};\n\t\tif (result in resultMappings) {\n\t\t\tif (victor !== resultMappings[result])\n\t\t\t\tthrow new Error(`Result \"${result}\" does not match victor ${victor}`);\n\t\t} else {\n\t\t\tthrow new Error(`Unknown Result metadata: \"${result}\"`);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/client/shaders/arrow_images/fragment.glsl",
    "content": "#version 300 es\r\n\r\nprecision highp float;\r\n\r\nin vec2 vTextureCoord;          // From vertex shader\r\nin vec4 vInstanceColor;         // From vertex shader\r\n\r\nuniform sampler2D u_sampler;     // Texture sampler\r\n\r\nout vec4 fragColor;             // Output color\r\n\r\nvoid main() {\r\n    // Sample texture with LOD bias for sharpness\r\n    vec4 texColor = texture(u_sampler, vTextureCoord, -0.5);\r\n    fragColor = texColor * vInstanceColor;\r\n}"
  },
  {
    "path": "src/client/shaders/arrow_images/vertex.glsl",
    "content": "#version 300 es\r\n\r\nin vec4 a_position;        // Per-vertex position (vec4 for homogeneous coordinates)\r\nin vec2 a_texturecoord;          // Per-vertex texture coordinates\r\nin vec3 a_instanceposition;      // Per-instance position offset (vec3: xyz)\r\nin vec4 a_instancecolor;         // Per-instance color (RGBA)\r\n\r\nuniform mat4 u_transformmatrix;  // Transformation matrix\r\n\r\nout vec2 vTextureCoord;         // To fragment shader\r\nout vec4 vInstanceColor;        // To fragment shader\r\n\r\nvoid main() {\r\n    // Apply instance position offset\r\n    vec4 offsetPosition = a_position + vec4(a_instanceposition, 0.0);\r\n    \r\n    // Transform position and pass through texture coords\r\n    gl_Position = u_transformmatrix * offsetPosition;\r\n    \r\n    // Pass texture coords and instance color to fragment shader\r\n    vTextureCoord = a_texturecoord;\r\n    vInstanceColor = a_instancecolor;\r\n}"
  },
  {
    "path": "src/client/shaders/arrows/vertex.glsl",
    "content": "#version 300 es\r\n\r\nin vec4 a_position;\r\nin vec3 a_instanceposition; // Instance position offset (vec3: xyz)\r\nin vec4 a_instancecolor;    // Instance color (vec4: rgba)\r\nin float a_instancerotation; // Instance rotation (float: radians)\r\n\r\nuniform mat4 u_transformmatrix;\r\n\r\nout vec4 vColor;\r\n\r\nvoid main() {\r\n    // Create rotation matrix\r\n    float cosA = cos(a_instancerotation);\r\n    float sinA = sin(a_instancerotation);\r\n    mat2 rotMat = mat2(cosA, sinA, -sinA, cosA);\r\n    \r\n    // Rotate vertex position\r\n    vec2 rotated = rotMat * a_position.xy;\r\n    vec3 rotatedPosition = vec3(rotated, a_position.z);\r\n    \r\n    // Add instance position offset\r\n    vec3 finalPosition = rotatedPosition + a_instanceposition;\r\n    \r\n    gl_Position = u_transformmatrix * vec4(finalPosition, 1.0);\r\n    vColor = a_instancecolor;\r\n}"
  },
  {
    "path": "src/client/shaders/board_uber_shader/fragment.glsl",
    "content": "#version 300 es\nprecision highp float;\n\n// src/client/shaders/board_uber_shader/fragment.glsl\n\n// GLOBAL UNIFORMS (May be used by several effects)\nuniform sampler2D u_colorTexture;\nuniform sampler2D u_maskTexture; // This texture has white pixels where light tiles are and black pixels where dark tiles are.\nuniform sampler2D u_perlinNoiseTexture;\nuniform sampler2D u_whiteNoiseTexture;\nuniform vec2 u_resolution; // Canvas dimensions, used for aspect correction\nuniform float u_pixelDensity; // How many device pixels per virtual pixel\n\n// The integers representing the unique id of effect types A & B this frame.\nuniform float u_effectTypeA;\nuniform float u_effectTypeB;\n\n// The master blend factor between the 'A' and 'B' effect slots.\nuniform float u_transitionProgress;\n\n\n// Spectral Edge Uniforms (Effect Type 4)\nuniform float u4_flowDistance;\nuniform vec2 u4_flowDirectionVec;\nuniform float u4_gradientRepeat;\nuniform float u4_maskOffset;\nuniform float u4_strength;\nuniform vec3 u4_color1;\nuniform vec3 u4_color2;\nuniform vec3 u4_color3;\nuniform vec3 u4_color4;\nuniform vec3 u4_color5;\nuniform vec3 u4_color6;\n\n// Iridescence Uniforms (Effect Type 5)\nuniform float u5_flowDistance;\nuniform vec2 u5_flowDirectionVec;\nuniform float u5_gradientRepeat;\nuniform float u5_maskOffset;\nuniform float u5_strength;\nuniform vec3 u5_color1;\nuniform vec3 u5_color2;\nuniform vec3 u5_color3;\nuniform vec3 u5_color4;\nuniform vec3 u5_color5;\nuniform vec3 u5_color6;\n\n// Ember Verge Uniforms (Effect Type 11)\nuniform float u11_flowDistance;\nuniform vec2 u11_flowDirectionVec;\nuniform float u11_gradientRepeat;\nuniform float u11_maskOffset;\nuniform float u11_strength;\nuniform vec3 u11_color1;\nuniform vec3 u11_color2;\nuniform vec3 u11_color3;\nuniform vec3 u11_color4;\nuniform vec3 u11_color5;\nuniform vec3 u11_color6;\n\n// Dusty Wastes Uniforms (Effect Type 6)\nuniform float u6_strength; // The opacity of the scrolling noise texture\nuniform float u6_noiseTiling; // How many times the noise texture repeats across the screen\nuniform vec2 u6_uvOffset1; // The texture offset for noise layer 1 (calculated cpu side for more control)\nuniform vec2 u6_uvOffset2; // The texture offset for noise layer 2 (calculated cpu side for more control)\n\n// Static Zone Uniforms (Effect Type 7)\nuniform float u7_strength; // The opacity of the white noise pixels\nuniform vec2 u7_uvOffset; // The texture offset for the white noise (calculated cpu side for more control)\nuniform float u7_pixelWidth; // How many pixels wide the white noise texture is\nuniform float u7_pixelSize; // How many virtual pixels wide each static pixel should be\n\n\n// INPUTS\nin vec2 v_uv;           // The model's original UVs for color/mask\nin vec4 v_screenCoord;  // The screen-space coordinate for the noise\nin vec4 v_color;\n\nout vec4 out_color;\n\n\n// Helper function to get a color from a procedural gradient.\nvec3 getColorFromRamp(float coord, vec3 color1, vec3 color2, vec3 color3, vec3 color4, vec3 color5, vec3 color6) {\n    vec3 color = u5_color1;\n\n    // Scale coord by the number of colors to create N segments,\n    // allowing the last segment to wrap back to the first.\n\tfloat NUM_COLORS = 6.0;\n    float scaledCoord = coord * NUM_COLORS;\n    int index = int(floor(scaledCoord));\n    float blendFactor = fract(scaledCoord);\n\n    // This chain of if-statements acts as an array lookup.\n    if (index == 0) color = mix(color1, color2, blendFactor);\n    else if (index == 1) color = mix(color2, color3, blendFactor);\n    else if (index == 2) color = mix(color3, color4, blendFactor);\n    else if (index == 3) color = mix(color4, color5, blendFactor);\n    else if (index == 4) color = mix(color5, color6, blendFactor);\n    else if (index == 5) color = mix(color6, color1, blendFactor); // Wrap back to the first\n\n    return color;\n}\n\n// Applies a color gradient flow procedural gradient effect.\nvec3 ColorFlow(\n    // --- Input values ---\n    vec3 baseColor,\n    vec2 screenUV,\n\tfloat maskValue,\n    // --- Effect parameters ---\n    float flowDistance,\n    vec2 flowDirectionVec,\n    float gradientRepeat,\n    float maskOffset,\n    float strength,\n\t// --- Color stops ---\n\tvec3 color1,\n\tvec3 color2,\n\tvec3 color3,\n\tvec3 color4,\n\tvec3 color5,\n\tvec3 color6\n) {\n\t// Project the screen UV onto the flow direction vector to get a 1D coordinate.\n\tfloat projectedUv = dot(screenUV, flowDirectionVec);\n\n\t// Add the scrolled distance, apply the repeat factor, and apply the mask offset.\n\tfloat phase = (projectedUv * gradientRepeat) + flowDistance + (maskValue * maskOffset);\n\n\t// Get the final wrapped coordinate for the color lookup.\n\tfloat gradientCoord = fract(phase);\n\n\t// Get the procedural color from our ramp.\n\tvec3 gradientColor = getColorFromRamp(gradientCoord, color1, color2, color3, color4, color5, color6);\n\n\t// Blend the gradient color with the base tile color.\n\treturn mix(baseColor, gradientColor, strength);\n}\n\n// Applies the \"Dusty Wastes\" animated noise effect.\nvec3 DustyWastes(\n\t// --- Input values ---\n\tvec3 baseColor,\n\tvec2 screenUV\n) {\n\tconst float NOISE_MULTIPLIER = 1.0; // Default: 1.13   Affects average final brightness to more closely match the original texture color\n\n    // Apply the pre-calculated offsets.\n\tvec2 uv1 = screenUV * u6_noiseTiling + u6_uvOffset1;\n\tvec2 uv2 = screenUV * u6_noiseTiling + u6_uvOffset2;\n\n\tfloat noise1 = texture(u_perlinNoiseTexture, uv1).r;\n\tfloat noise2 = texture(u_perlinNoiseTexture, uv2).r;\n\n\tfloat finalNoise = noise1 * noise2 * NOISE_MULTIPLIER;\n\tfloat signedNoise = (finalNoise * 2.0) - 1.0;\n\t\n\treturn baseColor + (signedNoise * u6_strength);\n}\n\n// Applies the \"Static\" pixelated noise effect.\nvec3 Static(\n    vec3 baseColor,\n    vec2 screenUV\n) {\n\t// vec2 snappedUV = floor((screenUV * u_resolution) / u7_pixelSize) * u7_pixelSize / u_resolution + u7_uvOffset;\n    vec2 snappedUV = screenUV * u_resolution[1] / u7_pixelWidth / u7_pixelSize / u_pixelDensity + u7_uvOffset;\n    float noise = texture(u_whiteNoiseTexture, snappedUV).r;\n    float signedNoise = (noise * 2.0) - 1.0;\n    return baseColor + (signedNoise * u7_strength); // Apply a brightness/darkness effect\n}\n\n// Switchboard. Takes an effect type and returns the result at full strength.\nvec3 calculateEffectColor(\n\tfloat effectType,\n\tvec3 baseColor,\n\tvec2 screenUV,\n    float maskValue\n) {\n\tif (effectType == 4.0) {\n\t\treturn ColorFlow(\n\t\t\tbaseColor,\n\t\t\tscreenUV,\n\t\t\tmaskValue,\n\t\t\t// Pass effect-specific uniforms\n\t\t\tu4_flowDistance,\n\t\t\tu4_flowDirectionVec,\n\t\t\tu4_gradientRepeat,\n\t\t\tu4_maskOffset,\n\t\t\tu4_strength,\n\t\t\t// Color stops\n\t\t\tu4_color1,\n\t\t\tu4_color2,\n\t\t\tu4_color3,\n\t\t\tu4_color4,\n\t\t\tu4_color5,\n\t\t\tu4_color6\n\t\t);\n\t} else if (effectType == 5.0) {\n\t\treturn ColorFlow(\n\t\t\tbaseColor,\n\t\t\tscreenUV,\n\t\t\tmaskValue,\n            // Pass effect-specific uniforms\n            u5_flowDistance,\n            u5_flowDirectionVec,\n            u5_gradientRepeat,\n            u5_maskOffset,\n            u5_strength,\n\t\t\t// Color stops\n\t\t\tu5_color1,\n\t\t\tu5_color2,\n\t\t\tu5_color3,\n\t\t\tu5_color4,\n\t\t\tu5_color5,\n\t\t\tu5_color6\n\t\t);\n\t} else if (effectType == 11.0) {\n\t\treturn ColorFlow(\n\t\t\tbaseColor,\n\t\t\tscreenUV,\n\t\t\tmaskValue,\n            // Pass effect-specific uniforms\n            u11_flowDistance,\n            u11_flowDirectionVec,\n            u11_gradientRepeat,\n            u11_maskOffset,\n            u11_strength,\n\t\t\t// Color stops\n\t\t\tu11_color1,\n\t\t\tu11_color2,\n\t\t\tu11_color3,\n\t\t\tu11_color4,\n\t\t\tu11_color5,\n\t\t\tu11_color6\n\t\t);\n\t} else if (effectType == 6.0) {\n\t\treturn DustyWastes(\n\t\t\tbaseColor,\n\t\t\tscreenUV\n\t\t);\n\t} else if (effectType == 7.0) {\n        return Static(\n            baseColor,\n            screenUV\n        );\n    }\n\n\t// Default case: no effect\n\treturn baseColor;\n}\n\n\nvoid main() {\n\t// INITIAL SETUP\n\n\tvec4 baseColor = texture(u_colorTexture, v_uv) * v_color;\n\tfloat maskValue = texture(u_maskTexture, v_uv).r;\n\n    // Normalize coordinates and adjust for aspect ratio\n    vec2 screenUV = gl_FragCoord.xy / u_resolution.xy;\n    float aspect_ratio = u_resolution.x / u_resolution.y;\n    screenUV.x *= aspect_ratio;\n\n\t// UBER-SHADER LOGIC\n\n\t// 1. Calculate the result for Slot A at full strength.\n\tvec3 modulatedColorA = calculateEffectColor(u_effectTypeA, baseColor.rgb, screenUV, maskValue);\n\n\t// 2. Calculate the result for Slot B at full strength.\n\tvec3 modulatedColorB = calculateEffectColor(u_effectTypeB, baseColor.rgb, screenUV, maskValue);\n\n\t// 3. Smoothly blend between the full results of the two slots.\n\tvec3 blendedModulatedColor = mix(modulatedColorA, modulatedColorB, u_transitionProgress);\n\n\t// 4. The final blended color is now applied directly to the whole tile.\n\t// The mask is only used internally by effects that need it (like color flow).\n\tout_color = vec4(clamp(blendedModulatedColor, 0.0, 1.0), baseColor.a);\n}"
  },
  {
    "path": "src/client/shaders/board_uber_shader/vertex.glsl",
    "content": "#version 300 es\n\n// INPUTS\nin vec3 a_position;\nin vec2 a_texturecoord;\nin vec4 a_color;\n\nuniform mat4 u_transformmatrix;\n\n// OUTPUTS\nout vec2 v_uv;\nout vec4 v_screenCoord; // Crucial for screen-space effects\nout vec4 v_color; // Color is needed for transparency of bigger boards\n\nvoid main() {\n\tgl_Position = u_transformmatrix * vec4(a_position, 1.0);\n\tv_uv = a_texturecoord;\n\tv_screenCoord = gl_Position;\n\tv_color = a_color;\n}"
  },
  {
    "path": "src/client/shaders/color/fragment.glsl",
    "content": "#version 300 es\nprecision highp float;\n\nin vec4 vColor;\nout vec4 fragColor;\n\nvoid main() {\n\tfragColor = vColor;\n}"
  },
  {
    "path": "src/client/shaders/color/instanced/vertex.glsl",
    "content": "#version 300 es\r\n\r\nin vec4 a_position;\r\nin vec4 a_color;\r\nin vec4 a_instanceposition; // Per-instance position offset attribute\r\n\r\nuniform mat4 u_transformmatrix;\r\n\r\nout vec4 vColor;\r\n\r\nvoid main() {\r\n    // Add the instance offset to the vertex position\r\n    vec4 transformedVertexPosition = vec4(a_position.xyz + a_instanceposition.xyz, 1.0);\r\n\r\n    gl_Position = u_transformmatrix * transformedVertexPosition;\r\n    vColor = a_color;\r\n}"
  },
  {
    "path": "src/client/shaders/color/vertex.glsl",
    "content": "#version 300 es\n\nin vec4 a_position;\nin vec4 a_color;\n\nuniform mat4 u_transformmatrix;\n\nout vec4 vColor;\n\nvoid main() {\n\tgl_Position = u_transformmatrix * a_position;\n\tvColor = a_color;\n}"
  },
  {
    "path": "src/client/shaders/color_grade/fragment.glsl",
    "content": "#version 300 es\r\nprecision highp float;\r\n\r\n// --- UNIFORMS ---\r\nuniform sampler2D u_sceneTexture;\r\n\r\nuniform float u_masterStrength; // 0.0 = no effect, 1.0 = full effect\r\nuniform float u_brightness; // 0.0 is no change\r\nuniform float u_contrast;   // 1.0 is no change\r\nuniform float u_gamma;      // 1.0 is no change\r\nuniform float u_saturation; // 1.0 is no change, 0.0 = grayscale\r\nuniform vec3 u_tintColor;   // vec3(1.0) is no change\r\nuniform float u_hueOffset;  // 0.0 is no change (0.0 to 1.0)\r\n\r\nin vec2 v_uv;\r\nout vec4 out_color;\r\n\r\n// --- CONSTANTS ---\r\n// These are standard weights for calculating luminance, based on human eye perception.\r\nconst vec3 LUMINANCE_VECTOR = vec3(0.2126, 0.7152, 0.0722);\r\n\r\n// --- HELPER FUNCTIONS for Hue Shift ---\r\n// Converts RGB color space to HSV color space\r\nvec3 rgb2hsv(vec3 c) {\r\n    vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);\r\n    vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));\r\n    vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));\r\n    float d = q.x - min(q.w, q.y);\r\n    float e = 1.0e-10;\r\n    return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);\r\n}\r\n\r\n// Converts HSV color space to RGB color space\r\nvec3 hsv2rgb(vec3 c) {\r\n    vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);\r\n    vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);\r\n    return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);\r\n}\r\n\r\nvoid main() {\r\n\t// Start with the original color from the scene\r\n\tvec4 originalColor = texture(u_sceneTexture, v_uv);\r\n\tvec4 processedColor = texture(u_sceneTexture, v_uv);\r\n\r\n\t// --- ORDER OF OPERATIONS ---\r\n\r\n\t// 1. Apply Contrast\r\n\tprocessedColor.rgb = (processedColor.rgb - 0.5) * u_contrast + 0.5;\r\n\t\r\n\t// 2. Apply Brightness\r\n\tprocessedColor.rgb += u_brightness;\r\n\r\n\t// 3. Apply Gamma Correction\r\n\t// We use 1.0 / gamma which is the standard for gamma correction.\r\n\t// Use max() to ensure the input to pow() is never negative, preventing NaN errors.\r\n\tprocessedColor.rgb = pow(max(processedColor.rgb, 0.0), vec3(1.0 / u_gamma));\r\n\r\n\t// 4. Apply Saturation\r\n\t// Calculate the grayscale value using the luminance vector.\r\n\t// The dot product is a fast way to do (r*0.2126 + g*0.7152 + b*0.0722).\r\n\tfloat luminance = dot(processedColor.rgb, LUMINANCE_VECTOR);\r\n\tvec3 grayscale = vec3(luminance);\r\n\t// Blend between the grayscale processed color and the original color.\r\n\t// mix() is a built-in GLSL function for linear interpolation.\r\n\tprocessedColor.rgb = mix(grayscale, processedColor.rgb, u_saturation);\r\n\r\n\t// 5. Apply Tint\r\n\tprocessedColor.rgb *= u_tintColor;\r\n\r\n\t// 6. Apply Hue Shift\r\n\tvec3 hsv = rgb2hsv(processedColor.rgb);\r\n\thsv.x += u_hueOffset;\r\n\thsv.x = fract(hsv.x); // Wrap the hue value around (0.0 to 1.0)\r\n\tprocessedColor.rgb = hsv2rgb(hsv);\r\n\r\n\t// Clamp the processed color to ensure it's in the valid 0.0-1.0 range\r\n\tprocessedColor.rgb = clamp(processedColor.rgb, 0.0, 1.0);\r\n\r\n\t// Apply Master Strength\r\n\t// Blend between the original color and the fully processed color\r\n\tvec3 finalRgb = mix(originalColor.rgb, processedColor.rgb, u_masterStrength);\r\n\t\r\n\tout_color = vec4(finalRgb, originalColor.a); // Preserve original alpha\r\n}"
  },
  {
    "path": "src/client/shaders/color_texture/fragment.glsl",
    "content": "#version 300 es\r\n\r\nprecision highp float;\r\n\r\nin vec2 vTextureCoord;\r\nin vec4 vColor;\r\n\r\nuniform sampler2D u_sampler;\r\n\r\nout vec4 fragColor;\r\n\r\nvoid main(void) {\r\n    fragColor = texture(u_sampler, vTextureCoord, -0.5) * vColor; // Apply a mipmap LOD bias so as to make the textures sharper.\r\n}"
  },
  {
    "path": "src/client/shaders/color_texture/vertex.glsl",
    "content": "#version 300 es\r\n\r\nin vec4 a_position;\r\nin vec2 a_texturecoord;\r\nin vec4 a_color;\r\n\r\nuniform mat4 u_transformmatrix;\r\n\r\nout vec2 vTextureCoord;\r\nout vec4 vColor;\r\n\r\nvoid main(void) {\r\n    gl_Position = u_transformmatrix * a_position;\r\n    vTextureCoord = a_texturecoord;\r\n    vColor = a_color;\r\n}"
  },
  {
    "path": "src/client/shaders/fullscreen_colorflow/fragment.glsl",
    "content": "#version 300 es\nprecision highp float;\n\n// This shader is used by ColorFlowRenderer.ts to render a fullscreen\n// color flow effect in the background of the chess game, replacing the starfield.\n// This is only used occasionally for obtaining cool video footage.\n\n// --- Uniforms ---\nuniform vec2 u_resolution;\nuniform float u_flowDistance;       // Equivalent to time * speed\nuniform vec2 u_flowDirectionVec;    // Calculated cos/sin vector\nuniform float u_gradientRepeat;     // How dense the rainbow is\nuniform float u_alpha;              // Master opacity\n\n// The 6-stop gradient colors\nuniform vec3 u_colors[6];\n\nout vec4 fragColor;\n\n// Linearly interpolates between 6 colors based on a 0-1 t value\nvec3 getColorFromRamp(float t) {\n    float scaledT = t * 6.0;\n    int index = int(floor(scaledT));\n    float blend = fract(scaledT);\n\n    // Handle wrapping\n    int nextIndex = (index + 1) % 6;\n\n    // In WebGL2 we can index arrays dynamically\n    // Note: We clamp index to avoid any precision issues at exactly 1.0\n    if (index >= 6) index = 0;\n    \n    return mix(u_colors[index], u_colors[nextIndex], blend);\n}\n\nvoid main() {\n    // 1. Normalized UV Coordinates with Aspect Ratio Correction\n    vec2 uv = gl_FragCoord.xy / u_resolution;\n    float aspect = u_resolution.x / u_resolution.y;\n    uv.x *= aspect;\n\n    // 2. Project UV onto the flow vector\n    // This creates the linear \"river\" direction\n    float projectedUv = dot(uv, u_flowDirectionVec);\n\n    // 3. Calculate Phase\n    // (projected position * density) + animation offset\n    float phase = (projectedUv * u_gradientRepeat) + u_flowDistance;\n\n    // 4. Wrap for gradient lookup (0.0 to 1.0)\n    float gradientCoord = fract(phase);\n\n    // 5. Sample Color\n    vec3 finalColor = getColorFromRamp(gradientCoord);\n\n    fragColor = vec4(finalColor, u_alpha);\n}"
  },
  {
    "path": "src/client/shaders/glitch/fragment.glsl",
    "content": "#version 300 es\nprecision highp float;\n\n// src/client/shaders/glitch/fragment.glsl\n\nuniform sampler2D u_sceneTexture;\n\n// --- Master Strength ---\nuniform float u_masterStrength; // 0.0 = no effect, 1.0 = full effect\n\n// --- Chromatic Aberration Uniforms ---\nuniform float u_aberrationStrength;\nuniform vec2 u_aberrationOffset; // Direction and magnitude of the color channel separation\n\n// --- Horizontal Tearing Uniforms ---\nuniform float u_tearStrength;\nuniform float u_tearResolution; // Height of tear lines in virtual CSS pixels (e.g., 5.0 for 5px high lines)\nuniform float u_tearMaxDisplacement; // Max horizontal shift for a tear in virtual CSS pixels\nuniform float u_time; // For animating tear patterns\nuniform vec2 u_resolution; // Viewport resolution (width, height) in pixels\nuniform float u_devicePixelRatio;\n\nin vec2 v_uv;\nout vec4 out_color;\n\nvoid main() {\n    vec4 originalColor = texture(u_sceneTexture, v_uv);\n    vec2 texCoord = v_uv;\n\n    // --- Horizontal Tearing ---\n    // Calculate a unique tear offset for this scanline based on its Y coordinate and time\n    // Convert u_tearResolution (pixels) to UV space height of a tear line\n    // Convert u_tearMaxDisplacement (pixels) to UV space horizontal displacement\n    float tearLineHeightUV = u_tearResolution * u_devicePixelRatio / u_resolution.y; \n    float tearMaxDisplacementUV = u_tearMaxDisplacement * u_devicePixelRatio / u_resolution.x;\n\n    // Determine which \"tear line\" this pixel belongs to\n    float lineIndex = floor(v_uv.y / tearLineHeightUV); \n\n    // Use a quantized time for a less fluid, more 'jerky' animation\n    float quantizedTime = floor(u_time * 20.0) / 20.0; // Adjust 20.0 for desired 'steps' per second\n\n    // Generate a pseudo-random value for displacement per line, varying with quantized time\n    // This replaces drawing from a noise texture\n    float randomOffset = fract(sin(lineIndex * 123.456 + quantizedTime * 789.0) * 4567.89); // Example magic numbers\n\n    // Map randomOffset (0-1) to desired displacement range (-tearMaxDisplacementUV to +tearMaxDisplacementUV)\n    float tearOffset = (randomOffset * 2.0 - 1.0) * tearMaxDisplacementUV;\n\n    // Determine direction based on lineIndex: every other line shifts opposite\n    float direction = mix(1.0, -1.0, mod(lineIndex, 2.0)); \n\n    // Apply the tear offset, scaled by tearStrength and direction\n    texCoord.x += tearOffset * direction * u_tearStrength;\n\n    // --- Chromatic Aberration ---\n    // Sample the red, green, and blue channels with different offsets\n    vec4 color;\n    color.r = texture(u_sceneTexture, texCoord + u_aberrationOffset * u_aberrationStrength).r;\n    color.g = texture(u_sceneTexture, texCoord).g;\n    color.b = texture(u_sceneTexture, texCoord - u_aberrationOffset * u_aberrationStrength).b;\n    color.a = texture(u_sceneTexture, texCoord).a; // Keep alpha as is\n\n    // Get the fully distorted color\n    vec4 distortedColor = color; // 'color' already contains the combined aberration and tear effects\n\n\t// Blend between original and distorted color using master strength\n\tout_color = mix(originalColor, distortedColor, u_masterStrength);\n}"
  },
  {
    "path": "src/client/shaders/heat_wave/fragment.glsl",
    "content": "#version 300 es\nprecision highp float;\n\nuniform sampler2D u_sceneTexture;\nuniform sampler2D u_noiseTexture;\n\nuniform float u_masterStrength; // 0.0 = no effect, 1.0 = full effect\nuniform float u_time;\nuniform float u_strength;\nuniform vec2 u_resolution; // Canvas dimensions\n\nin vec2 v_uv;\nout vec4 out_color;\n\nvoid main() {\n\t// Store the original, unaffected color\n\tvec4 originalColor = texture(u_sceneTexture, v_uv);\n\n\t// Aspect ratio correction\n    float aspectRatio = u_resolution.x / u_resolution.y;\n    vec2 noiseBaseUV = v_uv;\n    noiseBaseUV.x *= aspectRatio;\n\n    // Create two different scrolling UVs for the noise texture.\n    // They scroll at different speeds and in different directions.\n\tvec2 noiseUV1 = vec2(noiseBaseUV.x - u_time * 0.05, noiseBaseUV.y - u_time * 0.2);\n\tvec2 noiseUV2 = vec2(noiseBaseUV.x + u_time * 0.03, noiseBaseUV.y + u_time * 0.13);\n\n    // Sample the noise texture at both locations.\n    float noise1 = texture(u_noiseTexture, noiseUV1).r;\n    float noise2 = texture(u_noiseTexture, noiseUV2).r;\n\n    // Calculate the distortion from the *difference* between the two samples.\n    float distortion = (noise1 - noise2);\n\n    // Calculate the horizontal offset in UV space. Resolution-independent.\n    float horizontalOffset = distortion * u_strength;\n    // Correct the offset for the screen's aspect ratio.\n    // Results in consistent pixel displacement regardless of screen width.\n    horizontalOffset /= aspectRatio;\n\n    // Create the final distorted UVs for the scene texture.\n\tvec2 distortedUV = vec2(v_uv.x + horizontalOffset, v_uv.y);\n\n\t// Sample the scene using the distorted coordinates.\n\tvec4 distortedColor = texture(u_sceneTexture, distortedUV);\n\n\t// Blend between original and distorted color using master strength\n\tout_color = mix(originalColor, distortedColor, u_masterStrength);\n}"
  },
  {
    "path": "src/client/shaders/highlights/vertex.glsl",
    "content": "#version 300 es\r\n\r\nin vec4 a_position;     // Base shape vertex position (e.g., from -0.5 to 0.5)\r\nin vec4 a_color;        // Base shape vertex color\r\nin vec3 a_instanceposition;   // Per-instance position offset (center of the shape)\r\n\r\nuniform mat4 u_transformmatrix; // Combined model-view-projection matrix\r\nuniform float u_size;    // Desired size multiplier of the shape (scales a_position)\r\n\r\nout vec4 vColor;             // Pass color to fragment shader\r\n\r\nvoid main() {\r\n    // Scale the base vertex position's X and Y by the shape width.\r\n    // Assumes Z is 0 or handled appropriately, W is 1 for position.\r\n    vec3 scaledLocalPosition = vec3(a_position.xy * u_size, a_position.z);\r\n\r\n    // Add the instance-specific position offset to the scaled local position.\r\n    vec3 finalPosition = scaledLocalPosition + a_instanceposition;\r\n\r\n    // Transform the final position.\r\n    gl_Position = u_transformmatrix * vec4(finalPosition, 1.0);\r\n\r\n    // Pass the vertex color through.\r\n    vColor = a_color;\r\n}"
  },
  {
    "path": "src/client/shaders/mini_images/fragment.glsl",
    "content": "#version 300 es\n\nprecision highp float;\n\nin vec2 vTextureCoord;          // Interpolated texture coordinate from vertex shader\nin vec4 vColor;                 // Interpolated vertex color from vertex shader\n\nuniform sampler2D u_sampler;     // Texture sampler\n\nout vec4 fragColor;             // Output fragment color\n\nvoid main() {\n    // Sample the texture with LOD bias for sharpness\n    vec4 texColor = texture(u_sampler, vTextureCoord, -0.5);\n\n    // Multiply the texture color by the vertex color\n    fragColor = texColor * vColor;\n}"
  },
  {
    "path": "src/client/shaders/mini_images/vertex.glsl",
    "content": "#version 300 es\n\nin vec4 a_position;        // Per-vertex position\nin vec2 a_texturecoord;          // Per-vertex texture coordinate\nin vec4 a_color;           // Per-vertex color\nin vec3 a_instanceposition;      // Per-instance position offset\n\nuniform mat4 u_transformmatrix;  // Transformation matrix\nuniform float u_size;    // Desired size multiplier of the shape (scales a_position)\n\nout vec2 vTextureCoord;         // Pass texture coord to fragment shader\nout vec4 vColor;                // Pass vertex color to fragment shader\n\nvoid main() {\n    // Scale the base vertex position's X and Y by the shape width.\n    // Assumes Z is 0 or handled appropriately, W is 1 for position.\n    vec3 scaledLocalPosition = vec3(a_position.xy * u_size, a_position.z);\n\n    // Apply instance position offset to the base vertex position\n    vec3 finalPosition = scaledLocalPosition + a_instanceposition;\n\n    // Transform the final position\n    gl_Position = u_transformmatrix * vec4(finalPosition, 1.0);\n\n    // Pass texture coordinates and vertex color to the fragment shader\n    vTextureCoord = a_texturecoord;\n    vColor = a_color;\n}"
  },
  {
    "path": "src/client/shaders/post_pass/fragment.glsl",
    "content": "#version 300 es\r\nprecision highp float;\r\n\r\n// The texture containing our rendered scene.\r\nuniform sampler2D u_sceneTexture;\r\n\r\n// The UV coordinates passed from the vertex shader.\r\nin vec2 v_uv;\r\n\r\n// The output color for this pixel.\r\nout vec4 out_color;\r\n\r\nvoid main() {\r\n\t// Simply sample the texture at the given UV coordinate and output the color.\r\n\t// This is a \"pass-through\" shader.\r\n\tout_color = texture(u_sceneTexture, v_uv);\r\n}"
  },
  {
    "path": "src/client/shaders/post_pass/vertex.glsl",
    "content": "#version 300 es\r\n\r\n// A simple quad that covers the entire screen in Normalized Device Coordinates.\r\nconst vec2 positions[6] = vec2[](\r\n\tvec2(-1.0, -1.0),\r\n\tvec2( 1.0, -1.0),\r\n\tvec2(-1.0,  1.0),\r\n\tvec2(-1.0,  1.0),\r\n\tvec2( 1.0, -1.0),\r\n\tvec2( 1.0,  1.0)\r\n);\r\n\r\n// We need to pass the UV coordinates to the fragment shader.\r\n// They are derived from the vertex positions.\r\nout vec2 v_uv;\r\n\r\nvoid main() {\r\n\tgl_Position = vec4(positions[gl_VertexID], 0.0, 1.0);\r\n\t// Convert NDC position to UV coordinates (0.0 to 1.0)\r\n\tv_uv = gl_Position.xy * 0.5 + 0.5;\r\n}"
  },
  {
    "path": "src/client/shaders/sine_wave/fragment.glsl",
    "content": "#version 300 es\r\nprecision highp float;\r\n\r\nuniform sampler2D u_sceneTexture;\r\n\r\n// --- Distortion Controls ---\r\nuniform float u_masterStrength; // 0.0 = no effect, 1.0 = full effect\r\nuniform vec2 u_amplitude;\r\nuniform vec2 u_frequency;\r\nuniform float u_angle; // The new angle in radians\r\nuniform float u_time;\r\n\r\nin vec2 v_uv;\r\nout vec4 out_color;\r\n\r\nconst float PI = 3.1415926535;\r\n\r\nvoid main() {\r\n    // Get the original, unaffected color\r\n    vec4 originalColor = texture(u_sceneTexture, v_uv);\r\n\r\n\t// Calculate the distorted texture coordinates\r\n\r\n    // Setup the rotated coordinate system of each wave\r\n    vec2 dir1 = vec2(cos(u_angle), sin(u_angle));\r\n    vec2 dir2 = vec2(-dir1.y, dir1.x);\r\n\r\n    // Center the UV coordinates so the rotation is around the middle of the screen\r\n    vec2 centeredUV = v_uv - 0.5;\r\n\r\n    // Calculate distances along the rotated axes\r\n    float dist1 = dot(centeredUV, dir2);\r\n    float dist2 = dot(centeredUV, dir1);\r\n\r\n    // Calculate the sine wave offsets\r\n    float offset1 = sin(dist1 * u_frequency.y * 2.0 * PI + u_time) * u_amplitude.x;\r\n    float offset2 = sin(dist2 * u_frequency.x * 2.0 * PI + u_time) * u_amplitude.y;\r\n\r\n    // Combine offsets to get the final distortion vector\r\n\t// The final offset is a combination of both waves moving along their respective directions\r\n    vec2 totalOffset = (dir1 * offset1) + (dir2 * offset2);\r\n    vec2 distortedUV = v_uv + totalOffset;\r\n\r\n    // Get the fully distorted color\r\n    vec4 distortedColor = texture(u_sceneTexture, distortedUV);\r\n\r\n\t// Blend between original and distorted color using master strength\r\n\tout_color = mix(originalColor, distortedColor, u_masterStrength);\r\n}"
  },
  {
    "path": "src/client/shaders/starfield/vertex.glsl",
    "content": "#version 300 es\r\n\r\n\r\n// Base shape vertex (a corner of the star's quad)\r\nin vec2 a_position;\r\n\r\n// Per-instance attributes\r\nin vec2 a_instanceposition; // Center position of the star (x,y)\r\nin vec4 a_instancecolor;    // Color of the star (r,g,b,a)\r\nin float a_instancesize;    // Size of the star\r\n\r\nuniform mat4 u_transformmatrix;\r\n\r\nout vec4 vColor;\r\n\r\nvoid main() {\r\n    // Scale the base quad vertex by the instance's size, then add the instance's position.\r\n    // This creates a quad of the correct size at the correct location.\r\n    vec2 finalPosition = (a_position * a_instancesize) + a_instanceposition;\r\n\r\n    // We provide z=0.0 and w=1.0 for a complete 3D position vector\r\n    gl_Position = u_transformmatrix * vec4(finalPosition, 0.0, 1.0);\r\n    vColor = a_instancecolor;\r\n}"
  },
  {
    "path": "src/client/shaders/texture/fragment.glsl",
    "content": "#version 300 es\r\n\r\nprecision highp float;\r\n\r\nin vec2 vTextureCoord;\r\nuniform sampler2D u_sampler;\r\n\r\nout vec4 fragColor;\r\n\r\nvoid main() {\r\n    // Apply a mipmap LOD bias to make textures sharper.\r\n    fragColor = texture(u_sampler, vTextureCoord, -0.5);\r\n}"
  },
  {
    "path": "src/client/shaders/texture/instanced/vertex.glsl",
    "content": "#version 300 es\r\n\r\nin vec4 a_position;        // Per-vertex position (vec4 for homogeneous coordinates)\r\nin vec2 a_texturecoord;          // Per-vertex texture coordinates\r\nin vec3 a_instanceposition;      // Per-instance position offset (vec3: xyz)\r\n\r\nuniform mat4 u_transformmatrix;  // Transformation matrix\r\n\r\nout vec2 vTextureCoord;         // To fragment shader\r\n\r\nvoid main() {\r\n    // Apply instance position offset\r\n    vec4 offsetPosition = a_position + vec4(a_instanceposition, 0.0);\r\n    \r\n    // Transform position and pass through texture coords\r\n    gl_Position = u_transformmatrix * offsetPosition;\r\n    \r\n    // Pass texture coordinates directly to fragment shader\r\n    vTextureCoord = a_texturecoord;\r\n}"
  },
  {
    "path": "src/client/shaders/texture/vertex.glsl",
    "content": "#version 300 es\r\n\r\nin vec4 a_position;\r\nin vec2 a_texturecoord;\r\n\r\nuniform mat4 u_transformmatrix;\r\n\r\nout vec2 vTextureCoord;\r\n\r\nvoid main(void) {\r\n    gl_Position = u_transformmatrix * a_position;\r\n    vTextureCoord = a_texturecoord;\r\n}"
  },
  {
    "path": "src/client/shaders/vignette/fragment.glsl",
    "content": "#version 300 es\r\nprecision highp float;\r\n\r\nuniform sampler2D u_sceneTexture;\r\n\r\n// --- Vignette Controls ---\r\nuniform float u_masterStrength; // 0.0 = no effect, 1.0 = full effect\r\nuniform float u_radius;   // How far the vignette reaches. 0.5 is the screen edge.\r\nuniform float u_softness; // How gradual the falloff is.\r\nuniform float u_intensity; // How dark the vignette is. 1.0 is pure black.\r\n\r\nin vec2 v_uv;\r\nout vec4 out_color;\r\n\r\nvoid main() {\r\n    // Store the original, unaffected color\r\n\tvec4 originalColor = texture(u_sceneTexture, v_uv);\r\n\r\n\t// Calculate the applied vignette color\r\n\r\n    // Calculate the distance of the pixel from the center (0.5, 0.5)\r\n    float dist = length(v_uv - vec2(0.5));\r\n\r\n    // Calculate the vignette factor using smoothstep for a nice falloff.\r\n    float vignetteFactor = smoothstep(u_radius, u_radius + u_softness, dist);\r\n\r\n    // Calculate the color with the applied vignette.\r\n    // The 'mix' function blends between the original color and black.\r\n    vec3 vignettedColor = mix(originalColor.rgb, vec3(0.0), vignetteFactor * u_intensity);\r\n\r\n    // Blend between original and vignetted color using master strength\r\n    vec3 finalRgb = mix(originalColor.rgb, vignettedColor, u_masterStrength);\r\n\r\n    // Set the final output, preserving the original alpha\r\n    out_color = vec4(finalRgb, originalColor.a);\r\n}"
  },
  {
    "path": "src/client/shaders/voronoi_distortion/fragment.glsl",
    "content": "#version 300 es\r\nprecision highp float;\r\n\r\n// Input Texture\r\nuniform sampler2D u_sceneTexture;\r\n\r\n// Effect Controls\r\nuniform float u_masterStrength; // 0.0 = no effect, 1.0 = full effect\r\nuniform float u_time;           // Used for animation\r\nuniform float u_density;        // Controls the number of Voronoi cells\r\nuniform float u_strength;       // The maximum strength of the cells' distortion\r\nuniform float u_ridgeThickness; // The width of the ridges between cells\r\nuniform float u_ridgeStrength;   // The intensity of the ridges' lensing\r\n\r\n// Canvas Properties\r\nuniform vec2 u_resolution;      // Canvas dimensions for aspect ratio correction\r\n\r\nin vec2 v_uv;\r\nout vec4 out_color;\r\n\r\n// --- Helper Functions ---\r\n\r\nvec2 noise2x2(vec2 p) {\r\n    // A small constant is added after the dot product, preventing the bottom-left point from being stationary.\r\n\tfloat x = dot(p, vec2(123.4, 234.5)) + 42.0;\r\n\tfloat y = dot(p, vec2(345.6, 456.7)) + 24.0;\r\n\tvec2 noise = vec2(x, y);\r\n\tnoise = sin(noise);\r\n\tnoise = noise * 43758.5453;\r\n\tnoise = fract(noise);\r\n\treturn noise;\r\n}\r\n\r\n\r\nvoid main() {\r\n    // Store the original, unaffected color\r\n    vec4 originalColor = texture(u_sceneTexture, v_uv);\r\n\r\n\t// Voronoi Cell Calculation\r\n\r\n    // Normalize coordinates and adjust for aspect ratio to keep cells roughly square.\r\n    vec2 uv = gl_FragCoord.xy / u_resolution.xy;\r\n    float aspect_ratio = u_resolution.x / u_resolution.y;\r\n    uv.x *= aspect_ratio;\r\n\r\n    // Scale coordinates by density\r\n    vec2 uv_scaled = uv * u_density;\r\n    \r\n    // Get the integer and fractional parts of the coordinate\r\n    vec2 currentGridId = floor(uv_scaled);\r\n    vec2 currentGridCoord = fract(uv_scaled);\r\n\tcurrentGridCoord = currentGridCoord - 0.5; // Moves range from [0,1] to [-0.5,0.5]\r\n\r\n    float d1 = 10.0; // Distance to the closest point\r\n    float d2 = 10.0; // Distance to the second-closest point\r\n\r\n    vec2 d1_vector = vec2(0.0); // Vector to the closest point\r\n\r\n    // Loop through neighboring cells to find the two closest points\r\n    for (float i = -1.0; i <= 1.0; i++) {\r\n        for (float j = -1.0; j <= 1.0; j++) {\r\n            vec2 adjGridCoords = vec2(i, j);\r\n\r\n\t\t\t// Vary points based on time + noise.\r\n\t\t\tvec2 noise = noise2x2(currentGridId + adjGridCoords);\r\n\t\t\tvec2 pointOnAdjGrid = adjGridCoords + sin(u_time * noise) * 0.5; // 0.5 controls how far the points can move (should not exceed nearest neighbor)\r\n\r\n            // Calculate distance from the current fragment to this cell's point\r\n\t\t\tfloat dist = length(currentGridCoord - pointOnAdjGrid);\r\n\r\n            if (dist < d1) {\r\n                // This point is the new closest.\r\n                // The old closest becomes the new second-closest.\r\n                d2 = d1;\r\n                d1 = dist;\r\n                d1_vector = pointOnAdjGrid - currentGridCoord;\r\n            } else if (dist < d2) {\r\n                // This point is not the closest, but it is the new second-closest.\r\n                d2 = dist;\r\n            }\r\n        }\r\n    }\r\n\r\n    // Calculate the main cell distortion\r\n\r\n    // Determine the direction of distortion. We want to push *away* from the\r\n    // closest point, which is the inverse of the vector *to* the closest point.\r\n    vec2 distortion_direction = normalize(-d1_vector);\r\n\r\n    // Determine the magnitude of the distortion. We want zero distortion near\r\n    // the point (min_dist = 0) and max distortion far from it.\r\n    float distortion_magnitude = u_strength * smoothstep(0.1, 0.8, d1);\r\n\r\n    vec2 total_offset = distortion_direction * distortion_magnitude;\r\n\r\n\r\n    // Boundary Lensing Effect\r\n\r\n    // Calculate the boundary \"ridge\" mask.\r\n    // (d2 - d1) is our edge detector. It's almost 0 on the boundary.\r\n    float ridge_mask = 1.0 - smoothstep(0.0, u_ridgeThickness, d2 - d1);\r\n\r\n    // Create the sharp \"lensing\" distortion perpendicular to the boundary.\r\n    // The direction is perpendicular to the vector pointing from the pixel to the cell center.\r\n    vec2 ridge_direction = normalize(vec2(d1_vector.y, -d1_vector.x));\r\n    vec2 ridge_offset = ridge_direction * ridge_mask * u_ridgeStrength;\r\n\r\n    // Combine the base distortion with the new boundary distortion.\r\n    total_offset = total_offset + ridge_offset;\r\n\r\n\r\n\t// The final offset vector needs to be scaled back for the non-aspect-corrected UVs.\r\n    total_offset.x /= aspect_ratio;\r\n\r\n\r\n\t// [DEBUG] Visualize the raw distance field.\r\n\t// out_color = vec4(vec3(d1), 1.0);\r\n\t// return;\r\n\r\n\r\n    // Get the fully distorted color\r\n\r\n    // Apply the calculated offset to the original texture coordinates\r\n    vec2 distorted_uv = v_uv + total_offset;\r\n    vec4 distortedColor = texture(u_sceneTexture, distorted_uv);\r\n\r\n    // Blend between original and distorted color using master strength\r\n\tout_color = mix(originalColor, distortedColor, u_masterStrength);\r\n}"
  },
  {
    "path": "src/client/shaders/water/fragment.glsl",
    "content": "#version 300 es\nprecision highp float;\n\n// src/client/shaders/water/fragment.glsl\n\n// --- Input from Vertex Shader ---\nin vec2 v_uv;\nout vec4 out_color;\n\n// The maximum number of sources, must match the JS constant.\nconst int MAX_SOURCES = 10;\n\n\n// --- Uniforms ---\nuniform float u_masterStrength; // 0.0 = no effect, 1.0 = full effect\nuniform sampler2D u_sceneTexture; // The original scene texture\nuniform int u_sourceCount;        // How many active ripple sources we have\nuniform vec2 u_centers[MAX_SOURCES];       // The centers of the ripple sources (in UV space)\nuniform float u_time;             // Current time for animation\nuniform vec2 u_resolution;        // The dimensions of the canvas (width, height)\n\nuniform float u_strength;         // The magnitude of the distortion\nuniform float u_oscillationSpeed; // How fast the waves oscillate\nuniform float u_frequency;        // The density of the waves (waves per UV unit)\n\n\nvoid main() {\n\t// Store the original, unaffected color\n\tvec4 originalColor = texture(u_sceneTexture, v_uv);\n\n\t// This will store the combined X and Y offsets from all sources.\n\tvec2 totalDistortionVector = vec2(0.0);\n\n\tfor (int i = 0; i < MAX_SOURCES; i++) {\n\t\tif (i >= u_sourceCount) break; // Stop if we've processed all active sources\n\n\t\tvec2 center = u_centers[i];\n\n\t\t// Calculate the difference vector and apply aspect correction to it.\n\t\tvec2 diff = v_uv - center;\n\t\tdiff.x *= u_resolution.x / u_resolution.y;\n\t\tfloat dist = length(diff);\n\n\t\t// Calculate the sine wave. This creates the ripple pattern.\n\t\t// The wave is based on distance from the center, frequency, and time.\n\t\tfloat wave = sin(dist * u_frequency - u_time * u_oscillationSpeed);\n\n\t\t// Calculate the distortion vector for this specific ripple source and add it to our accumulator.\n\t\t// `normalize(diff)` gives the direction away from this source's center.\n\t\tif (dist > 0.0) { // Avoid division by zero at the exact center\n\t\t\tvec2 sourceDistortion = normalize(diff) * wave;\n\t\t\ttotalDistortionVector += sourceDistortion;\n\t\t}\n\t}\n\n\t// De-correct the aspect ratio of the final distortion vector\n\t// before applying it to the non-corrected UV coordinates.\n\ttotalDistortionVector.x /= (u_resolution.x / u_resolution.y);\n\t\n\t// Apply the calculated distortion to the texture coordinates.\n\tvec2 distortedTexCoord = v_uv + totalDistortionVector * u_strength;\n\n\t// Sample the original scene with the new, distorted coordinates.\n\tvec4 distortedColor = texture(u_sceneTexture, distortedTexCoord);\n\n\t// Blend between original and distorted color using master strength\n\tout_color = mix(originalColor, distortedColor, u_masterStrength);\n}"
  },
  {
    "path": "src/client/shaders/water_ripple/fragment.glsl",
    "content": "#version 300 es\nprecision highp float;\n\n// The maximum number of concurrent droplets supported by this shader.\n// This value MUST match the corresponding constant in the WaterRipplePass class.\nconst int MAX_DROPLETS = 20;\n\n// Input Texture\nuniform sampler2D u_sceneTexture; // The result of the previous rendering pass.\n\n// Droplet Data (Received every frame)\nuniform vec2 u_centers[MAX_DROPLETS];    // The center UV coordinate for each droplet.\nuniform float u_times[MAX_DROPLETS];     // The elapsed time (in seconds) for each droplet.\nuniform int u_dropletCount;              // The number of active droplets to process in the arrays.\n\n// Global Effect Controls (Configurable)\nuniform float u_strength;                // Overall strength of the distortion effect.\nuniform float u_propagationSpeed;        // How fast the ripple's leading edge expands (UV units/sec).\nuniform float u_oscillationSpeed;        // How fast the internal waves oscillate (phase shift/sec).\nuniform float u_frequency;               // The density of the rings in the ripple (waves per UV unit).\nuniform float u_falloff;                 // How quickly the trailing waves decay. Higher is faster.\nuniform float u_glintIntensity;          // Controls the brightness of the glint.\nuniform float u_glintExponent;           // Controls the sharpness/tightness of the glint. Higher is thinner.\n\n// Canvas Properties\nuniform vec2 u_resolution;               // The width and height of the canvas for aspect correction.\n\nin vec2 v_uv;\nout vec4 out_color;\n\nvoid main() {\n    // This vector will accumulate the distortion offset from all active droplets.\n\tvec2 totalOffset = vec2(0.0);\n    float totalGlint = 0.0;\n\n    // Loop through only the active droplets for this frame.\n\tfor (int i = 0; i < u_dropletCount; i++) {\n\t\tvec2 center = u_centers[i];\n\t\tfloat time = u_times[i];\n\n        // Calculate aspect-corrected distance from the droplet's center.\n\t\t// This makes ripples circular on non-square screens.\n\t\tvec2 diff = v_uv - center;\n\t\tdiff.x *= u_resolution.x / u_resolution.y;\n\t\tfloat dist = length(diff);\n\n        // Create a soft mask for the ripple's leading edge that is 1.0 inside and fades to 0.0 outside.\n        // This prevents the ripple from appearing before it should.\n\t\tfloat maxRadius = time * u_propagationSpeed;\n        float waveMask = 1.0 - smoothstep(maxRadius - 0.1, maxRadius, dist);\n\n        // Generate the animating sine wave.\n        float wave = sin((dist * u_frequency) - (time * u_oscillationSpeed));\n\n        // Calculate the inverse square decay for the trailing waves.\n        // Determine how far this pixel is \"behind\" the leading edge.\n        float distanceBehind = max(0.0, maxRadius - dist);\n\t\tfloat trailingFade = 1.0 / (1.0 + u_falloff * distanceBehind * distanceBehind);\n\n        // Combine factors to get the magnitude of the ripple.\n\t\tfloat rippleMagnitude = wave * waveMask * trailingFade;\n\n        // Calculate the offset in the aspect-corrected space.\n        // `normalize(diff)` gives a direction in the circular space.\n\t\tvec2 offset = normalize(diff) * rippleMagnitude * u_strength;\n\t\t\n        // Transform the offset vector back from the aspect-corrected space to the original UV space.\n        // We only transformed the x-component, so we only need to reverse that.\n        offset.x /= (u_resolution.x / u_resolution.y);\n\n\t\t// The accumulated final offset.\n\t\ttotalOffset += offset;\n\n\t\t// Calculate the glint for this droplet\n        // Isolate the crest of the wave (the positive part).\n        float crest = max(0.0, wave);\n        // Raise it to a high power to create a tight hotspot and add it to the total.\n        totalGlint += pow(crest, u_glintExponent) * waveMask * trailingFade;\n\t}\n\n    // Apply the final, combined offset to the original texture coordinates.\n\tvec2 distortedUV = v_uv + totalOffset;\n\tvec4 color = texture(u_sceneTexture, distortedUV);\n    // Add the final accumulated glint\n    color.rgb += totalGlint * u_glintIntensity; // Glint intensity\n\n\tout_color = color;\n}"
  },
  {
    "path": "src/client/sounds/spritesheet/note.txt",
    "content": "Sound spritesheet was compressed to opus format, using stereo, 48000Hz, and 64 Bitrate."
  },
  {
    "path": "src/client/views/admin.ejs",
    "content": "<!doctype html>\n<head>\n\t<script defer src=\"scripts/esm/views/admin.js\"></script>\n\t<link rel='stylesheet' href='/css/header.css'>\n\t<link rel=\"stylesheet\" href=\"/css/admin.css\" />\n\t<link rel=\"icon\" href=\"/img/favicon/favicon-light.png\" media=\"(prefers-color-scheme: light)\" />\n\t<link rel=\"icon\" href=\"/img/favicon/favicon-dark.png\" media=\"(prefers-color-scheme: dark)\" id=\"favicon\" />\n</head>\n<body>\n\t<%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language})\n\t%>\n\t<textarea id=\"commandHistory\" readonly></textarea>\n\t<div class=\"inputContainer\">\n\t\t<input id=\"commandInput\" type=\"text\" />\n\t\t<button type=\"button\" id=\"sendButton\">Send Command</button>\n\t</div>\n</body>\n"
  },
  {
    "path": "src/client/views/components/footer.ejs",
    "content": "<footer>\n    <a href=\"mailto:support@infinitechess.org\"><%=t('footer.contact')%></a>\n    <a href=\"/termsofservice?lng=<%= language %>\"><%=t('footer.terms_of_service')%></a>\n    <a href=\"https://github.com/Infinite-Chess/infinitechess.org\" target=\"_blank\"><%=t('footer.source_code')%></a>\n</footer>"
  },
  {
    "path": "src/client/views/components/header.ejs",
    "content": "<header>\n    <!-- Reusable SVGs (here to reduce redundancy) -->\n    <div class=\"hidden\"> \n        <!-- The pawn svg and loading animation svg we use in several places -->\n        <svg>\n            <symbol id=\"svg-pawn\" viewBox=\"0 0 45 45\">\n                <path d=\"M22.5 9c-2.21 0-4 1.79-4 4 0 .89.29 1.71.78 2.38C17.33 16.5 16 18.59 16 21c0 2.03.94 3.84 2.41 5.03-3 1.06-7.41 5.55-7.41 13.47h23c0-7.92-4.41-12.41-7.41-13.47 1.47-1.19 2.41-3 2.41-5.03 0-2.41-1.33-4.5-3.28-5.62.49-.67.78-1.49.78-2.38 0-2.21-1.79-4-4-4z\" fill-opacity=\"1\" fill-rule=\"nonzero\" stroke-dasharray=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"miter\" stroke-miterlimit=\"4\" stroke-opacity=\"1\" stroke-width=\"1.5\" opacity=\"1\"/>\n            </symbol>\n        </svg>\n        <!-- Checkmark svg commonly found in the settings dropdowns -->\n        <svg>\n            <symbol id=\"checkmark\" viewBox=\"0 0 320 320\">\n                <path d=\"m290 85-21-21a10 10 0 00-15 0L126 192l-60-61A10 10 0 00 50 130l-20 20a10 10 0 00 0 15l88 90c4 4 10 4 15 0L290 100c4-4 4-10 0-15\"/>\n            </symbol>\n        </svg>\n        <!-- Language globe svg -->\n        <svg>\n            <symbol id=\"svg-language\" fill=\"none\" viewBox=\"0 0 240 240\">\n                <path d=\"m30 75 44 30 4 3h3l4-4 9-12V90h2s0 0 0 0l40-18 3-2v0l1-4 3-42m-8 112 26 11 6 3v4l-2 5-13 18c0 2 0 2-2 3h0l-3 1h-24l-2-2-2-3-7-22v-7l6-10c0-3 2-4 2-5h8l8 3ZM220 120a100 100 0 11-200 0 100 100 0 01 200 0Z\" stroke=\"#555\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"20\"/>\n            </symbol>\n        </svg>\n        <!-- 2x2 Board tile theme selection svg -->\n        <svg>\n            <symbol id=\"svg-board\" viewBox=\"0 0 479 479\">\n                <path d=\"M0 240v239h480V0H0v240m0 0v240l1-120V120L0 1v239m60-90v90h180v180h180V240H240V60H60v90\" fill=\"#777\" fill-rule=\"evenodd\"/>\n            </symbol>\n        </svg>\n        <!-- Dot and corner tris legal move selection svg -->\n        <svg>\n            <symbol id=\"svg-legalmove\" viewBox=\"0 0 3710 3700\">\n                <path d=\"M0 740v730l740-730 730-740H0v740m2980-10 730 730V0H2250l730 730m-1260 360h-40l-30 10-10 10h-30l-20 10-20 10h-10l-90 50-20 10-50 30-30 20a840 840 0 00-200 230v10l-10 20-30 50-10 30-40 120a1420 1420 0 00 10 340l20 60v20l10 20 20 50 30 50 10 10 60 90a740 740 0 00 340 250l140 40a1500 1500 0 00 330-20l30-10h20l20-10 10-10c10 10 150-60 200-110 90-80 200-210 190-230l10-10a430 430 0 00 70-160l10-80 10-40a670 670 0 00 0-230l-10-40-10-10a540 540 0 00-90-220c0-30-110-150-190-210l-100-70-40-20c-40-20-120-50-130-40l-10-10c-20-20-310-30-320-10M0 2970v730h1480l-730-730-740-740-10 740m2980 0-730 730h1460V2240l-730 730\" fill=\"#666\" fill-rule=\"evenodd\"/>\n            </symbol>\n        </svg>\n        <!-- Perspective mode svg -->\n        <svg>\n            <symbol id=\"svg-perspective\" fill=\"none\" viewBox=\"0 0 480 480\">\n                <path d=\"M0 0h480v480H0\" fill=\"none\"/><path d=\"M440 280v80l-130 26M440 280 40 320m400-40v-80M40 320v120l130-26M40 320V160m400 40v-80L310 94M440 200 40 160m0 0V40l130 26m140 28v292m0-292L170 66m140 320-140 28m0-348v348\" stroke=\"#555\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"40\"/>\n            </symbol>\n        </svg>\n        <!-- Mouse svg -->\n        <svg>\n            <symbol id=\"svg-mouse\" viewBox=\"0 0 240 240\" fill=\"none\">\n                <path d=\"M120 70v20m53.3 70 4.8-71.9.1-2a50 50 0 00-19.1-41.5l-1.7-1.2a60 60 0 00-72.2-2l-2.6 2-1.7 1.3a50 50 0 00-19 43.8l4.8 72a53.4 53.4 0 00 106.6 0Z\" stroke=\"#555\" stroke-linecap=\"round\" stroke-width=\"20\"/></svg>\n            </symbol>\n        </svg>\n        <!-- Camera perspective fov svg -->\n        <svg>\n            <symbol id=\"svg-camera\" fill=\"none\" viewBox=\"0 0 240 240\">\n                <path d=\"M120 160a30 30 0 10 0-60 30 30 0 00 0 60Z\" stroke=\"#555\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"20\"/><path d=\"M30 168V92c0-11.2 0-16.8 2.2-21a20 20 0 01 8.7-8.8C45.2 60 50.9 60 62 60h13a10 10 0 00 7.2-4.5l1.1-2.2 2.3-4.2a20 20 0 01 14.5-9h39.8a20 20 0 01 14.5 9l2.3 4.2 1.1 2.2a10 10 0 00 7.2 4.4h13c11.2 0 16.8 0 21 2.3 3.8 2 7 5 8.8 8.7 2.2 4.3 2.2 10 2.2 21v76.1c0 11.2 0 16.8-2.2 21a20 20 0 01-8.7 8.8c-4.3 2.2-9.9 2.2-21 2.2H62c-11.2 0-16.8 0-21-2.2a20 20 0 01-8.8-8.7C30 184.8 30 179.2 30 168.1Z\" stroke=\"#555\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"20\"/>\n            </symbol>\n        </svg>\n        <!-- Selection svg -->\n        <svg>\n            <symbol id=\"svg-selection\" fill=\"none\" viewBox=\"0 0 24 24\">\n              <path d=\"M7.92098 2.29951C6.93571 1.5331 5.5 2.23523 5.5 3.48349V20.4923C5.5 21.9145 7.2945 22.5382 8.17661 21.4226L12.3676 16.1224C12.6806 15.7267 13.1574 15.4958 13.6619 15.4958H20.5143C21.9425 15.4958 22.5626 13.6887 21.4353 12.8119L7.92098 2.29951Z\" stroke=\"#555\" stroke-linecap=\"round\" stroke-width=\"3\"/>\n            </symbol>\n        </svg>\n        <!-- Sound svg -->\n        <svg>\n            <symbol id=\"svg-sound\" fill=\"none\" viewBox=\"0 0 240 240\">\n                <g fill=\"#555\" stroke=\"none\"><path d=\"M183.6 193.6a10 10 0 01-7-2.9 10 10 0 01 0-14.1 80 80 0 00 0-113.2 10 10 0 01 14.1-14.1 100 100 0 01 0 141.4 10 10 0 01-7.1 2.9Z\"/><path d=\"M155.4 165.4a10 10 0 01-7.1-3 10 10 0 01 0-14.1 40 40 0 00 0-56.6 10 10 0 01 14-14.1 60 60 0 01 0 84.8 10 10 0 01-7 3ZM113.8 40.8a10 10 0 00-10.9 2.1L65.7 80h-26a20 20 0 00-20 20v40a20 20 0 00 20 20h25.9l37 37.1A10 10 0 00 110 200a8.4 8.4 0 00 3.8-.8A10 10 0 00 120 190V50a10 10 0 00-6.2-9.2Z\"/></g>\n            </symbol>\n        </svg>          \n        <!-- Undo arrow for resetting to default -->\n        <svg>\n            <symbol id=\"svg-undo\" fill=\"none\" viewBox=\"-200 0 1900 1900\">\n                <path d=\"M910 1473c147-169 90-414-136-388 9 56 18 110 20 130 9 47-30 54-30 54l-438-310 423-378s43 0 35 64l-16 128C1142 724 1372 1230 940 1490l-30-17Z\" fill=\"#555\" fill-rule=\"evenodd\" clip-rule=\"evenodd\"/>\n            </symbol>\n        </svg>\n    </div>\n    <a class=\"home compact-1\" href=\"/?lng=<%= language %>\">\n        <!-- <img class=\"headerlogo\" src=\"img/logo/light-theme.png\"/> -->\n        <picture class=\"headerlogo\" >\n            <source srcset=\"/img/logo/light-theme.avif\" type=\"image/avif\" />\n            <source srcset=\"/img/logo/light-theme.webp\" type=\"image/webp\" />\n            <img src=\"img/logo/light-theme.png\" alt=\"Omega one, the logo of Infinite Chess.\">\n        </picture>\n        <p><%=t('header.home')%></p>\n    </a>\n    <nav class=\"compact-3\">\n        <a href=\"/play?lng=<%= language %>\">\n            <span><%=t('header.play')%></span>\n            <svg class=\"svg-pawn\"><use href=\"#svg-pawn\"></use></svg>\n        </a>\n        <a href=\"/news?lng=<%= language %>\">\n            <span><%=t('header.news')%></span>\n            <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" id=\"svg-news\" viewBox=\"0 0 240 240\"><g fill=\"#555\"><path d=\"M110 50a10 10 0 10 0 20h70a10 10 0 10 0-20h-70Zm-10 50c0-6 4-10 10-10h20a10 10 0 11 0 20h-20a10 10 0 01-10-10Zm10 30a10 10 0 10 0 20h20a10 10 0 00 0-20h-20Zm-10 50c0-6 4-10 10-10h20a10 10 0 11 0 20h-20a10 10 0 01-10-10Zm60-90a10 10 0 10 0 20h20a10 10 0 00 0-20h-20Zm-10 50c0-6 4-10 10-10h20a10 10 0 11 0 20h-20a10 10 0 01-10-10Zm10 30a10 10 0 10 0 20h20a10 10 0 00 0-20h-20Z\"/><path d=\"M45 230H190a40 40 0 00 23-6c6-4 10-10 12-15A47 47 0 00 230 190V40a30 30 0 00-30-30H90a30 30 0 00-30 30v90H40a30 30 0 00-30 30v30c0 18 8 30 17 35a37 37 0 00 18 5ZM210 40c0-6-4-10-10-10H90a10 10 0 00-10 10v150c0 8-2 15-4 20H190c6 0 10-1 12-3l5-6c2-4 3-7 3-11V40ZM40 150h20v40c0 12-5 16-8 18-4 3-10 2-14 0-3-2-8-6-8-18v-30c0-6 4-10 10-10Z\" fill-rule=\"evenodd\" clip-rule=\"evenodd\"/></g></svg>\n        </a>\n        <a href=\"/leaderboard?lng=<%= language %>\">\n            <span><%=t('header.leaderboard')%></span>\n            <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" id=\"svg-leaderboard\" viewBox=\"0 0 24 24\"><path d=\"M12 11 8 3H4l4.5 9.46M12 11l4-8h4l-4.5 9.46M12 11c1.34 0 2.58.56 3.5 1.46M12 11c-1.34 0-2.58.56-3.5 1.46m7 0A4.99 4.99 0 1 1 7 16a5 5 0 0 1 1.5-3.54\" stroke=\"#555\" stroke-linejoin=\"round\" stroke-width=\"2\"/></svg>\n        </a>\n        <a id=\"login-link\" href=\"/login?lng=<%= language %>\">\n            <span id=\"login\" class=\"login\"><%=t('header.login')%></span>\n            <span id=\"profile\" class=\"hidden\"><%=t('header.profile')%></span>\n            <svg id=\"svg-login\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 240 240\"><path d=\"M150 165V190a20 20 0 01-20 20H60a20 20 0 01-20-20V50c0-11 9-20 20-20h70a20 20 0 01 20 20v30m50 40H90m0 0 25 25M90 120l25-25\" stroke=\"#555\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"20\"/></svg>\n            <svg id=\"svg-profile\" class=\"hidden\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#000\" stroke=\"#000\" version=\"1.1\" viewBox=\"0 0 2000 2000\"><path d=\"M1656 1800H344c-70 0-123-70-96-134C370 1370 662 1200 1000 1200s629 170 752 466c27 64-25 134-96 134M592 600c0-220 183-400 408-400s408 180 408 400-183 400-408 400a405 405 0 01-408-400m1404 1164a952 952 0 00-612-697 593 593 0 00 220-560 610 610 0 00-530-503A608 608 0 00 388 600c0 190 89 357 228 467a952 952 0 00-612 697c-27 122 74 236 200 236h1590c128 0 229-114 200-236\" fill=\"#555\" fill-rule=\"evenodd\" stroke=\"none\"/></svg>\n        </a>\n        <a id=\"createaccount-link\" href=\"/createaccount?lng=<%= language %>\">\n            <span id=\"createaccount\"><%=t('header.createaccount')%></span>\n            <span id=\"logout\" class=\"hidden\"><%=t('header.logout')%></span>\n            <svg id=\"svg-createaccount\" xmlns=\"http://www.w3.org/2000/svg\" xml:space=\"preserve\" fill=\"#555\" stroke=\"#555\" version=\"1.1\" viewBox=\"0 0 3100 3100\"><path d=\"m3006 1597-453-453a150 150 0 00-212 0l-91 90V150a150 150 0 00-150-150H200A150 150 0 00 50 150v2400a150 150 0 00 150 150h850v250a150 150 0 00 150 150h453c40 0 78-16 106-44L3006 1810a150 150 0 00 0-212zM350 300h1600v1235L1094 2390l-9 10H350V300zm1241 2500H1350v-241l1097-1097 240 241L1592 2800\"/></svg>\n            <svg id=\"svg-logout\" class=\"hidden\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 240 240\"><path d=\"m160 170 50-50m0 0-50-50m50 50H90m30 50-.1 5.7a30 30 0 01-24 26.9l-5.7.7-10.2 1.1c-15.4 1.8-23 2.6-29.1.7a30 30 0 01-18.3-16.4C30 183 30 175.2 30 159.7V80.3c0-15.5 0-23.2 2.6-29A30 30 0 01 51 34.9c6-2 13.7-1 29 .7l10.3 1 5.7.8A30 30 0 01 120 64.3V70\" stroke=\"#555\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"20\"/></svg>\n        </a>\n    </nav>\n    <!-- gear svg for settings -->\n    <div id=\"settings\" class=\"settings\">\n        <svg class=\"gear\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 240 240\"><path d=\"M128 4c10 0 20 8 21 18v4a30 30 0 00 40 22l4-2c10-3 21 0 26 10l8 13c5 9 3 20-4 26l-4 3a28 28 0 00 0 44l4 3c7 6 10 17 4 26l-8 13c-5 10-16 13-26 10l-4-2a30 30 0 00-40 22v4c-2 10-10 18-21 18h-16c-10 0-20-8-21-18v-4a30 30 0 00-40-22l-4 2c-10 3-21 0-26-10l-8-13a20 20 0 01 4-26l4-3a28 28 0 00 0-44l-4-3A20 20 0 01 13 70L20 56c5-10 16-13 26-10l4 2a30 30 0 00 40-22v-4c2-10 10-18 21-18h16ZM120 153a33 33 0 10 0-66 33 33 0 00 0 66Z\" fill=\"#555\" fill-rule=\"evenodd\" clip-rule=\"evenodd\"/></svg>\n        <div class=\"settings-dropdown dropdown visibility-hidden\">\n            <div id=\"language-settings-dropdown-item\" class=\"settings-dropdown-item\">\n                <svg class=\"svg-language\"><use href=\"#svg-language\"></use></svg>\n                <p class=\"text\"><%=t('header.settings.language')%></p>\n                <span class=\"arrow-head-right\"></span>\n            </div>\n            <div id=\"appearance-settings-dropdown-item\" class=\"settings-dropdown-item\">\n                <svg class=\"svg-board\"><use href=\"#svg-board\"></use></svg>\n                <p class=\"text\"><%=t('header.settings.appearance')%></p>\n                <span class=\"arrow-head-right\"></span>\n            </div>\n            <div id=\"legalmove-settings-dropdown-item\" class=\"settings-dropdown-item\">\n                <svg class=\"svg-legalmove\"><use href=\"#svg-legalmove\"></use></svg>\n                <p class=\"text\"><%=t('header.settings.legalmoves')%></p>\n                <span class=\"arrow-head-right\"></span>\n            </div>\n            <div id=\"gameplay-settings-dropdown-item\" class=\"settings-dropdown-item\">\n                <svg class=\"svg-selection\"><use href=\"#svg-selection\"></use></svg>\n                <p class=\"text\"><%=t('header.settings.gameplay')%></p>\n                <span class=\"arrow-head-right\"></span>\n            </div>\n            <div id=\"perspective-settings-dropdown-item\" class=\"settings-dropdown-item hidden\"> <!-- Hidden Initially until we test if mouse (& perspective mode) is supported -->\n                <svg class=\"svg-perspective\"><use href=\"#svg-perspective\"></use></svg>\n                <p class=\"text\"><%=t('header.settings.perspective')%></p>\n                <span class=\"arrow-head-right\"></span>\n            </div>\n            <div id=\"sound-settings-dropdown-item\" class=\"settings-dropdown-item\">\n                <svg class=\"svg-sound\"><use href=\"#svg-sound\"></use></svg>\n                <p class=\"text\"><%=t('header.settings.sound')%></p>\n                <span class=\"arrow-head-right\"></span>\n            </div>\n            <div class=\"ping-meter hidden\">\n                <div>\n                    <span class=\"ping\"><%=t('header.settings.ping.0')%></span>\n                    <strong class=\"ping-value\">-</strong>\n                    <span class=\"ms\"><%=t('header.settings.ping.1')%></span>\n                </div>\n                <div class=\"ping-bars\">\n                    <div class=\"ping-bar\" style=\"height: 6px;\"></div>\n                    <div class=\"ping-bar\" style=\"height: 12px;\"></div>\n                    <div class=\"ping-bar\" style=\"height: 18px;\"></div>\n                    <div class=\"ping-bar\" style=\"height: 24px;\"></div>\n                    <div class=\"ping-glow\"></div>\n                </div>\n                <svg class=\"svg-pawn spinny-pawn hidden\"><use href=\"#svg-pawn\"></use></svg>\n            </div>\n        </div>\n        <!-- Language -->\n        <div class=\"language-dropdown dropdown visibility-hidden\">\n            <div class=\"dropdown-title\">\n                <span class=\"arrow-head-left\"></span>\n                <svg class=\"svg-language\"><use href=\"#svg-language\"></use></svg>\n                <p class=\"text\"><%=t('header.settings.language')%></p>\n            </div>\n            <div class=\"dropdown-scrollable\">\n                <% for(let i = 0; i < languages.length; i++) { %>\n                    <div class=\"language-dropdown-item <%= languages[i].code === language ? 'selected' : '' %>\" value=\"<%= languages[i].code %>\">\n                        <div class=\"language-names\">\n                            <p class=\"name\"><%= languages[i].name %></p>\n                            <p class=\"englishName\"><%= languages[i].englishName %></p>\n                        </div>\n                        <%- languages[i].code === language ? '<svg class=\"checkmark\"><use href=\"#checkmark\"></use></svg>' : '' %>\n                    </div>\n                <% } %>\n            </div>\n        </div>\n        <!-- Appearance -->\n        <div class=\"appearance-dropdown dropdown visibility-hidden\">\n            <div class=\"dropdown-title\">\n                <span class=\"arrow-head-left\"></span>\n                <svg class=\"svg-board\"><use href=\"#svg-board\"></use></svg>\n                <p class=\"text\"><%=t('header.settings.appearance')%></p>\n            </div>\n            <div class=\"dropdown-scrollable\">\n                <p class=\"theme-title\"><%=t('header.settings.appearance-theme')%></p>\n                <div class=\"theme-list\"></div>\n                <!-- Board Boolean Options -->\n                <label class=\"boolean-option coordinates\">\n                    <p class=\"text\"><%=t('header.settings.appearance-coordinates')%></p>\n                    <div class=\"switch\">\n                        <input type=\"checkbox\">\n                        <span></span>\n                    </div>\n                </label>\n                <label class=\"boolean-option starfield\">\n                    <p class=\"text\"><%=t('header.settings.appearance-starfield')%></p>\n                    <div class=\"switch\">\n                        <input type=\"checkbox\">\n                        <span></span>\n                    </div>\n                </label>\n                <label class=\"boolean-option advanced-effects\">\n                    <p class=\"text\"><%=t('header.settings.appearance-advanced-effects')%></p>\n                    <div class=\"switch\">\n                        <input type=\"checkbox\">\n                        <span></span>\n                    </div>\n                </label>\n            </div>\n        </div>\n        <!-- Legal Move Shape -->\n        <div class=\"legalmove-dropdown dropdown visibility-hidden\">\n            <div class=\"dropdown-title\">\n                <span class=\"arrow-head-left\"></span>\n                <svg class=\"svg-legalmove\"><use href=\"#svg-legalmove\"></use></svg>\n                <p class=\"text\"><%=t('header.settings.legalmoves')%></p>\n            </div>\n            <div class=\"legalmove-options\">\n                <div class=\"legalmove-option squares\">\n                    <svg class=\"svg-squares\" xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" viewBox=\"0 0 3780 3780\"><path d=\"M1890 950v940H0v1890h1890V1890h1890v940a34850 34850 0 00 10-950L3780 0H1890v950M0 2840\" fill=\"#777\" fill-rule=\"evenodd\"/></svg>\n                    <p class=\"text\"><%=t('header.settings.legalmoves-squares')%></p>\n                    <svg class=\"checkmark visibility-hidden\"><use href=\"#checkmark\"></use></svg>\n                </div>\n                <div class=\"legalmove-option dots\">\n                    <svg class=\"svg-legalmove\"><use href=\"#svg-legalmove\"></use></svg>\n                    <p class=\"text\"><%=t('header.settings.legalmoves-dots')%></p>\n                    <svg class=\"checkmark visibility-hidden\"><use href=\"#checkmark\"></use></svg>\n                </div>\n            </div>\n        </div>\n        <!-- Gameplay -->\n        <div class=\"gameplay-dropdown dropdown visibility-hidden\">\n            <div class=\"dropdown-title\">\n                <span class=\"arrow-head-left\"></span>\n                <svg class=\"svg-selection\"><use href=\"#svg-selection\"></use></svg>\n                <p class=\"text\"><%=t('header.settings.gameplay')%></p>\n            </div>\n            <!-- Gameplay Boolean Options -->\n            <label class=\"boolean-option drag\">\n                <p class=\"text\"><%=t('header.settings.gameplay-drag')%></p>\n                <div class=\"switch\">\n                    <input type=\"checkbox\">\n                    <span></span>\n                </div>\n            </label>\n            <label class=\"boolean-option premove\">\n                <p class=\"text\"><%=t('header.settings.gameplay-premove')%></p>\n                <div class=\"switch\">\n                    <input type=\"checkbox\">\n                    <span></span>\n                </div>\n            </label>\n            <label class=\"boolean-option animations\">\n                <p class=\"text\"><%=t('header.settings.gameplay-animations')%></p>\n                <div class=\"switch\">\n                    <input type=\"checkbox\">\n                    <span></span>\n                </div>\n            </label>\n            <label class=\"boolean-option fast-transitions\">\n                <p class=\"text\"><%=t('header.settings.gameplay-fast_transitions')%></p>\n                <div class=\"switch\">\n                    <input type=\"checkbox\">\n                    <span></span>\n                </div>\n            </label>\n            <label class=\"boolean-option lingering-annotations\">\n                <p class=\"text\"><%=t('header.settings.gameplay-lingering_annotations')%></p>\n                <div class=\"switch\">\n                    <input type=\"checkbox\">\n                    <span></span>\n                </div>\n            </label>\n        </div>\n        <!-- Perspective -->\n        <div class=\"perspective-dropdown dropdown visibility-hidden\">\n            <div class=\"dropdown-title\">\n                <span class=\"arrow-head-left\"></span>\n                <svg class=\"svg-perspective\"><use href=\"#svg-perspective\"></use></svg>\n                <p class=\"text\"><%=t('header.settings.perspective')%></p>\n            </div>\n            <div class=\"perspective-options\">\n                <div class=\"perspective-option mouse-sensitivity\">   \n                    <div class=\"perspective-option-title\">\n                        <svg class=\"svg-mouse\"><use href=\"#svg-mouse\"></use></svg>\n                        <p><%=t('header.settings.perspective-mouse-sensitivity')%></p>\n                    </div>\n                    <div class=\"slider-container\">\n                        <input class=\"slider\" type=\"range\" min=\"25\" max=\"200\" value=\"100\">\n                        <span class=\"value\"></span>\n                    </div>\n                </div>\n                <div class=\"perspective-option fov\">   \n                    <div class=\"perspective-option-title\">\n                        <svg class=\"svg-camera\"><use href=\"#svg-camera\"></use></svg>\n                        <p><%=t('header.settings.perspective-fov')%></p>\n                    </div> \n                    <div class=\"slider-container\">\n                        <input class=\"slider\" type=\"range\" min=\"60\" max=\"150\" value=\"90\">\n                        <span class=\"value\"></span>\n                    </div>\n                    <div class=\"reset-default-container hidden\">\n                        <div class=\"reset-default\">\n                            <svg class=\"svg-undo\"><use href=\"#svg-undo\"></use></svg>\n                            <span><%=t('header.settings.reset-to-default')%></span>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </div>\n        <!-- Sound -->\n        <div class=\"sound-dropdown dropdown visibility-hidden\">\n            <div class=\"dropdown-title\">\n                <span class=\"arrow-head-left\"></span>\n                <svg class=\"svg-sound\"><use href=\"#svg-sound\"></use></svg>\n                <p class=\"text\"><%=t('header.settings.sound')%></p>\n            </div>\n            <div class=\"sound-options\">\n                <div class=\"sound-option master-volume\">\n                    <div class=\"sound-option-title\">\n                        <p><%=t('header.settings.sound-master-volume')%></p>\n                    </div>\n                    <div class=\"slider-container\">\n                        <input class=\"slider\" type=\"range\" min=\"0\" max=\"100\" value=\"100\">\n                        <span class=\"value\"></span>\n                    </div>\n                </div>\n\t\t\t\t<label class=\"boolean-option ambience\">\n\t\t\t\t\t<p class=\"text\"><%=t('header.settings.sound-ambience')%></p>\n\t\t\t\t\t<div class=\"switch\">\n\t\t\t\t\t\t<input type=\"checkbox\">\n\t\t\t\t\t\t<span></span>\n\t\t\t\t\t</div>\n\t\t\t\t</label>\n            </div>\n        </div>\n    </div>\n</header>\n\n<script defer src=\"/scripts/esm/components/header/header.js\" type=\"module\"></script>"
  },
  {
    "path": "src/client/views/createaccount.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"<%=language%>\" dir=\"<%=t('direction')%>\" >\n    <head>\n        <script>\n            const translations = <%-JSON.stringify({\n                ...t('create-account.javascript', {returnObjects: true}),\n                ...t('password-validation', {returnObjects: true})\n            })%>;\n        </script>\n        <meta charset=\"utf-8\" />\n        <meta http-equiv=\"x-ua-compatible\" content=\"ie=edge\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n        <title><%=t('create-account.title')%></title>\n        <link rel='stylesheet' href='/css/header.css'>\n        <link rel='stylesheet' href='/css/createaccount.css'>\n        <link rel='stylesheet' href='/css/footer.css'>\n        <link rel=\"icon\" href=\"/img/favicon/favicon-light.png\" media=\"(prefers-color-scheme: light)\" />\n        <link rel=\"icon\" href=\"/img/favicon/favicon-dark.png\" media=\"(prefers-color-scheme: dark)\" id=\"favicon\" />\n        <script defer src=\"/scripts/esm/views/createaccount.js\" type=\"module\"></script>\n    </head>\n    <body>\n        <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>\n        <main>\n            <div id=\"content\">\n                <h1 class=\"center\"><%=t('create-account.title')%></h1>\n                <form action=\"./createaccount\" method=\"POST\" class=\"center\" id=\"form\">\n                    <div class=\"formfield\">\n                        <div id=\"username-input-line\">\n                            <label for=\"username\"><%=t('create-account.username')%></label>\n                            <input id=\"username\" type=\"text\" name=\"username\" required minLength=\"3\" maxLength=\"20\" autocomplete=\"off\" autofocus>\n                        </div>\n                        <!-- This is where our script inserts an error division! -->\n                        <div id=\"emailinputline\">\n                            <div class=\"line\">\n                                <label for=\"email\"><%=t('create-account.email')%></label>\n                                <input id=\"email\" type=\"email\" name=\"email\" required minLength=\"3\" maxLength=\"320\" autocomplete=\"off\">\n                            </div>\n                            <%# Honeypot Bot Catcher: Any client that fills out this invisible field is a bot! %>\n                            <div class=\"line visually-hidden\">\n                                <label for=\"recovery\">Recovery</label>\n                                <input id=\"recovery\" type=\"text\" name=\"recovery\" autocomplete=\"off\" tabindex=\"-1\"/>\n                            </div>\n                        </div>\n                        <div id=\"password-input-line\">\n                            <div class=\"line\">\n                                <label for=\"password\"><%=t('create-account.password')%></label>\n                                <input id=\"password\" type=\"password\" name=\"password\" required minLength=\"6\">\n                            </div>\n                        </div>\n                        \n                    </div>\n                    <input type=\"submit\" value=\"<%=t('create-account.create_button')%>\" class=\"unavailable\" id=\"submit\">\n                    <p class=\"agreement\"><%=t('create-account.agreement.0')%><a href='/termsofservice?lng=<%= language %>' target='_blank'><%=t('create-account.agreement.1')%></a><%=t('create-account.agreement.2')%></p>\n                </form>\n            </div>\n        </main>\n        <%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %>\n    </body>\n</html>"
  },
  {
    "path": "src/client/views/credits.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"<%=language%>\" dir=\"<%=t('direction')%>\" >\n    <head>\n        <meta charset=\"utf-8\" />\n        <meta http-equiv=\"x-ua-compatible\" content=\"ie=edge\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n        <title><%=t('credits.title')%></title>\n        <link rel='stylesheet' href='/css/header.css'>\n        <link rel='stylesheet' href='/css/credits.css'>\n        <link rel='stylesheet' href='/css/footer.css'>\n        <link rel=\"icon\" href=\"/img/favicon/favicon-light.png\" media=\"(prefers-color-scheme: light)\" />\n        <link rel=\"icon\" href=\"/img/favicon/favicon-dark.png\" media=\"(prefers-color-scheme: dark)\" id=\"favicon\" />\n    </head>\n    <body>\n        <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>\n        <main>\n            <div id=\"content\">\n                <h1 class=\"center\"><%=t('credits.title')%></h1>\n                <p class=\"center\" style=\"margin-bottom:3em\"><%=t('credits.copyright')%></p>\n                \n                <h2><%=t('credits.variants_heading')%></h2>\n                <p><%=t('credits.variants_credits.0')%></p>\n                <p><%=t('credits.variants_credits.1')%></p>\n                <p><%=t('credits.variants_credits.2')%></p>\n                <p><%=t('credits.variants_credits.3')%></p>\n                <p><%=t('credits.variants_credits.4')%></p>\n                <p><%=t('credits.variants_credits.5')%></p>\n                <p><%=t('credits.variants_credits.6')%></p>\n                <p><%=t('credits.variants_credits.7')%></p>\n                <p><%=t('credits.variants_credits.8')%></p>\n                <p><%=t('credits.variants_credits.9')%></p>\n                <p><a href='https://math.colgate.edu/~integers/og2/og2.pdf' target='_blank'>Omega</a> <%=t('credits.variants_credits.10')%></p>\n                <p><a href='https://chess.stackexchange.com/questions/42480/checkmate-in-%cf%89%c2%b2-moves-with-finitely-many-pieces' target='_blank'>Omega^2</a> <%=t('credits.variants_credits.11')%></p>\n                <p><a href='https://math.colgate.edu/~integers/og2/og2.pdf' target='_blank'>Omega^3</a> <%=t('credits.variants_credits.12')%></p>\n                <p><a href='https://math.colgate.edu/~integers/rg4/rg4.pdf' target='_blank'>Omega^4</a> <%=t('credits.variants_credits.13')%></p>\n                <p><%=t('credits.variants_credits.14')%></p>\n                <p><%=t('credits.variants_credits.15')%></p>\n                <p><%=t('credits.variants_credits.16')%></p>\n                <p><%=t('credits.variants_credits.17')%></p>\n\n                <h2><%=t('credits.textures_heading')%></h2>\n                <p><a target='_blank' href='https://commons.wikimedia.org/wiki/Category:SVG_chess_pieces'>Cburnett</a> <%=t('credits.textures_licensed_under')%> <a target='_blank' href='https://creativecommons.org/licenses/by-sa/3.0/deed.en'>Creative Commons Attribution-Share Alike 3.0 Unported License</a>.</p>\n                <p><a target='_blank' href='https://greenchess.net/info.php?item=downloads'>Green Chess</a> <%=t('credits.textures_licensed_under')%> <a target='_blank' href='https://creativecommons.org/licenses/by-sa/3.0/deed.en'>Creative Commons Attribution-Share Alike 3.0</a></p>\n                <p><a target='_blank' href='https://github.com/pychess/pychess/blob/master/LICENSE'>Pychess</a> <%=t('credits.textures_licensed_under')%> <a target='_blank' href='https://www.gnu.org/licenses/gpl-3.0.en.html'>GNU General Public License</a></p>\n\n                <h2><%=t('credits.sounds_heading')%></h2>\n                <p><%=t('credits.sounds_credits.0.0')%> <a href='https://github.com/lichess-org/lila/blob/master/COPYING.md' target='_blank'>Lichess-org Lila</a> <%=t('credits.sounds_credits.0.1')%> <a href='https://www.gnu.org/licenses/agpl-3.0.en.html' target='_blank'>GNU Affero General Public License</a></p>\n                <p><%=t('credits.sounds_credits.1')%></p>\n\n                <h2><%=t('credits.code_heading')%></h2>\n                <p><a target='_blank' href='https://www.lcg.ufrj.br/WebGL/hws.edu-examples/doc-bump/gl-matrix.js.html'>High Performance Matrix & Vector Operations</a> <%=t('credits.code_credits.0')%></p>\n                <p><a target='_blank' href='https://github.com/tsevasa/infinite-chess-notation'>Infinite Chess Notation Converter</a> <%=t('credits.code_credits.1')%></p>\n                <p><a target='_blank' href='https://github.com/FirePlank/infinite-chess-engine'>HydroChess Engine</a> <%=t('credits.code_credits.2')%></p>\n\n                <h2><%=t('credits.language_heading')%></h2>\n                <p><%=t('credits.language_credits.0')%><a href=\"https://github.com/life-enjoyer\" target=\"_blank\"><%=t('credits.language_credits.1')%></a><%=t('credits.language_credits.2')%><a href=\"https://www.youtube.com/@cycygamingfrenglish\" target=\"_blank\"><%=t('credits.language_credits.3')%></a><%=t('credits.language_credits.4')%></p>\n                <p><%=t('credits.language_credits.5')%><a href=\"https://github.com/Heinrich-XIAO\" target=\"_blank\"><%=t('credits.language_credits.6')%></a><%=t('credits.language_credits.7')%></p>\n                <p><%=t('credits.language_credits.8')%><a href=\"https://github.com/Heinrich-XIAO\" target=\"_blank\"><%=t('credits.language_credits.9')%></a><%=t('credits.language_credits.10')%></p>\n                <p><%=t('credits.language_credits.11')%><a href=\"https://github.com/Apsurt\" target=\"_blank\"><%=t('credits.language_credits.12')%></a><%=t('credits.language_credits.13')%></p>\n                <p><%=t('credits.language_credits.14')%><a href=\"https://github.com/OEsqueleto\" target=\"_blank\"><%=t('credits.language_credits.15')%></a><%=t('credits.language_credits.16')%></p>\n                <p><%=t('credits.language_credits.17')%><a href=\"https://github.com/xa31er\" target=\"_blank\"><%=t('credits.language_credits.18')%></a><%=t('credits.language_credits.19')%></p>\n                <p><%=t('credits.language_credits.20')%><span><%=t('credits.language_credits.21')%></span><%=t('credits.language_credits.22')%></p>\n            </main>\n        <%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %>\n    </body>\n</html>"
  },
  {
    "path": "src/client/views/errors/400.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"<%=language%>\" dir=\"<%=t('direction')%>\" >\n\n<head>\n    <meta charset=\"UTF-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>400</title>\n    <link rel=\"stylesheet\" href=\"/css/404.css\" />\n</head>\n\n<body>\n    <h1>400 Bad Request</h1>\n    <p><%=t('error-pages.400_message')%></p>\n</body>\n\n</html>"
  },
  {
    "path": "src/client/views/errors/401.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"<%=language%>\" dir=\"<%=t('direction')%>\" >\n\n<head>\n    <meta charset=\"UTF-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>401</title>\n    <link rel=\"stylesheet\" href=\"/css/404.css\" />\n</head>\n\n<body>\n    <h1>401 Unauthorized</h1>\n</body>\n\n</html>"
  },
  {
    "path": "src/client/views/errors/404.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"<%=language%>\" dir=\"<%=t('direction')%>\" >\n\n<head>\n    <meta charset=\"UTF-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>404</title>\n    <link rel=\"stylesheet\" href=\"/css/404.css\" />\n</head>\n\n<body>\n    <h1>404 Not Found</h1>\n</body>\n\n</html>"
  },
  {
    "path": "src/client/views/errors/409.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"<%=language%>\" dir=\"<%=t('direction')%>\" >\n\n<head>\n    <meta charset=\"UTF-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>409</title>\n    <link rel=\"stylesheet\" href=\"/css/404.css\" />\n</head>\n\n<body>\n    <h1>409 Conflict</h1>\n    <p><%=t('error-pages.409_message.0')%><a href='/createaccount'><%=t('error-pages.409_message.1')%></a><%=t('error-pages.409_message.2')%></p>\n</body>\n\n</html>"
  },
  {
    "path": "src/client/views/errors/500.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"<%=language%>\" dir=\"<%=t('direction')%>\" >\n\n<head>\n    <meta charset=\"UTF-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>500</title>\n    <link rel=\"stylesheet\" href=\"/css/404.css\" />\n</head>\n\n<body>\n    <h1>500 Server Error</h1>\n    <p><%=t('error-pages.500_message')%></p>\n</body>\n\n</html>"
  },
  {
    "path": "src/client/views/guide.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"<%=language%>\" dir=\"<%=t('direction')%>\" >\n    <head>\n        <meta charset=\"utf-8\" />\n        <meta http-equiv=\"x-ua-compatible\" content=\"ie=edge\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n        <title><%=t('play.guide.title')%></title>\n        <link rel='stylesheet' href='/css/header.css'>\n        <link rel='stylesheet' href='/css/guide.css'>\n        <link rel='stylesheet' href='/css/footer.css'>\n        <link rel=\"icon\" href=\"/img/favicon/favicon-light.png\" media=\"(prefers-color-scheme: light)\" />\n        <link rel=\"icon\" href=\"/img/favicon/favicon-dark.png\" media=\"(prefers-color-scheme: dark)\" id=\"favicon\" />\n        <script defer src=\"/scripts/esm/views/guide.js\" type=\"module\"></script>\n    </head>\n    <body>\n        <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>\n        <main>\n            <div class=\"content\">\n                <div class=\"guide-header\">\n                    <a href=\"/play\" class=\"back-arrow\" title=\"Back to Play\">\n                        <svg viewBox=\"0 0 1024 1024\" class=\"icon\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#000000\" stroke=\"#000000\" stroke-width=\"46.08\"><g stroke-width=\"0\"></g><g stroke-linecap=\"round\" stroke-linejoin=\"round\"></g><g><path d=\"M768 903.232l-50.432 56.768L256 512l461.568-448 50.432 56.768L364.928 512z\" fill=\"#000000\"></path></g></svg>\n                    </a>\n                    <h1 class=\"center\"><%=t('play.guide.title')%></h1>\n                    <div class=\"back-arrow-spacer\"></div>\n                </div>\n\n                <h2><%=t('play.guide.rules')%></h2>\n                <hr class=\"line-break\">\n                <p><%=t('play.guide.rules_paragraphs.0')%></p>\n                <picture>\n                    <source srcset=\"/img/game/guide/promotionlines.avif\" type=\"image/avif\" />\n                    <source srcset=\"/img/game/guide/promotionlines.webp\" type=\"image/webp\" />\n                    <img loading=\"lazy\" src=\"/img/game/guide/promotionlines.png\" class=\"img-promotionlines\">\n                </picture>\n                <ul>\n                    <li><%=t('play.guide.rules_paragraphs.1')%></li>\n                    <li><%=t('play.guide.rules_paragraphs.2.0')%><em><%=t('play.guide.rules_paragraphs.2.1')%></em><%=t('play.guide.rules_paragraphs.2.2')%></li>\n                </ul>\n                <p><%=t('play.guide.rules_paragraphs.3')%></p>\n                <p><%=t('play.guide.rules_paragraphs.4')%></p>\n                <div class=\"clear-float\"></div>\n                \n                <h2><%=t('play.guide.careful_heading')%></h2>\n                <hr class=\"line-break\">\n                <picture>\n                    <source srcset=\"/img/game/guide/kingrookfork.avif\" type=\"image/avif\" />\n                    <source srcset=\"/img/game/guide/kingrookfork.webp\" type=\"image/webp\" />\n                    <img loading=\"lazy\" src=\"/img/game/guide/kingrookfork.png\" class=\"img-kingrookfork\">\n                </picture>\n                <p><%=t('play.guide.careful_paragraphs.0')%></p>\n                <p><%=t('play.guide.careful_paragraphs.1')%></p>\n                <div class=\"clear-float\"></div>\n\n                <h2><%=t('play.guide.controls_heading')%></h2>\n                <hr class=\"line-break\">\n                <p><%=t('play.guide.controls_paragraph')%></p>\n                <ul>\n                    <li><strong>WASD</strong><%=t('play.guide.keybinds.0')%></li>\n                    <li><strong><%=t('play.guide.keybinds.1.0')%></strong><%=t('play.guide.keybinds.1.1')%><strong><%=t('play.guide.keybinds.1.2')%></strong><%=t('play.guide.keybinds.1.3')%></li>\n                    <li><strong><%=t('play.guide.keybinds.2.0')%></strong><%=t('play.guide.keybinds.2.1')%></li>\n                    <picture>\n                        <source srcset=\"/img/game/guide/arrowindicators.avif\" type=\"image/avif\" />\n                        <source srcset=\"/img/game/guide/arrowindicators.webp\" type=\"image/webp\" />\n                        <img loading=\"lazy\" src=\"/img/game/guide/arrowindicators.png\" class=\"img-arrowindicators\">\n                    </picture>\n                    <li><strong><%=t('play.guide.keybinds.3.0')%></strong><%=t('play.guide.keybinds.3.1')%><strong><%=t('play.guide.keybinds.3.2')%></strong><%=t('play.guide.keybinds.3.3')%></li>\n                    <li><strong><%=t('play.guide.keybinds.4.0')%></strong><%=t('play.guide.keybinds.4.1')%></li>\n                    <li><strong>1</strong><%=t('play.guide.keybinds.5')%></li>\n                </ul>\n                <p><%=t('play.guide.controls_paragraph2')%></p>\n                <ul>\n                    <li><strong>R</strong><%=t('play.guide.keybinds_extra.0')%></li>\n                    <li><strong>N</strong><%=t('play.guide.keybinds_extra.1')%></li>\n                    <li><strong>M</strong><%=t('play.guide.keybinds_extra.2')%></li>\n                    <li><strong>P</strong><%=t('play.guide.keybinds_extra.3')%><strong>P</strong>.</li>\n                    <li><strong>`</strong><%=t('play.guide.keybinds_extra.4.0')%><strong>~</strong><%=t('play.guide.keybinds_extra.4.1')%></li>\n                </ul>\n                <div class=\"clear-float\"></div>\n\n                <h2><%=t('play.guide.fairy_heading')%></h2>\n                <hr class=\"line-break\">\n                <p><%=t('play.guide.fairy_paragraph')%></p>\n                <div id=\"fairy-pieces\" class=\"fairy-pieces\">\n                    <picture id=\"ch-img\" class=\"img-fairymoveset\">\n                        <source srcset=\"/img/game/guide/fairy/chancellor.avif\" type=\"image/avif\" />\n                        <source srcset=\"/img/game/guide/fairy/chancellor.webp\" type=\"image/webp\" />\n                        <img src=\"/img/game/guide/fairy/chancellor.png\">\n                    </picture>\n                    <picture id=\"ar-img\" class=\"img-fairymoveset hidden\">\n                        <source srcset=\"/img/game/guide/fairy/archbishop.avif\" type=\"image/avif\" />\n                        <source srcset=\"/img/game/guide/fairy/archbishop.webp\" type=\"image/webp\" />\n                        <img src=\"/img/game/guide/fairy/archbishop.png\">\n                    </picture>\n                    <picture id=\"am-img\" class=\"img-fairymoveset hidden\">\n                        <source srcset=\"/img/game/guide/fairy/amazon.avif\" type=\"image/avif\" />\n                        <source srcset=\"/img/game/guide/fairy/amazon.webp\" type=\"image/webp\" />\n                        <img src=\"/img/game/guide/fairy/amazon.png\">\n                    </picture>\n                    <picture id=\"gu-img\" class=\"img-fairymoveset hidden\">\n                        <source srcset=\"/img/game/guide/fairy/guard.avif\" type=\"image/avif\" />\n                        <source srcset=\"/img/game/guide/fairy/guard.webp\" type=\"image/webp\" />\n                        <img src=\"/img/game/guide/fairy/guard.png\">\n                    </picture>\n                    <picture id=\"ha-img\" class=\"img-fairymoveset hidden\">\n                        <source srcset=\"/img/game/guide/fairy/hawk.avif\" type=\"image/avif\" />\n                        <source srcset=\"/img/game/guide/fairy/hawk.webp\" type=\"image/webp\" />\n                        <img src=\"/img/game/guide/fairy/hawk.png\">\n                    </picture>\n                    <picture id=\"ce-img\" class=\"img-fairymoveset hidden\">\n                        <source srcset=\"/img/game/guide/fairy/centaur.avif\" type=\"image/avif\" />\n                        <source srcset=\"/img/game/guide/fairy/centaur.webp\" type=\"image/webp\" />\n                        <img src=\"/img/game/guide/fairy/centaur.png\">\n                    </picture>\n                    <picture id=\"nr-img\" class=\"img-fairymoveset hidden\">\n                        <source srcset=\"/img/game/guide/fairy/knightrider.avif\" type=\"image/avif\" />\n                        <source srcset=\"/img/game/guide/fairy/knightrider.webp\" type=\"image/webp\" />\n                        <img src=\"/img/game/guide/fairy/knightrider.png\">\n                    </picture>\n                    <picture id=\"hu-img\" class=\"img-fairymoveset hidden\">\n                        <source srcset=\"/img/game/guide/fairy/huygen.avif\" type=\"image/avif\" />\n                        <source srcset=\"/img/game/guide/fairy/huygen.webp\" type=\"image/webp\" />\n                        <img src=\"/img/game/guide/fairy/huygen.png\">\n                    </picture>\n                    <picture id=\"ro-img\" class=\"img-fairymoveset hidden\">\n                        <source srcset=\"/img/game/guide/fairy/rose.avif\" type=\"image/avif\" />\n                        <source srcset=\"/img/game/guide/fairy/rose.webp\" type=\"image/webp\" />\n                        <img src=\"/img/game/guide/fairy/rose.png\">\n                    </picture>\n                    <picture id=\"ob-img\" class=\"img-fairymoveset hidden\">\n                        <source srcset=\"/img/game/guide/fairy/obstacle.avif\" type=\"image/avif\" />\n                        <source srcset=\"/img/game/guide/fairy/obstacle.webp\" type=\"image/webp\" />\n                        <img src=\"/img/game/guide/fairy/obstacle.png\">\n                    </picture>\n                    <picture id=\"vo-img\" class=\"img-fairymoveset hidden\">\n                        <source srcset=\"/img/game/guide/fairy/void.avif\" type=\"image/avif\" />\n                        <source srcset=\"/img/game/guide/fairy/void.webp\" type=\"image/webp\" />\n                        <img src=\"/img/game/guide/fairy/void.png\">\n                    </picture>\n                    <div class=\"fairy-card-container\">\n                        <div id=\"fairy-back\" class=\"left-arrow opacity-0_25 unselectable\">\n                            <svg viewBox=\"0 0 1024 1024\" class=\"icon\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#000000\" stroke=\"#000000\" stroke-width=\"46.08\"><g id=\"SVGRepo_bgCarrier\" stroke-width=\"0\"></g><g id=\"SVGRepo_tracerCarrier\" stroke-linecap=\"round\" stroke-linejoin=\"round\"></g><g id=\"SVGRepo_iconCarrier\"><path d=\"M768 903.232l-50.432 56.768L256 512l461.568-448 50.432 56.768L364.928 512z\" fill=\"#000000\"></path></g></svg>\n                        </div>\n                        <div id=\"fairy-card\" class=\"fairy-card\">\n                            <div class=\"space-1\"></div>\n                            <div id=\"ch-desc\" class=\"fairy-card-desc\">\n                                <p class=\"fairy-card-title\"><%=t('play.guide.pieces.chancellor.name')%></p>\n                                <p class=\"fairy-card-description\"><%=t('play.guide.pieces.chancellor.description')%></p>\n                            </div>\n                            <div id=\"ar-desc\" class=\"fairy-card-desc hidden\">\n                                <p class=\"fairy-card-title\"><%=t('play.guide.pieces.archbishop.name')%></p>\n                                <p class=\"fairy-card-description\"><%=t('play.guide.pieces.archbishop.description')%></p>\n                            </div>\n                            <div id=\"am-desc\" class=\"fairy-card-desc hidden\">\n                                <p class=\"fairy-card-title\"><%=t('play.guide.pieces.amazon.name')%></p>\n                                <p class=\"fairy-card-description\"><%=t('play.guide.pieces.amazon.description')%></p>\n                            </div>\n                            <div id=\"gu-desc\" class=\"fairy-card-desc hidden\">\n                                <p class=\"fairy-card-title\"><%=t('play.guide.pieces.guard.name')%></p>\n                                <p class=\"fairy-card-description\"><%=t('play.guide.pieces.guard.description')%></p>\n                            </div>\n                            <div id=\"ha-desc\" class=\"fairy-card-desc hidden\">\n                                <p class=\"fairy-card-title\"><%=t('play.guide.pieces.hawk.name')%></p>\n                                <p class=\"fairy-card-description\"><%=t('play.guide.pieces.hawk.description')%></p>\n                            </div>\n                            <div id=\"ce-desc\" class=\"fairy-card-desc hidden\">\n                                <p class=\"fairy-card-title\"><%=t('play.guide.pieces.centaur.name')%></p>\n                                <p class=\"fairy-card-description\"><%=t('play.guide.pieces.centaur.description')%></p>\n                            </div>\n                            <div id=\"nr-desc\" class=\"fairy-card-desc hidden\">\n                                <p class=\"fairy-card-title\"><%=t('play.guide.pieces.knightrider.name')%></p>\n                                <p class=\"fairy-card-description\"><%=t('play.guide.pieces.knightrider.description')%></p>\n                            </div>\n                            <div id=\"hu-desc\" class=\"fairy-card-desc hidden\">\n                                <p class=\"fairy-card-title\"><%=t('play.guide.pieces.huygen.name')%></p>\n                                <p class=\"fairy-card-description\"><%=t('play.guide.pieces.huygen.description')%></p>\n                            </div>\n                            <div id=\"ro-desc\" class=\"fairy-card-desc hidden\">\n                                <p class=\"fairy-card-title\"><%=t('play.guide.pieces.rose.name')%></p>\n                                <p class=\"fairy-card-description\"><%=t('play.guide.pieces.rose.description')%></p>\n                            </div>\n                            <div id=\"ob-desc\" class=\"fairy-card-desc hidden\">\n                                <p class=\"fairy-card-title\"><%=t('play.guide.pieces.obstacle.name')%></p>\n                                <p class=\"fairy-card-description\"><%=t('play.guide.pieces.obstacle.description')%></p>\n                            </div>\n                            <div id=\"vo-desc\" class=\"fairy-card-desc hidden\">\n                                <p class=\"fairy-card-title\"><%=t('play.guide.pieces.void.name')%></p>\n                                <p class=\"fairy-card-description\"><%=t('play.guide.pieces.void.description')%></p>\n                            </div>\n                            <div class=\"space-2\"></div>\n                        </div>\n                        <div id=\"fairy-forward\"  class=\"right-arrow unselectable\">\n                            <svg viewBox=\"0 0 1024 1024\" class=\"icon\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#000000\" stroke=\"#000000\" stroke-width=\"46.08\"><g stroke-width=\"0\"></g><g stroke-linecap=\"round\" stroke-linejoin=\"round\"></g><g transform=\"scale(-1, 1) translate(-1035,0)\"><path d=\"M768 903.232l-50.432 56.768L256 512l461.568-448 50.432 56.768L364.928 512z\" fill=\"#000000\"></path></g></svg>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </main>\n        <%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %>\n    </body>\n</html>\n"
  },
  {
    "path": "src/client/views/icnvalidator.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"UTF-8\" />\n\t\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n\t\t<title>ICN Game Notation Validator</title>\n\t\t<link rel=\"stylesheet\" href=\"/css/icnvalidator.css\" />\n\t</head>\n\t<body>\n\t\t<div class=\"container\">\n\t\t\t<h1>ICN Game Notation Validator</h1>\n\t\t\t<p>\n\t\t\t\tThis tool validates Infinite Chess Notation (ICN) games outputed from the HydroChess\n\t\t\t\tSPRT. Upload a JSON file to check if all game notations are valid syntax, don't\n\t\t\t\tcrash, contain no illegal moves, and have the expected endings.\n\t\t\t</p>\n\n\t\t\t<div class=\"upload-section\" id=\"upload-section\">\n\t\t\t\t<h2>Upload JSON File</h2>\n\t\t\t\t<p>\n\t\t\t\t\tClick to select or drag and drop a JSON file containing an array of game\n\t\t\t\t\tnotations\n\t\t\t\t</p>\n\t\t\t\t<label for=\"file-input\" class=\"file-label\">Choose File</label>\n\t\t\t\t<input type=\"file\" id=\"file-input\" accept=\".json\" />\n\t\t\t\t<p id=\"file-name\" style=\"margin-top: 1rem; color: var(--accent-color)\"></p>\n\t\t\t</div>\n\n\t\t\t<div class=\"progress-section\" id=\"progress-section\">\n\t\t\t\t<h2>Processing...</h2>\n\t\t\t\t<div class=\"progress-bar\">\n\t\t\t\t\t<div class=\"progress-fill\" id=\"progress-fill\">0%</div>\n\t\t\t\t</div>\n\t\t\t\t<p id=\"progress-text\">Processing game 0 of 0</p>\n\t\t\t</div>\n\n\t\t\t<div class=\"summary-section\" id=\"summary-section\">\n\t\t\t\t<h2>Validation Summary</h2>\n\t\t\t\t<div class=\"summary-hero\">\n\t\t\t\t\t<div class=\"hero-stat\">\n\t\t\t\t\t\t<span class=\"hero-value\" id=\"pass-ratio\">0 / 0</span>\n\t\t\t\t\t\t<span class=\"hero-label\">GAMES PASSED</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"hero-divider\"></div>\n\t\t\t\t\t<div class=\"hero-stat\">\n\t\t\t\t\t\t<span class=\"hero-value\" id=\"pass-percentage\">0%</span>\n\t\t\t\t\t\t<span class=\"hero-label\">SUCCESS RATE</span>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"stat-grid\">\n\t\t\t\t\t<div class=\"stat-card\">\n\t\t\t\t\t\t<h3>ICN Converter Errors</h3>\n\t\t\t\t\t\t<p class=\"stat-value warning\" id=\"icnconverter-errors\">0</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"stat-card\">\n\t\t\t\t\t\t<h3>Formulator Errors</h3>\n\t\t\t\t\t\t<p class=\"stat-value error\" id=\"formulator-errors\">0</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"stat-card\">\n\t\t\t\t\t\t<h3>Illegal Move Errors</h3>\n\t\t\t\t\t\t<p class=\"stat-value error\" id=\"illegal-move-errors\">0</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"stat-card\">\n\t\t\t\t\t\t<h3>Termination Mismatch</h3>\n\t\t\t\t\t\t<p class=\"stat-value error\" id=\"termination-mismatch-errors\">0</p>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div class=\"details-section\" id=\"variant-section\" style=\"display: none\">\n\t\t\t\t<h2>Errors by Variant</h2>\n\t\t\t\t<div class=\"variant-stats\" id=\"variant-stats\"></div>\n\t\t\t</div>\n\n\t\t\t<div class=\"details-section\" id=\"errors-section\" style=\"display: none\">\n\t\t\t\t<h2>Error Details</h2>\n\t\t\t\t<div class=\"error-list\" id=\"error-list\"></div>\n\t\t\t</div>\n\n\t\t\t<div class=\"log-section\">\n\t\t\t\t<h2>Activity Log</h2>\n\t\t\t\t<div class=\"log-output\" id=\"log-output\"></div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<script defer src=\"scripts/esm/views/icnvalidator.js\" type=\"module\"></script>\n\t</body>\n</html>\n"
  },
  {
    "path": "src/client/views/index.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"<%=language%>\" dir=\"<%=t('direction')%>\" >\n    <head>\n        <meta charset=\"utf-8\" />\n        <meta http-equiv=\"x-ua-compatible\" content=\"ie=edge\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n        <title><%=t('index.title')%></title>\n        <link rel='stylesheet' href='/css/header.css'>\n        <link rel='stylesheet' href='/css/index.css'>\n        <link rel='stylesheet' href='/css/footer.css'>\n        <link rel=\"icon\" href=\"/img/favicon/favicon-light.png\" media=\"(prefers-color-scheme: light)\" />\n        <link rel=\"icon\" href=\"/img/favicon/favicon-dark.png\" media=\"(prefers-color-scheme: dark)\" id=\"favicon\" />\n        <script>\n            const translations = <%-JSON.stringify({\n                ...t('index.javascript', {returnObjects: true})\n            })%>;\n        </script>\n        <script defer src=\"/scripts/esm/views/index.js\" type=\"module\"></script>\n    </head>\n    <body>\n        <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>\n        <main>\n            <div class=\"content\">\n                <div class=\"logo\">\n                    <picture>\n                        <source srcset=\"/img/king_w.avif\" type=\"image/avif\" />\n                        <source srcset=\"/img/king_w.webp\" type=\"image/webp\" />\n                        <img src=\"/img/king_w.png\" alt=\"White chess king\">\n                    </picture>\n                    <h1 class=\"center\">Infinite Chess</h1>\n                    <picture>\n                        <source srcset=\"/img/queen_w.avif\" type=\"image/avif\" />\n                        <source srcset=\"/img/queen_w.webp\" type=\"image/webp\" />\n                        <img src=\"/img/queen_w.png\" alt=\"White chess king\">\n                    </picture>\n                    <p class=\"center\"><%=t('index.secondary_title')%></p>\n                </div>\n                <div class=\"center\">\n                    <a href=\"/play?lng=<%= language %>\" class=\"play-button unselectable\"><%=t('header.play')%></a>\n                </div>\n                <div class=\"center\">\n                    <iframe src=\"https://www.youtube.com/embed/rav29N0-h2c?si=iFIw70CDr9dILX1O\" title=\"YouTube video: I Made Chess, but It's Infinite\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen></iframe>\n                </div>\n                <h2><%=t('index.what_is_it_title')%></h2>\n                <p><%-t('index.what_is_it_pargaraphs', { joinArrays: '</p><p>' })%></p>\n                <h3><%=t('index.how_to_title')%></h3>\n                <p><%=t('index.how_to_paragraph.0')%><a href='/play?lng=<%= language %>'><%=t('index.how_to_paragraph.1')%></a><%=t('index.how_to_paragraph.2')%></p>\n                \n                <br>\n                <h3 class=\"center\"><%=t('index.about_title')%></h3>\n                <!-- <h3>About the Project</h3> -->\n                <p><%=t('index.about_paragraphs.0')%></p>\n                <p><%=t('index.about_paragraphs.1.0')%><a target='_blank' href='https://www.patreon.com/Naviary'><%=t('index.about_paragraphs.1.1')%></a><%=t('index.about_paragraphs.1.2')%></p>\n\n                <br>\n                <h3 class=\"center\"><%=t('index.patreon_title')%></h3>\n                <div class=\"patreon-container\">\n                    <p>Andreas</p>\n                    <p>Mauer01</p>\n                    <p>Meni Rosenfeld</p>\n                    <p>Uncle Dave</p>\n                    <p><spam class=\"IM\">IM</spam> Luke Harmon-Vellotti</p>\n                    <p>KnightBeforeLast</p> <!-- Poryhedron -->\n                    <p>Marillia</p>\n                    <p>Elliot Glazer</p>\n                    <p>AbyssalCryptid</p>\n                    <p>Marsgreekgod</p>\n                    <p>Or Ben Naim</p>\n                </div>\n                <div class=\"patreon-container\">\n                    <p>EmmaBellHelium</p>\n                    <p>Mark Wiemer</p>\n                    <p>Tommy Nordman</p>\n                    <p>Joe & Rafi Moed</p> <!-- Joe Moed-->\n                </div>\n                <h3 class=\"center\"><%=t('index.github_title')%></h3>\n                <div class=\"github-container\">\n                    <!-- Example contributor, appended by javascript -->\n                    <!-- <a href=\"https://github.com/Naviary2\">\n                        <img src=\"https://avatars.githubusercontent.com/u/163621561?v=4\">\n                        <div class=\"github-stats\">\n                            <p class=\"name\">Naviary2</p>\n                            <p class=\"contribution-count\">1568 contributions</p>\n                        </div>\n                    </a> -->\n                </div>\n            </div>\n        </main>\n        <%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %>\n    </body>\n</html>"
  },
  {
    "path": "src/client/views/leaderboard.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"<%=language%>\" dir=\"<%=t('direction')%>\" >\n    <head>\n        <script>\n            const translations = <%-JSON.stringify({\n                ...t('leaderboard.javascript', {returnObjects: true}),\n                ...t('play.play-menu', {returnObjects: true})\n            })%>;\n        </script>\n        <meta charset=\"utf-8\" />\n        <meta http-equiv=\"x-ua-compatible\" content=\"ie=edge\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n        <title><%=t('leaderboard.title')%></title>\n        <link rel='stylesheet' href='/css/header.css'>\n        <link rel='stylesheet' href='/css/leaderboard.css'>\n        <link rel='stylesheet' href='/css/footer.css'>\n        <link rel=\"icon\" href=\"/img/favicon/favicon-light.png\" media=\"(prefers-color-scheme: light)\" />\n        <link rel=\"icon\" href=\"/img/favicon/favicon-dark.png\" media=\"(prefers-color-scheme: dark)\" id=\"favicon\" />\n        <script defer src=\"/scripts/esm/views/leaderboard.js\" type=\"module\"></script>\n    </head>\n    <body>\n        <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>\n        <main>\n            <div class=\"content\">\n                <h1 class=\"center\"><%=t('leaderboard.title')%></h1>\n                <hr>\n                <div id=\"user_ranking_container\" class=\"center\">\n                    <span id=\"user_ranking_text\" class=\"hidden\"><%=t('leaderboard.your_global_ranking')%></span><span id=\"user_ranking\"></span>\n                </div>\n                <div class=\"center\" id=\"leaderboard-table\"></div>\n                <div class=\"center button-wrapper\">\n                    <button id=\"show_more_button\" class=\"unselectable hidden\"><%=t('leaderboard.show_more')%></button>\n                </div>\n                <p><%=t('leaderboard.inactive_players.0')%><%=ratingDeviationUncertaintyThreshold%><%=t('leaderboard.inactive_players.1')%></p>\n                <p id=\"supported-variants\"></p>\n            </div>\n        </main>\n        <%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %>\n    </body>\n</html>\n"
  },
  {
    "path": "src/client/views/login.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"<%=language%>\" dir=\"<%=t('direction')%>\" >\n    <head>\n        <script>\n            const translations = <%-JSON.stringify({\n                ...t('login.javascript', {returnObjects: true})\n            })%>;\n        </script>\n        <meta charset=\"utf-8\" />\n        <meta http-equiv=\"x-ua-compatible\" content=\"ie=edge\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n        <title><%=t('login.title')%></title>\n        <link rel='stylesheet' href='/css/header.css'>\n        <link rel='stylesheet' href='/css/login.css'>\n        <link rel='stylesheet' href='/css/footer.css'>\n        <link rel=\"icon\" href=\"/img/favicon/favicon-light.png\" media=\"(prefers-color-scheme: light)\" />\n        <link rel=\"icon\" href=\"/img/favicon/favicon-dark.png\" media=\"(prefers-color-scheme: dark)\" id=\"favicon\" />\n        <script defer src=\"/scripts/esm/views/login.js\" type=\"module\"></script>\n    </head>\n    <body>\n        <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>\n        <main>\n            <div id=\"content\">                  \n                <h1 class=\"center\"><%=t('login.title')%></h1>\n\n                <!-- LOGIN FORM -->\n                <form method=\"POST\" class=\"center\" id=\"login-form\">\n                    <!-- Container for the Login Fields (Initially visible) -->\n                    <div id=\"login-form-container\">\n                        <div class=\"formfield\">\n                            <div id=\"username-input-line\">\n                                <label for=\"username\"><%=t('login.username')%></label>\n                                <input id=\"username\" type=\"text\" name=\"username\" required maxLength=\"40\" autocomplete=\"off\" autofocus>\n                            </div>\n                            <div id=\"password-input-line\">\n                                <label for=\"password\"><%=t('login.password')%></label>\n                                <input id=\"password\" type=\"password\" name=\"password\" required>\n                            </div>\n                            <!-- This is where our script inserts a login error division! (relative to password-input-line) -->\n                        </div>\n                        <!-- The main login submit button -->\n                        <input type=\"submit\" value=\"<%=t('login.login_button')%>\" class=\"unavailable\" id=\"submit\">\n                    </div>\n                </form>\n\n                <!-- FORGOT PASSWORD FORM (Initially hidden) -->\n                <form method=\"POST\" class=\"center hidden\" id=\"forgot-password-form\">\n                    <!-- Container for the Forgot Password Fields -->\n                    <div id=\"forgot-form-container\"> <!-- This outer div might become redundant if the form itself is hidden/shown -->\n                                                    <!-- For now, keep it for minimal JS change, but you could simplify -->\n                        <p class=\"form-instruction\"><%=t('login.forgot_instruction')%></p>\n                        <div class=\"formfield\">\n                            <div id=\"email-input-line\">\n                                <label for=\"forgot-email\"><%=t('create-account.email')%></label>\n                                <input id=\"forgot-email\" type=\"email\" name=\"email\" required maxLength=\"100\" autocomplete=\"off\">\n                            </div>\n                            <!-- This is where our script will insert a forgot password error/success division! (relative to email-input-line) -->\n                        </div>\n                        <!-- The submit button for the forgot password form -->\n                        <input type=\"submit\" value=\"<%=t('login.send_reset_link')%>\" class=\"unavailable\" id=\"forgot-submit\">\n                    </div>\n                </form>\n\n                <!-- The \"Forgot Password?\" and \"Back to Login\" links - These control visibility of the FORMS -->\n                <p class=\"forgot-link-container center\">\n                    <a href=\"#\" id=\"forgot-link\"><%=t('login.forgot_question')%></a>\n                    <a href=\"#\" id=\"back-to-login-link\" class=\"hidden\"><%=t('login.back_to_login')%></a>\n                </p>\n            </div>\n        </main>\n        <%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %>\n    </body>\n</html>"
  },
  {
    "path": "src/client/views/member.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"<%=language%>\" dir=\"<%=t('direction')%>\" >\n    <head>\n        <script>\n            const translations = <%-JSON.stringify({\n                ...t('member.javascript', {returnObjects: true})\n            })%>;\n        </script>\n        <meta charset=\"utf-8\" />\n        <meta http-equiv=\"x-ua-compatible\" content=\"ie=edge\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n        <title><%=t('member.title')%></title>\n        <link rel='stylesheet' href='/css/header.css'>\n        <link rel='stylesheet' href='/css/member.css'>\n        <link rel='stylesheet' href='/css/footer.css'>\n        <link rel=\"icon\" href=\"/img/favicon/favicon-light.png\" media=\"(prefers-color-scheme: light)\" />\n        <link rel=\"icon\" href=\"/img/favicon/favicon-dark.png\" media=\"(prefers-color-scheme: dark)\" id=\"favicon\" />\n        <script defer src=\"/scripts/esm/views/member.js\" type=\"module\"></script>\n    </head>\n    <body>\n        <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>\n        <main>\n            <div id=\"content\">                  \n                <div id=\"verifyerror\" class=\"hidden\">\n                    <h2 class=\"red center\"><%=t('member.verify_message')%></h2>\n                    <p class=\"center\"><%=t('member.resend_message.0')%><span id=\"sendemail\" class=\"underline\"><%=t('member.resend_message.1')%></span><%=t('member.resend_message.2')%><a href=\"mailto:support@infinitechess.org\"><%=t('member.resend_message.3')%></a></p>\n                </div>\n                <h2 id=\"verifyconfirm\" class=\"hidden green center\"><%=t('member.verify_confirm')%></h2>\n                <div class=\"member\">\n                    <picture>\n                        <source srcset=\"/img/member_default.webp\" type=\"image/webp\" />\n                        <source srcset=\"/img/member_default.avif\" type=\"image/avif\" />\n                        <img src=\"/img/member_default.png\" alt=\"Blank profile image\">\n                    </picture>\n                    <div class=\"membername-container\">\n                        <h1 id=\"membername\"></h1>\n                        <div id=\"badgelist\">\n                            <picture id=\"checkmate-badge-bronze\" class=\"badge hidden tooltip-u\" data-tooltip=\"<%=t('member.badge-tooltips.checkmate_bronze')%>\">\n                                <div class=\"shine-clockwise\"></div>\n                                <div class=\"shine-anticlockwise\"></div>\n                                <source srcset=\"/img/badges/checkmate-badge-bronze.webp\" type=\"image/webp\" />\n                                <source srcset=\"/img/badges/checkmate-badge-bronze.avif\" type=\"image/avif\" />\n                                <img loading=\"lazy\" src=\"/img/badges/checkmate-badge-bronze.png\">\n                            </picture>\n                            <picture id=\"checkmate-badge-silver\" class=\"badge hidden tooltip-u\" data-tooltip=\"<%=t('member.badge-tooltips.checkmate_silver')%>\">\n                                <div class=\"shine-clockwise\"></div>\n                                <div class=\"shine-anticlockwise\"></div>\n                                <source srcset=\"/img/badges/checkmate-badge-silver.webp\" type=\"image/webp\" />\n                                <source srcset=\"/img/badges/checkmate-badge-silver.avif\" type=\"image/avif\" />\n                                <img loading=\"lazy\" src=\"/img/badges/checkmate-badge-silver.png\">\n                            </picture>\n                            <picture id=\"checkmate-badge-gold\" class=\"badge hidden tooltip-u\" data-tooltip=\"<%=t('member.badge-tooltips.checkmate_gold')%>\">\n                                <div class=\"shine-clockwise\"></div>\n                                <div class=\"shine-anticlockwise\"></div>\n                                <source srcset=\"/img/badges/checkmate-badge-gold.webp\" type=\"image/webp\" />\n                                <source srcset=\"/img/badges/checkmate-badge-gold.avif\" type=\"image/avif\" />\n                                <img loading=\"lazy\" src=\"/img/badges/checkmate-badge-gold.png\">\n                            </picture>\n                        </div>\n                    </div>\n                </div>\n                <section class=\"stats\" class=\"center\">\n                    <p><strong><%=t('member.joined')%> </strong><span id=\"joined\"></span></p>\n                    <p><strong><%=t('member.seen')%> </strong><span id=\"seen\"></span></p>\n                </section>\n                <section class=\"stats\" class=\"center\">\n                    <p><strong><%=t('member.infinity_leaderboard_position')%> </strong><span id=\"infinity_leaderboard_position\"></span></p>\n                    <p><strong><%=t('member.ranked_elo')%> </strong><span id=\"ranked_elo\"></span></p>\n                    <p><strong><%=t('member.infinity_leaderboard_rating_deviation')%> </strong><span id=\"infinity_leaderboard_rating_deviation\"></span></p>\n                </section>\n                <section class=\"stats\" class=\"center\">\n                    <p><strong><%=t('member.practice_progress')%> </strong><span id=\"practice_progress\"></span></p>\n                </section>\n                <div id=\"content-container\">\n                    <div style=\"flex-grow: 1;\">\n                        <button id=\"show-account-info\" class=\"hidden action-button\"><%=t('member.reveal_info')%></button>\n                        <section id=\"accountinfo\" class=\"hidden\">\n                            <h6 class=\"center\"><%=t('member.account_info_heading')%></h6>\n                            <p><strong><%=t('member.email')%> </strong><spam id=\"email\"> - </spam></p>\n                        </section>\n                    </div>\n                    <div style=\"flex-grow: 0;\">\n                        <button id=\"delete-account\" class=\"hidden action-button\"><%=t('member.delete_account')%></button>\n                    </div>\n                </div>\n            </div>\n        </main>\n        <%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %>\n    </body>\n</html>\n"
  },
  {
    "path": "src/client/views/news.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"<%=language%>\" dir=\"<%=t('direction')%>\" >\n    <head>\n        <meta charset=\"utf-8\" />\n        <meta http-equiv=\"x-ua-compatible\" content=\"ie=edge\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n        <title><%=t('news.title')%></title>\n        <link rel='stylesheet' href='/css/header.css'>\n        <link rel='stylesheet' href='/css/news.css'>\n        <link rel='stylesheet' href='/css/footer.css'>\n        <link rel=\"icon\" href=\"/img/favicon/favicon-light.png\" media=\"(prefers-color-scheme: light)\" />\n        <link rel=\"icon\" href=\"/img/favicon/favicon-dark.png\" media=\"(prefers-color-scheme: dark)\" id=\"favicon\" />\n        <script defer src=\"/scripts/esm/views/news.js\" type=\"module\"></script>\n    </head>\n    <body>\n        <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>\n        <main>\n            <div class=\"content\">                  \n                <h1 class=\"center\"><%=t('news.title')%></h1>\n                <hr>\n                <div class=\"news-posts\">\n                    <%-newsHTML%>\n                </div>\n                <p class=\"body\"><%=t('news.more_dev_logs.0')%><a target='_blank' href='https://discord.gg/NFWFGZeNh5'><%=t('news.more_dev_logs.1')%></a><%=t('news.more_dev_logs.2')%><a href='https://www.chess.com/forum/view/chess-variants/infinite-chess-app-devlogs-and-more' target='_blank'><%=t('news.more_dev_logs.3')%></a></p>\n            </div>\n        </main>\n        <%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %>\n    </body>\n</html>\n"
  },
  {
    "path": "src/client/views/play.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"<%=language%>\" dir=\"<%=t('direction')%>\" >\n    <head>\n        <script>\n            const translations = <%-JSON.stringify({\n                ...t('play.javascript', {returnObjects: true}),\n                ...t('play.play-menu', {returnObjects: true})\n            })%>;\n        </script>\n        <!-- Htmlscript inserted inline into the html is in charge of managing the loading animation before the page is fully loaded. -->\n        <script><%- include(`${distfolder}/client/scripts/cjs/game/htmlscript.js`) %></script>\n\n        <meta charset=\"utf-8\" />\n        <meta http-equiv=\"x-ua-compatible\" content=\"ie=edge\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, user-scalable = no\" />\n        <title><%=t('play.title')%></title>\n        <link rel='stylesheet' href='/css/header.css'>\n        <link rel='stylesheet' href='/css/play.css' onerror=\"htmlscript.callback_LoadingError(event)\" onload=\"htmlscript.removeOnerror.call(this);\"> <!-- NEEDS TO BE ABOVE JAVASCRIPT-INJECTED STYLINGS SO THAT TAKES PRECEDENCE -->\n        <link rel=\"icon\" href=\"/img/favicon/favicon-light.png\" media=\"(prefers-color-scheme: light)\" />\n        <link rel=\"icon\" href=\"/img/favicon/favicon-dark.png\" media=\"(prefers-color-scheme: dark)\" id=\"favicon\" />\n        <!-- Custom fonts: Merriweather, Open Sans -->\n        <link href=\"https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,400;0,700;0,900;1,400;1,900&family=Open+Sans:ital,wght@0,400;0,700;0,800;1,400;1,700;1,800&display=swap\" rel=\"stylesheet\">\n        <script defer src=\"/scripts/esm/game/main.js\" type=\"module\" onerror=\"htmlscript.callback_LoadingError(event)\" onload=\"(() => { htmlscript.removeOnerror.call(this); })()\"></script>\n        <!-- Javascript will add future styles in here. NEEDS TO BE BELOW OTHER STYLESHEET SO THIS TAKES PRECEDENCE -->\n        <style id=\"style\"></style>\n    </head>\n    <body>\n        <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>\n\n        <!-- SVG definitions -->\n        <div class=\"hidden\">\n            <!-- Play Button (Sideways triangle) -->\n            <svg>\n                <symbol id=\"svg-load\" fill=\"#333\" viewBox=\"-1.5 -1 9 9\">\n                    <path d=\"M5.495 2.573 1.5.143C.832-.266 0 .25 0 1.068V5.93c0 .82.832 1.333 1.5.927l3.995-2.43c.673-.41.673-1.445 0-1.855\" fill-rule=\"evenodd\"/>\n                </symbol>\n            </svg>\n            <!-- Cloud Save Button -->\n            <svg>\n                <symbol id=\"svg-cloud-save\" fill=\"#333\" viewBox=\"0 0 2400 2400\">\n                    <metadata>\n                        Author: Iconsax\n                        License: MIT License\n                        Source: https://github.com/microsoft/fluentui-system-icons/\n                    </metadata>\n                    <path d=\"M2174 1290a560 560 0 00-405-386c-55-250-210-430-428-497a688 688 0 00-687 180 632 632 0 00-143 610c-200 50-300 216-310 375v30a410 410 0 00 396 420h1038c142 0 278-53 382-148a550 550 0 00 157-583Z\"/>\n                </symbol>\n            </svg>\n            <!-- Trash Can -->\n            <svg>\n                <metadata>\n                    Author: Solar Icons\n                    License: CC Attribution License\n                    Source: https://www.figma.com/de-de/community/file/1166831539721848736/solar-icons-set/\n                </metadata>\n                <symbol id=\"svg-delete\" fill=\"#333\" viewBox=\"0 0 2400 2400\">\n                    <path d=\"M300 639c0-49 35-88 77-88h267c53-2 100-40 117-97l3-10 12-39c7-24 13-45 21-63 34-74 97-126 170-139 17-3 37-3 60-3h347c22 0 42 0 60 3 72 13 135 65 169 140 8 17 14 40 21 62l12 40 3 10c18 56 74 94 127 96h257c42 0 77 40 77 88s-35 87-77 87H377c-42 0-77-39-77-87Z\"/><path d=\"M1160 2200h80c279 0 418 0 508-89 90-88 100-233 119-524l26-419c10-158 15-236-30-286-45-50-122-50-275-50H812c-153 0-230 0-275 50-45 50-40 128-30 286l26 419c19 290 28 436 119 524 90 90 230 90 508 90Zm-135-981a76 76 0 00-82-70 78 78 0 00-68 86l50 526c4 43 41 75 82 70a78 78 0 00 68-86l-50-526Zm432-70c42 4 72 42 68 86l-50 526a76 76 0 01-82 70 78 78 0 01-68-86l50-526a75 75 0 01 82-70Z\" fill-rule=\"evenodd\" clip-rule=\"evenodd\"/>\n                </symbol>\n            </svg>\n        </div>\n\n        <main>\n            <!-- Left vertical bar of Board Editor -->\n        \t<div id=\"editor-menu\" class=\"editor-menu unselectable hidden\">\n                <div class=\"editor-positionname-row\">\n                    <span id=\"position-dirty-indicator\" class=\"dirty-indicator hidden\"></span>\n                    <div class=\"editor-positionname\" id=\"active-position-name-display\"></div>\n                </div>\n                <hr class=\"editor-separator\">\n\t\t\t\t<!-- Section: Position -->\n\t\t\t\t<div>\n\t\t\t\t\t<div class=\"editor-header\"><%=t('play.editor.position')%></div>\n\t\t\t\t\t<div class=\"position-actions\">\n\t\t\t\t\t\t<div data-action=\"reset\" id=\"reset\" class=\"instant tooltip-dr\" data-tooltip=\"<%=t('play.editor.tooltip_reset')%>\">\n\t\t\t\t\t\t\t<svg class=\"svg-reset\" fill=\"none\" viewBox=\"0 0 240 240\"><g stroke=\"#333\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"24\" clip-path=\"url(#a)\"><path d=\"M120 30a90 90 0 11-57 20\"/><path d=\"M30 45h40v40\"/></g><defs><clipPath id=\"a\"><path d=\"M0 0h240v240H0\" fill=\"#333\"/></clipPath></defs></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div data-action=\"clearall\" id=\"clearall\" class=\"instant tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_clear')%>\">\n\t\t\t\t\t\t\t<svg class=\"svg-delete\"><use href=\"#svg-delete\"></use></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div data-action=\"load-position\" id=\"load-position\" class=\"tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_load')%>\">\n\t\t\t\t\t\t\t<svg class=\"svg-load-position\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"#333\" stroke-width=\"0\" viewBox=\"0 0 2400 2400\"><g fill=\"#333\" stroke=\"none\"><path d=\"M1475 575h250v487l-70-40h-1l-16-8a93 93 0 00-76 0l-16 8-71 40V575Z\"/><path d=\"M2200 1155v220c0 377 0 566-117 683s-306 117-683 117h-400c-377 0-566 0-683-117S200 1752 200 1375V670c0-88 0-132 7-170a400 400 0 01 319-318c37-7 80-7 169-7 39 0 58 0 77 2a400 400 0 01 218 90c14 12 28 26 55 53l55 55a855 855 0 00 171 150 400 400 0 00 54 24v571c0 14 0 30 2 45a100 100 0 00 40 70c35 23 70 14 86 8 14-5 28-13 39-20h2l106-60 106 60h2c10 7 25 15 39 20 16 6 51 15 86-10a98 98 0 00 40-70c2-12 2-30 2-43V580c105 7 174 25 226 72l24 22c77 86 77 218 77 480Z\"/></g></svg>\n\t\t\t\t\t\t</div>\n                        <div data-action=\"save-position-as\" id=\"save-position-as\" class=\"tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_save_as')%>\">\n\t\t\t\t\t\t\t<svg class=\"svg-save-position-as\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n                                <metadata>\n                                    Author: Microsoft\n                                    License: MIT License\n                                    Source: https://github.com/microsoft/fluentui-system-icons/\n                                </metadata>\n                                <path fill=\"#333\" d=\"M6.75 3h-1C4.2312 3 3 4.2312 3 5.75v12.5C3 19.7688 4.2312 21 5.75 21H6v-6c0-1.2426 1.0074-2.25 2.25-2.25h7.3531l1.7876-1.7876c.641-.641 1.4835-.962 2.3223-.9624h.0021c.437.0002.8749.0874 1.2849.2615v-1.976a3.25 3.25 0 0 0-.9519-2.298l-2.0355-2.0356a3.2503 3.2503 0 0 0-2.2626-.9517V7.5c0 1.2426-1.0074 2.25-2.25 2.25H9c-1.2426 0-2.25-1.0074-2.25-2.25V3Z\"/><path fill=\"#333\" d=\"m14.1031 14.25-2.6148 2.6148a3.685 3.685 0 0 0-.9693 1.712l-.4577 1.8307c-.0503.2013-.07.4-.0628.5925H7.5v-6a.75.75 0 0 1 .75-.75h5.8531ZM14.25 3v4.5a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75V3h6Z\"/><path fill=\"#333\" d=\"M19.7152 11h-.0021a2.2785 2.2785 0 0 0-1.6152.6695l-5.9024 5.9024a2.684 2.684 0 0 0-.7063 1.2475l-.4577 1.8307c-.199.7961.5221 1.5173 1.3182 1.3182l1.8307-.4577a2.684 2.684 0 0 0 1.2475-.7063l5.9024-5.9024c.8927-.8926.8927-2.3398 0-3.2324A2.2782 2.2782 0 0 0 19.7152 11Z\"/>\n                            </svg>\n                        </div>\n                        <div data-action=\"save-position\" id=\"save-position\" class=\"tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_save')%>\">\n\t\t\t\t\t\t\t<svg class=\"svg-save-position\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n                                <metadata>\n                                    Author: Microsoft\n                                    License: MIT License\n                                    Source: https://github.com/microsoft/fluentui-system-icons/\n                                </metadata>\n                                <path fill=\"#333\" d=\"M6.75 3h-1C4.2312 3 3 4.2312 3 5.75v12.5C3 19.7688 4.2312 21 5.75 21H6v-6c0-1.2426 1.0074-2.25 2.25-2.25h7.5c1.2426 0 2.25 1.0074 2.25 2.25v6h.25c1.5188 0 2.75-1.2312 2.75-2.75V8.2855a3.25 3.25 0 0 0-.9519-2.298l-2.0355-2.0356a3.2503 3.2503 0 0 0-2.2626-.9517V7.5c0 1.2426-1.0074 2.25-2.25 2.25H9c-1.2426 0-2.25-1.0074-2.25-2.25V3Z\"/><path fill=\"#333\" d=\"M14.25 3v4.5a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75V3h6ZM16.5 21v-6a.75.75 0 0 0-.75-.75h-7.5a.75.75 0 0 0-.75.75v6h9Z\"/>\n                            </svg>\n                        </div>\n\t\t\t\t\t\t<div data-action=\"copy-notation\" id=\"copy-notation\" class=\"instant tooltip-dr\" data-tooltip=\"<%=t('play.editor.tooltip_copy_notation')%>\">\n\t\t\t\t\t\t\t<svg class=\"svg-copy-notation\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#333\" transform=\"rotate(1 80 0)\" viewBox=\"-38.4 -38.4 396.8 396.8\"><g fill=\"none\" fill-rule=\"evenodd\"><path d=\"M0 0h320v320H0\"/><path d=\"M80 80v113.3c0 25 19.7 45.5 44.5 46.7H240v40a40 40 0 01-40 40H40a40 40 0 01-40-40V120a40 40 0 01 40-40zm210-80a30 30 0 01 30 30v160a30 30 0 01-30 30H130a30 30 0 01-30-30V30a30 30 0 01 30-30\" fill=\"#333\"/></g></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div data-action=\"paste-notation\" id=\"paste-notation\" class=\"instant tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_paste_notation')%>\">\n\t\t\t\t\t\t\t<svg class=\"svg-paste-notation\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 2400 2400\"><g fill=\"#333\"><path d=\"M1600 425c0 124-100 225-225 225h-350A224 224 0 01 800 425C800 301 900 200 1025 200h350A224 224 0 01 1600 425Z\"/><path d=\"M1883 503a260 260 0 00-77-45c-29-11-58 12-64 42a374 374 0 01-367 300h-350a372 372 0 01-367-300c-6-30-36-53-65-41C477 506 400 612 400 825V1800c0 300 179 400 400 400h800c221 0 400-100 400-400V825c0-163-45-263-117-322ZM800 1225h400c41 0 75 34 75 75 0 40-34 75-75 75H800a76 76 0 01-75-75c0-41 34-75 75-75Zm800 550H800a76 76 0 01-75-75c0-41 34-75 75-75h800c41 0 75 34 75 75 0 40-34 75-75 75Z\"/></g></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div data-action=\"gamerules\" id=\"gamerules\" class=\"tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_gamerules')%>\">\n\t\t\t\t\t\t\t<svg class=\"svg-gamerules\" xmlns=\"http://www.w3.org/2000/svg\" xml:space=\"preserve\" version=\"1.1\" viewBox=\"0 0 51200 51200\"><path d=\"M50818 24352a1514 1514 0 00-2140-132l-4467 3955L31596 39340a4300 4300 0 01-2849 1080c-303 0-606-30-910-94l-7213-1598-8710-1922-6105-1352-874-195a2644 2644 0 01-1548-1332 2670 2670 0 01 225-2805 2575 2575 0 01 953-810 2602 2602 0 01 1216-294c70 0 142 6 214 6l6295 1360 14600 3157a5790 5790 0 00 5082-1322l8976-7907 9166-8074a2907 2907 0 00 890-2920 2903 2903 0 00-2206-2117l-1142-246-8326-1800-10550-2277a5788 5788 0 00-5080 1324L9615 21577l-4236 3722-3686 3237a3966 3966 0 00-397 534c-65 80-123 152-181 240A5778 5778 0 00 0 32706a5764 5764 0 00 1100 3384 5613 5613 0 00 1280 1286 5530 5530 0 00 1668 840l66 20 6425 1410 6086 1330 10567 2313a7354 7354 0 00 6410-1663l362-317 12547-11110 4178-3700a1522 1522 0 00 130-2146\" fill=\"#333\"/></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div data-action=\"start-local-game\" id=\"start-local-game\" class=\"instant tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_start_local')%>\">\n\t\t\t\t\t\t\t<svg class=\"svg-start-local-game\" viewBox=\"0 0 45 45\">\n\t\t\t\t\t\t\t\t<path d=\"M22.5 9c-2.21 0-4 1.79-4 4 0 .89.29 1.71.78 2.38C17.33 16.5 16 18.59 16 21c0 2.03.94 3.84 2.41 5.03-3 1.06-7.41 5.55-7.41 13.47h23c0-7.92-4.41-12.41-7.41-13.47 1.47-1.19 2.41-3 2.41-5.03 0-2.41-1.33-4.5-3.28-5.62.49-.67.78-1.49.78-2.38 0-2.21-1.79-4-4-4z\" fill-opacity=\"1\" fill-rule=\"nonzero\" stroke-dasharray=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"miter\" stroke-miterlimit=\"4\" stroke-opacity=\"1\" stroke-width=\"1.5\" opacity=\"1\" fill=\"#333\"/>\n\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t</div>\n                        <div data-action=\"start-engine-game\" id=\"start-engine-game\" class=\"instant tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_start_engine')%>\">\n\t\t\t\t\t\t\t<svg class=\"svg-start-engine-game\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 240 240\">\n                                <path d=\"M90 20v30m60-30v30M90 190v30m60-30v30m40-130h30m-30 50h30M20 90h30m-30 50h30m48 50h44c17 0 25 0 32-3a30 30 0 00 13-13c3-7 3-15 3-32V98c0-17 0-25-3-32a30 30 0 00-13-13c-7-3-15-3-32-3H98c-17 0-25 0-32 3a30 30 0 00-13 13C50 73 50 81 50 98v44c0 17 0 25 3 32 3 5 8 10 13 13 7 3 15 3 32 3Z\" stroke=\"#333\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"25\"/>\n                            </svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<hr class=\"editor-separator\">\n\t\t\t\t<!-- Section: Tools -->\n\t\t\t\t<div>\n\t\t\t\t\t<div class=\"editor-header\"><%=t('play.editor.tools')%></div>\n\t\t\t\t\t<div class=\"editor-tools\">\n\t\t\t\t\t\t<div data-tool=\"normal\" id=\"normal\" class=\"active tooltip-dr\" data-tooltip=\"<%=t('play.editor.tooltip_normal')%>\">\n\t\t\t\t\t\t\t<svg class=\"svg-normal\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#333\" stroke=\"#333\" viewBox=\"-96 0 512 512\"><path d=\"M302.19 329.13H196.1l55.84 135.99c3.88 9.43-.56 20-9.45 24l-49.16 21.42c-9.17 4-19.45-.57-23.34-9.7L116.95 371.7 30.3 460.83C18.72 472.71 0 463.55 0 447.98V18.3C0 1.9 19.92-6.1 30.28 5.44l284.4 292.54c11.48 11.18 3.02 31.15-12.5 31.15z\"/></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div data-tool=\"eraser\" id=\"eraser\" class=\"tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_eraser')%>\">\n\t\t\t\t\t\t\t<svg class=\"svg-eraser\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"#333\" stroke-width=\"0\" viewBox=\"0 0 240 240\">\n                                <metadata>\n                                    Author: Solar Icons\n                                    License: CC Attribution License\n                                    Source: https://www.figma.com/de-de/community/file/1166831539721848736/solar-icons-set/\n                                </metadata>\n                                <g fill=\"#333\" stroke=\"none\"><path d=\"M114 55C131 38 140 30 150 30s18 8 35 25S210 80 210 90s-8 20-25 36l-42 42-70-71 41-42Z\"/><path d=\"m62 108 70 70-6 7-10 10H210a8 8 0 01 0 15H90c-10 0-19-9-35-25C38 168 30 160 30 150s8-20 25-36l7-6Z\"/></g>\n                            </svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div data-tool=\"selection-tool\" id=\"selection-tool\" class=\"tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_selection_tool')%>\">\n\t\t\t\t\t\t\t<svg class=\"svg-selection-tool\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#222\" viewBox=\"0 0 2560 2560\"><path d=\"M1520 480a120 120 0 01-120 120h-240a120 120 0 01 0-240h240a120 120 0 01 120 120Zm-120 1480h-240a120 120 0 00 0 240h240a120 120 0 00 0-240Zm400-1360h160v160a120 120 0 00 240 0V560a200.2 200.2 0 00-200-200h-200a120 120 0 00 0 240Zm280 440a120 120 0 00-120 120v240a120 120 0 00 240 0v-240a120 120 0 00-120-120ZM480 1520a120 120 0 00 120-120v-240a120 120 0 10-240 0v240a120 120 0 00 120 120Zm280 440H600v-160a120 120 0 00-240 0v200a200.2 200.2 0 00 200 200h200a120 120 0 00 0-240Zm0-1600H560a200.2 200.2 0 00-200 200v200a120 120 0 00 240 0V600h160a120 120 0 00 0-240Zm1600 1600h-160v-160a120 120 0 00-240 0v160h-160a120 120 0 00 0 240h160v160a120 120 0 00 240 0v-160h160a120 120 0 00 0-240Z\"/></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div data-tool=\"specialrights\" id=\"specialrights\" class=\"tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_specialrights')%>\">\n\t\t\t\t\t\t\t<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"-4 -4 24 24\"><path fill=\"#333\" d=\"M10 1H6v5H1v4h5v5h4v-5h5V6h-5V1Z\"/></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<hr class=\"editor-separator\">\n\t\t\t\t<!-- Section: Selection -->\n\t\t\t\t<div>\n\t\t\t\t\t<div class=\"editor-header\"><%=t('play.editor.selection')%></div>\n\t\t\t\t\t<div class=\"selection-actions\">\n\t\t\t\t\t\t<div data-action=\"select-all\" id=\"select-all\" class=\"tooltip-dr\" data-tooltip=\"<%=t('play.editor.tooltip_select_all')%>\">\n                            <svg class=\"svg-select-all\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#000\" viewBox=\"0 0 240 240\"><path d=\"M30 180h1.2a10 10 0 01 8.7 8.8V200h11.3a10 10 0 01 0 20H28.8a10 10 0 01-8.7-8.8v-22.4a10 10 0 01 8.7-8.7H30Zm180 0h1.2a10 10 0 01 8.5 7.7l.2 1.1.1 1.2v21.2a10 10 0 01-7.7 8.5l-1 .2L210 220h-21.2a10 10 0 01-1-19.7l1-.2L190 200h10v-11.2a10 10 0 01 7.7-8.5l1.1-.2L210 180Zm-80 20a10 10 0 01 0 20h-20a10 10 0 01 0-20h20ZM30 100a10 10 0 01 10 10v20a10 10 0 01-20 0v-20a10 10 0 01 10-10Zm180 0a10 10 0 01 10 10v20a10 10 0 01-20 0v-20a10 10 0 01 10-10Zm0-80h1.2a10 10 0 01 8.7 8.8v22.4a10 10 0 01-9 8.7h-2.5a10 10 0 01-8.7-8.7V40h-11.2a10 10 0 01 0-20H210ZM50 20h1.2a10 10 0 01 1 19.7l-1 .2H40v11.3a10 10 0 01-7.7 8.5l-1 .3h-2.5a10 10 0 01-8.5-7.6L20 51.2V28.8a10 10 0 01 7.6-8.5L28.8 20H50Zm100 40a30 30 0 01 30 28.2V150a30 30 0 01-28.2 30H90a30 30 0 01-30-28.2V90a30 30 0 01 28.2-30H150Zm0 20H90a10 10 0 00-10 8.8V150a10 10 0 00 8.8 10H150a10 10 0 00 10-8.8V90a10 10 0 00-8.8-10H150Zm-20-60a10 10 0 01 0 20h-20a10 10 0 01 0-20h20Z\"/></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div data-action=\"delete-selection\" id=\"delete-selection\" class=\"disabled tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_clear_selection')%>\">\n                            <svg class=\"svg-delete-selection\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#333\" baseProfile=\"tiny\" version=\"1.2\" viewBox=\"0 0 240 240\"><path d=\"M120 40a80 80 0 10 0 160 80 80 0 00 0-160zm37 103a10 10 0 11-14 14L120 134.2l-23 23a10 10 0 01-14 0 10 10 0 01 0-14.2l22.9-23-23-23A10 10 0 11 97 83l23 22.9 23-23a10 10 0 11 14 14.2L134.3 120l23 23\"/></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div data-action=\"copy-selection\" id=\"copy-selection\" class=\"disabled tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_copy_selection')%>\">\n\t\t\t\t\t\t\t<svg class=\"svg-copy-selection\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"#000\" viewBox=\"0 0 2400 2400\"><g stroke=\"#000\" stroke-linecap=\"round\" stroke-width=\"150\"><path d=\"M2100 1000c-1-218-11-335-88-412C1924 500 1782 500 1500 500h-300c-283 0-424 0-512 88C600 676 600 818 600 1100v500c0 283 0 424 88 512 88 88 230 88 512 88h300c283 0 424 0 512-88 88-88 88-230 88-512v-100\"/><path d=\"M300 1000v600a300 300 0 00 300 300M1800 500a300 300 0 00-300-300h-400C723 200 534 200 417 317 352 382 323 470 310 600\"/></g></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div data-action=\"paste-selection\" id=\"paste-selection\" class=\"disabled tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_paste_selection')%>\">\n\t\t\t\t\t\t\t<svg class=\"svg-paste-selection\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 240 240\"><path d=\"M120 0a41.4 41.4 0 00-23.1 6.3l-1 .6-2.7 1.6C91 9.8 90.1 10 90 10a20 20 0 00-20 20H60a30 30 0 00-30 30v140a30 30 0 00 30 30h120a30 30 0 00 30-30V60a30 30 0 00-30-30h-10a20 20 0 00-20-20s-1-.2-3.2-1.5L144 7l-1-.6A41.4 41.4 0 00 120 0Zm47.3 50A20 20 0 01 150 60H90a20 20 0 01-17.3-10H60a10 10 0 00-10 10v140a10 10 0 00 10 10h120a10 10 0 00 10-10V60a10 10 0 00-10-10h-12.7Zm-56.9-28.5c1.2-.6 4-1.5 9.6-1.5 5.6 0 8.4 1 9.6 1.5l3 1.7.7.5 3.5 2.2c3 1.4 7.6 3.9 13.2 3.9v10H90v-10c5.6 0 10.3-2.4 13.2-4l3.5-2.3.8-.5 3-1.7Z\" fill=\"#111\" fill-rule=\"evenodd\" clip-rule=\"evenodd\"/></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div data-action=\"invert-color\" id=\"invert-color\" class=\"disabled tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_invert_color')%>\">\n                            <svg class=\"svg-invert-selection-color\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#333\" stroke=\"#333\" stroke-width=\".38\" transform=\"scale(-1 1)\" viewBox=\"0 0 160 160\"><path d=\"M80 10a70 70 0 10 0 140A70 70 0 00 80 10zm0 130V20a60 60 0 11 0 120\"/></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div data-action=\"rotate-left\" id=\"rotate-left\" class=\"disabled tooltip-dr\" data-tooltip=\"<%=t('play.editor.tooltip_rotate_left')%>\">\n                            <svg class=\"svg-rotate-selection-left\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 240 240\"><path d=\"M125 205a85 85 0 10-73.7-42.6M15 150l36.3 12.4m17-38.6-13.6 39.7-3.4-1.1\" stroke=\"#000\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"20\"/></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div data-action=\"rotate-right\" id=\"rotate-right\" class=\"disabled tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_rotate_right')%>\">\n                            <svg class=\"svg-rotate-selection-right\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" transform=\"scale(-1 1)\" viewBox=\"0 0 240 240\"><path d=\"M125 205a85 85 0 10-73.7-42.6M15 150l36.3 12.4m17-38.6-13.6 39.7-3.4-1.1\" stroke=\"#000\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"20\"/></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div data-action=\"flip-horizontal\" id=\"flip-horizontal\" class=\"disabled tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_flip_horizontal')%>\">\n                            <svg class=\"svg-flip-selection-horizontally\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 2400 2400\"><path d=\"M200 1811V591c0-170 0-257 54-285 54-28 124 22 262 120L674 540c62 44 93 66 110 98 16 32 16 70 16 146v834c0 76 0 114-17 146-16 32-47 54-109 98l-158 113c-138 100-208 149-262 120C200 2068 200 1983 200 1812Zm2000 0V591c0-170 0-257-54-285-54-28-124 22-262 120L1726 540c-62 44-93 66-110 98-16 32-16 70-16 146v834c0 76 0 114 17 146 16 32 47 54 109 98l158 113c138 100 208 149 262 120 54-27 54-112 54-283Z\" fill=\"#333\"/><path d=\"M1200 125c41 0 75 34 75 75v400a75 75 0 01-150 0V200c0-41 34-75 75-75Zm0 800c41 0 75 34 75 75v400a75 75 0 01-150 0v-400c0-41 34-75 75-75Zm0 800c41 0 75 34 75 75v400a75 75 0 01-150 0v-400c0-41 34-75 75-75Z\" fill=\"#333\" fill-rule=\"evenodd\" clip-rule=\"evenodd\"/></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div data-action=\"flip-vertical\" id=\"flip-vertical\" class=\"disabled tooltip-d\" data-tooltip=\"<%=t('play.editor.tooltip_flip_vertical')%>\">\n                            <svg class=\"svg-flip-selection-vertically\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 2400 2400\"><path d=\"M1811 2200H591c-170 0-257 0-285-54-28-54 22-124 120-262l114-158c44-62 66-93 98-110 32-16 70-16 146-16h834c76 0 114 0 146 17 32 16 54 47 98 109l113 158c100 138 149 208 120 262-27 54-112 54-283 54Zm0-2000H591c-170 0-257 0-285 54-28 54 22 124 120 262L540 674c44 62 66 93 98 110 32 16 70 16 146 16h834c76 0 114 0 146-17 32-16 54-47 98-109l113-158c100-138 149-208 120-262C2068 200 1983 200 1812 200Z\" fill=\"#333\"/><path d=\"M125 1200c0-41 34-75 75-75h400a75 75 0 01 0 150H200a75 75 0 01-75-75Zm800 0c0-41 34-75 75-75h400a75 75 0 01 0 150h-400a75 75 0 01-75-75Zm800 0c0-41 34-75 75-75h400a75 75 0 01 0 150h-400a75 75 0 01-75-75Z\" fill=\"#333\" fill-rule=\"evenodd\" clip-rule=\"evenodd\"/></svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n        \t\t<hr class=\"editor-separator\">\n\t\t\t\t<!-- Section: Palette -->\n\t\t\t\t<div>\n\t\t\t\t\t<div class=\"editor-header\"><%=t('play.editor.palette')%></div>\n\t\t\t\t\t<div data-action=\"color\" id=\"editor-color-select\" class=\"color-select\">\n\t\t\t\t\t\t<span class=\"opposite-color-text\"><%=t('play.editor.color')%></span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div id=\"editor-pieceTypes\"></div>\n\t\t\t\t\t<div id=\"editor-neutralTypes\"></div>\n\t\t\t\t</div>\n            </div>\n\n            <!-- Toggle button for the board editor sidebar (narrow screens only) -->\n            <button id=\"editor-menu-toggle\" class=\"editor-menu-toggle tooltip-dr\" data-tooltip=\"<%=t('play.javascript.editor.expand_sidebar')%>\"></button>\n\n            <!-- Entire board UI, including loading screen, canvas and overlay -->\n            <div id=\"boardUI\">\n\n                <!-- Loading Screen -->\n\n                <div id=\"loading-animation\" class=\"animation-container\">\n                    <div id=\"loading-glow\" class=\"loading-glow loadingGlowAnimation\"></div>\n                    <span id=\"loading-text\" class=\"loading-text pulsing\"><%=t('play.loading')%></span>\n                    <div id=\"loading-error\" class=\"loading-error hidden\">\n                        <h1><%=t('play.error')%></h1>\n                        <p id=\"loading-error-text\"></p>\n                    </div>\n                    <div class=\"checkerboard\"></div>\n                </div>\n\n                <!-- The webgl context will render the game onto here -->\n                <canvas id=\"game\"></canvas>\n\n                <!-- The spinny pawn loading animation when a game is loading,\n                while the svgs are being requested and piece textures generated -->\n                <div class=\"game-loading-screen transparent\">\n                    <svg class=\"svg-pawn spinny-pawn\"><use href=\"#svg-pawn\"></use></svg>\n                    <div class=\"loading-error hidden\">\n                        <h1><%=t('play.error')%></h1>\n                        <p class=\"loading-error-text\"></p>\n                    </div>\n                </div>\n\n                <div id=\"overlay\" class=\"overlay\">\n\n                    <!-- The external discord & game credits links on the title screen -->\n                    <div id=\"menu-external-links\" class=\"menu-external-links hidden\">\n                        <!-- Discord icon, bottom left -->\n                        <a href=\"https://discord.gg/NFWFGZeNh5\" target=\"_blank\" class=\"discord-icon\">\n                            <svg viewBox=\"0 -28.5 256 256\" version=\"1.1\" preserveAspectRatio=\"xMidYMid\" fill=\"#000000\"><g stroke-width=\"0\"></g><g stroke-linecap=\"round\" stroke-linejoin=\"round\"></g><g> <g> <path d=\"M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z\" fill=\"#000000\" fill-rule=\"nonzero\"> </path> </g> </g></svg>\n                        </a>\n\n                        <a href=\"https://github.com/Infinite-Chess/infinitechess.org\" target=\"_blank\" class=\"github-icon\">\n                            <svg viewBox=\"0 0 98 96\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#000000\"><g stroke-width=\"0\"></g><g stroke-linecap=\"round\" stroke-linejoin=\"round\"></g><g> <g> <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z\" fill=\"#000\"></path></g></g> </svg>     \n                        </a>\n\n                        <a href=\"/credits?lng=<%= language %>\" class=\"credits\"><%=t('play.main-menu.credits')%></a>\n                    </div>\n\n                    <!-- Menu screen UI -->\n                    <div id=\"title\" class=\"title hidden\">\n                        <h1>Infinite Chess</h1>\n                        <button id=\"play\" class=\"play titlebubble button\"><%=t('play.main-menu.play')%></button>\n                        <button id=\"practice\" class=\"practice titlebubble button\"><%=t('play.main-menu.practice')%></button>\n                        <button id=\"rules\" class=\"rules titlebubble button\"><%=t('play.main-menu.guide')%></button>\n                        <!-- <button id=\"board-editor\" class=\"board-editor titlebubble button opacity-0_5\"><%=t('play.main-menu.editor')%></button> -->\n                        <!-- ENABLE WHEN board editor is ready -->\n                        <button id=\"board-editor\" class=\"board-editor titlebubble button\"><%=t('play.main-menu.editor')%></button>\n                    </div>\n\n                    <!-- Play selection -->\n                    <div id=\"play-selection\" class=\"play-selection hidden\">\n                        <p id=\"play-name\" class=\"play-name\"><%=t('play.play-menu.title')%></p>\n                        <button id=\"online\" class=\"online titlebubble button\"><%=t('play.play-menu.online')%></button>\n                        <button id=\"local\" class=\"local titlebubble button\"><%=t('play.play-menu.local')%></button>\n                        <button id=\"computer\" class=\"computer titlebubble button\"><%=t('play.play-menu.computer')%></button>\n                        <div class=\"game-options titlebubble\">\n                            <div class=\"options\">\n                                <div class=\"option-card\">\n                                    <p><%=t('play.play-menu.variant')%></p>\n                                    <div class=\"option\">\n                                        <select id=\"option-variant\" class=\"button\">\n                                            <option value=\"Classical\"><%=t('play.play-menu.Classical')%></option>\n                                            <option value=\"Confined_Classical\"><%=t('play.play-menu.Confined_Classical')%></option>\n                                            <option value=\"Classical_Plus\"><%=t('play.play-menu.Classical_Plus')%></option>\n                                            <option value=\"CoaIP\"><%=t('play.play-menu.CoaIP')%></option>\n                                            <option value=\"CoaIP_HO\"><%=t('play.play-menu.CoaIP_HO')%></option>\n                                            <option value=\"CoaIP_RO\"><%=t('play.play-menu.CoaIP_RO')%></option>\n                                            <option value=\"CoaIP_NO\"><%=t('play.play-menu.CoaIP_NO')%></option>\n                                            <!-- <option value=\"Knighted_Chess\"><%=t('play.play-menu.Knighted_Chess')%></option> -->\n                                            <option value=\"Palace\"><%=t('play.play-menu.Palace')%></option>\n                                            <option value=\"Pawndard\"><%=t('play.play-menu.Pawndard')%></option>\n                                            <option value=\"Core\"><%=t('play.play-menu.Core')%></option>\n                                            <option value=\"Standarch\"><%=t('play.play-menu.Standarch')%></option>\n                                            <option value=\"Space_Classic\"><%=t('play.play-menu.Space_Classic')%></option>\n                                            <option value=\"Space\"><%=t('play.play-menu.Space')%></option>\n                                            <option value=\"Abundance\"><%=t('play.play-menu.Abundance')%></option>\n                                            <option value=\"Pawn_Horde\"><%=t('play.play-menu.Pawn_Horde')%></option>\n                                            <option value=\"Knightline\"><%=t('play.play-menu.Knightline')%></option>\n                                            <option value=\"Obstocean\"><%=t('play.play-menu.Obstocean')%></option>\n                                            <!-- <option value=\"Amazon_Chandelier\"><%=t('play.play-menu.Amazon_Chandelier')%></option> -->\n                                            <!-- <option value=\"Containment\"><%=t('play.play-menu.Containment')%></option> -->\n                                            <!-- <option value=\"Classical_Limit_7\"><%=t('play.play-menu.Classical_Limit_7')%></option>\n                                            <option value=\"CoaIP_Limit_7\"><%=t('play.play-menu.CoaIP_Limit_7')%></option> -->\n                                            <option value=\"Chess\"><%=t('play.play-menu.Chess')%></option>\n                                            <!-- <option value=\"Classical_KOTH\"><%=t('play.play-menu.Classical_KOTH')%></option>\n                                            <option value=\"CoaIP_KOTH\"><%=t('play.play-menu.CoaIP_KOTH')%></option> -->\n                                            <option value=\"4x4x4x4_Chess\"><%=t('play.play-menu.4x4x4x4_Chess')%></option>\n                                            <option value=\"5D_Chess\"><%=t('play.play-menu.5D_Chess')%></option>\n                                            <option value=\"Omega\"><%=t('play.play-menu.Omega')%></option>\n                                            <option value=\"Omega_Squared\"><%=t('play.play-menu.Omega_Squared')%></option>\n                                            <option value=\"Omega_Cubed\"><%=t('play.play-menu.Omega_Cubed')%></option>\n                                            <option value=\"Omega_Fourth\"><%=t('play.play-menu.Omega_Fourth')%></option>\n                                        </select>\n                                    </div>\n                                </div>\n                                <div id=\"option-card-clock\" class=\"option-card\">\n                                    <p><%=t('play.play-menu.clock')%></p>\n                                    <div class=\"option\">\n                                        <select id=\"option-clock\" class=\"button\">\n                                            <!-- Developmental time control: 20 seconds -->\n                                            <!-- <option value=\"15+2\">0.25<%=t('play.play-menu.minutes')%>+2<%=t('play.play-menu.seconds')%></option> -->\n                                            <option value=\"60+2\">1<%=t('play.play-menu.minutes')%>+2<%=t('play.play-menu.seconds')%></option>\n                                            <option value=\"120+2\">2<%=t('play.play-menu.minutes')%>+2<%=t('play.play-menu.seconds')%></option>\n                                            <option value=\"180+2\">3<%=t('play.play-menu.minutes')%>+2<%=t('play.play-menu.seconds')%></option>\n                                            <option value=\"300+2\">5<%=t('play.play-menu.minutes')%>+2<%=t('play.play-menu.seconds')%></option>\n                                            <option value=\"480+3\">8<%=t('play.play-menu.minutes')%>+3<%=t('play.play-menu.seconds')%></option>\n                                            <option value=\"600+6\" selected>10<%=t('play.play-menu.minutes')%>+6<%=t('play.play-menu.seconds')%></option>\n                                            <option value=\"720+5\">12<%=t('play.play-menu.minutes')%>+5<%=t('play.play-menu.seconds')%></option>\n                                            <option value=\"900+6\">15<%=t('play.play-menu.minutes')%>+6<%=t('play.play-menu.seconds')%></option>\n                                            <option value=\"1200+8\">20<%=t('play.play-menu.minutes')%>+8<%=t('play.play-menu.seconds')%></option>\n                                            <option value=\"1500+10\">25<%=t('play.play-menu.minutes')%>+10<%=t('play.play-menu.seconds')%></option>\n                                            <option value=\"1800+15\">30<%=t('play.play-menu.minutes')%>+15<%=t('play.play-menu.seconds')%></option>\n                                            <option value=\"2400+20\">40<%=t('play.play-menu.minutes')%>+20<%=t('play.play-menu.seconds')%></option>\n                                            <option value=\"-\"><%=t('play.play-menu.infinite_time')%></option>\n                                        </select>\n                                    </div>\n                                </div>\n                                <div id=\"option-card-strength\" class=\"option-card\">\n                                    <p><%=t('play.practice-menu.difficulty')%></p>\n                                    <div class=\"option\">\n                                        <select id=\"option-difficulty\" class=\"button\">\n                                            <option value=\"easy\"><%=t('play.play-menu.easy')%></option>\n                                            <option value=\"medium\"><%=t('play.play-menu.medium')%></option>\n                                            <option value=\"hard\" selected><%=t('play.play-menu.hard')%></option>\n                                        </select>\n                                    </div>\n                                </div>\n                                <div id=\"option-card-color\" class=\"option-card\">\n                                    <p><%=t('play.play-menu.color')%></p>\n                                    <div class=\"option\">\n                                        <select id=\"option-color\" class=\"button\">\n                                            <option value=\"Random\"><%=t('play.play-menu.piece_colors.0')%></option>\n                                            <option value=\"White\"><%=t('play.play-menu.piece_colors.1')%></option>\n                                            <option value=\"Black\"><%=t('play.play-menu.piece_colors.2')%></option>\n                                        </select>\n                                    </div>\n                                </div>\n                                <div id=\"option-card-private\" class=\"option-card\">\n                                    <p><%=t('play.play-menu.private')%></p>\n                                    <div class=\"option\">\n                                        <select id=\"option-private\" class=\"button\">\n                                            <option value=\"public\"><%=t('play.play-menu.no')%></option>\n                                            <option value=\"private\"><%=t('play.play-menu.yes')%></option>\n                                        </select>\n                                    </div>\n                                </div>\n                                <div id=\"option-card-rated\" class=\"option-card\">\n                                    <p><%=t('play.play-menu.rated')%></p>\n                                    <div class=\"option\">\n                                        <select id=\"option-rated\" class=\"button\">\n                                            <option value=\"casual\"><%=t('play.play-menu.no')%></option>\n                                            <option id=\"option-rated-yes\" value=\"rated\" disabled><%=t('play.play-menu.yes')%></option>\n                                        </select>\n                                    </div>\n                                </div>\n                            </div>\n                            <div class=\"invite-list\">\n                                <p id=\"join-existing\" class=\"join-existing\"><%=t('play.play-menu.join_games')%> </p>\n                                <div id=\"our-invite\"></div>\n                                <div id=\"invites\">\n                                    <!-- Example invite -->\n                                    <!-- <div class=\"invite\">\n                                        <div class=\"invite-child\">Playername (elo)</div>\n                                        <div class=\"invite-child\">Standard</div>\n                                        <div class=\"invite-child\">15m+15s</div>\n                                        <div class=\"invite-child\">Random</div>\n                                        <div class=\"invite-child\">Casual</div>\n                                        <div class=\"invite-child accept\">Accept</div>\n                                    </div> -->\n                                </div>\n                            </div>\n                            <div id=\"join-private\" class=\"join-private hidden\">\n                                <p><%=t('play.play-menu.private_invite')%></p>\n                                <input type=\"text\" id=\"textbox-private\" class=\"textbox-private\" placeholder=\"<%=t('play.play-menu.code')%>\" maxlength=\"5\">\n                                <button id=\"join-button\" class=\"join-button\"><%=t('play.play-menu.join')%></button>\n                            </div>\n                            <div id=\"invite-code\" class=\"invite-code hidden\">\n                                <p><%=t('play.play-menu.your_invite')%></p>\n                                <p id=\"invite-code-code\" class=\"invite-code-code\"></p>\n                                <button id=\"copy-button\" class=\"copy-button\"><%=t('play.play-menu.copy')%></button>\n                            </div>\n                        </div>\n                        <button id=\"create-invite\" class=\"create-invite titlebubble button\"><%=t('play.play-menu.create_invite')%></button>\n                        <button id=\"play-back\" class=\"play-back titlebubble button\"><%=t('play.play-menu.back')%></button>\n                    </div>\n\n                    <!-- Practice selection -->\n                    <div id=\"practice-selection\" class=\"practice-selection hidden\">\n                        <p class=\"practice-name\"><%=t('play.practice-menu.title')%></p>\n                        <div class=\"practice-box titlebubble\">\n                            <div class=\"practice-head\">\n                                <div class=\"checkmate-progress\"></div>\n                                <div class=\"checkmate-progress-bar\">\n                                    <picture id=\"checkmate-badge-bronze\" class=\"badge tooltip-u unselectable\" data-tooltip=\"<%=t('member.badge-tooltips.checkmate_bronze')%>\">\n                                        <div class=\"shine-clockwise hidden\"></div>\n                                        <div class=\"shine-anticlockwise hidden\"></div>\n                                        <source srcset=\"/img/badges/checkmate-badge-bronze.webp\" type=\"image/webp\" />\n                                        <source srcset=\"/img/badges/checkmate-badge-bronze.avif\" type=\"image/avif\" />\n                                        <img loading=\"lazy\" src=\"/img/badges/checkmate-badge-bronze.png\" class=\"unearned\">\n                                    </picture>\n                                    <picture id=\"checkmate-badge-silver\" class=\"badge tooltip-u unselectable\" data-tooltip=\"<%=t('member.badge-tooltips.checkmate_silver')%>\">\n                                        <div class=\"shine-clockwise hidden\"></div>\n                                        <div class=\"shine-anticlockwise hidden\"></div>\n                                        <source srcset=\"/img/badges/checkmate-badge-silver.webp\" type=\"image/webp\" />\n                                        <source srcset=\"/img/badges/checkmate-badge-silver.avif\" type=\"image/avif\" />\n                                        <img loading=\"lazy\" src=\"/img/badges/checkmate-badge-silver.png\" class=\"unearned\">\n                                    </picture>\n                                    <picture id=\"checkmate-badge-gold\" class=\"badge tooltip-u unselectable\" data-tooltip=\"<%=t('member.badge-tooltips.checkmate_gold')%>\">\n                                        <div class=\"shine-clockwise hidden\"></div>\n                                        <div class=\"shine-anticlockwise hidden\"></div>\n                                        <source srcset=\"/img/badges/checkmate-badge-gold.webp\" type=\"image/webp\" />\n                                        <source srcset=\"/img/badges/checkmate-badge-gold.avif\" type=\"image/avif\" />\n                                        <img loading=\"lazy\" src=\"/img/badges/checkmate-badge-gold.png\" class=\"unearned\">\n                                    </picture>\n                                </div>\n                                <div class=\"checkmate-difficulty difficulty-title\"><%=t('play.practice-menu.difficulty')%></div>\n                            </div>\n                            <div class=\"checkmate-list\">\n                                <div id=\"checkmates\">\n                                    <!-- Checkmate list is inserted dynamically here on demand. -->\n                                    <!-- Example looks like: -->\n                                    <!-- <div class=\"checkmate unselectable selected\" id=\"2R1N1P-1k\">\n                                        <div class=\"completion-mark\"></div>\n                                        <div class=\"piecelistW\">\n                                            <div class=\"checkmate-child checkmatepiececontainer\">\n                                                <div class=\"checkmatepiece rooksW\"><svg></svg></div>\n                                            </div>\n                                            <div class=\"checkmate-child checkmatepiececontainer collated-strong\">\n                                                <div class=\"checkmatepiece rooksW\"><svg></svg></div>\n                                            </div>\n                                            <div class=\"checkmate-child checkmatepiececontainer\">\n                                                <div class=\"checkmatepiece knightsW\"><svg></svg></div>\n                                            </div>\n                                            <div class=\"checkmate-child checkmatepiececontainer\">\n                                                <div class=\"checkmatepiece pawnsW\"><svg></svg></div>\n                                            </div>\n                                        </div>\n                                        <div class=\"checkmate-child versus\">vs</div>\n                                        <div class=\"piecelistB\">\n                                            <div class=\"checkmate-child checkmatepiececontainer\">\n                                                <div class=\"checkmatepiece kingsB\"></div>\n                                            </div>\n                                        </div>\n                                        <div class=\"checkmate-difficulty\">Medium</div>\n                                    </div> -->\n                                </div>\n                            </div>\n                        </div>\n                        <button id=\"practice-play\" class=\"practice-play titlebubble button\"><%=t('play.practice-menu.play')%></button>\n                        <button id=\"practice-back\" class=\"practice-back titlebubble button\"><%=t('play.practice-menu.back')%></button>\n                    </div>\n\n                    <!-- Stats below navigation bar -->\n                    <!-- This is absolutely positioned. Our javascript will udate it's 'top' ruleset based\n                    on the height of the navigation bar. -->\n                    <div id=\"stats\">\n                        <p id=\"status-fps\" class=\"status hidden\"></p>\n                        <p id=\"status-moves\" class=\"status hidden\"></p>\n                    </div>\n\n                    <!-- Navigation controls -->\n                    <div id=\"navigation-bar\" class=\"navigation-bar hidden unselectable\">\n                        <div class=\"teleport\">\n                            <div class=\"buttoncontainer\">\n                                <div id=\"back\" class=\"button tooltip-dr\" data-tooltip=\"<%=t('play.gamebuttontooltips.undo_transition')%>\">\n                                    <svg viewBox=\"-1.68 -1.68 27.36 27.36\" fill=\"none\" stroke=\"#333\" stroke-width=\"0.45600000000000007\"><g stroke-width=\"0\"></g><g stroke-linecap=\"round\" stroke-linejoin=\"round\"></g><g> <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M10.5303 5.46967C10.8232 5.76256 10.8232 6.23744 10.5303 6.53033L5.81066 11.25H20C20.4142 11.25 20.75 11.5858 20.75 12C20.75 12.4142 20.4142 12.75 20 12.75H5.81066L10.5303 17.4697C10.8232 17.7626 10.8232 18.2374 10.5303 18.5303C10.2374 18.8232 9.76256 18.8232 9.46967 18.5303L3.46967 12.5303C3.17678 12.2374 3.17678 11.7626 3.46967 11.4697L9.46967 5.46967C9.76256 5.17678 10.2374 5.17678 10.5303 5.46967Z\" fill=\"#333\"></path> </g></svg>\n                                </div>\n                            </div>\n                            <div class=\"buttoncontainer\">\n                                <div id=\"expand\" class=\"button tooltip-d\" data-tooltip=\"<%=t('play.gamebuttontooltips.expand_fit_all')%>\">\n                                    <svg viewBox=\"-3.84 -3.84 31.68 31.68\" fill=\"none\"><g stroke-width=\"0\"></g><g stroke-linecap=\"round\" stroke-linejoin=\"round\"></g><g><path d=\"M3 7C3 7.55228 2.55228 8 2 8C1.44772 8 1 7.55228 1 7V3C1 1.89543 1.89543 1 3 1H7C7.55228 1 8 1.44772 8 2C8 2.55228 7.55228 3 7 3H4.41421L10.7071 9.29289C11.0976 9.68342 11.0976 10.3166 10.7071 10.7071C10.3166 11.0976 9.68342 11.0976 9.29289 10.7071L3 4.41422V7Z\" fill=\"#333\"></path> <path d=\"M21 17C21 16.4477 21.4477 16 22 16C22.5523 16 23 16.4477 23 17V21C23 22.1046 22.1046 23 21 23H17C16.4477 23 16 22.5523 16 22C16 21.4477 16.4477 21 17 21H19.5858L13.2929 14.7071C12.9024 14.3166 12.9024 13.6834 13.2929 13.2929C13.6834 12.9024 14.3166 12.9024 14.7071 13.2929L21 19.5858V17Z\" fill=\"#333\"></path> <path d=\"M21 7C21 7.55228 21.4477 8 22 8C22.5523 8 23 7.55228 23 7V3C23 1.89543 22.1046 1 21 1H17C16.4477 1 16 1.44772 16 2C16 2.55228 16.4477 3 17 3H19.5858L13.2929 9.29289C12.9024 9.68342 12.9024 10.3166 13.2929 10.7071C13.6834 11.0976 14.3166 11.0976 14.7071 10.7071L21 4.41421V7Z\" fill=\"#333\"></path> <path d=\"M3 17C3 16.4477 2.55228 16 2 16C1.44772 16 1 16.4477 1 17V21C1 22.1046 1.89543 23 3 23H7C7.55228 23 8 22.5523 8 22C8 21.4477 7.55228 21 7 21H4.41421L10.7071 14.7071C11.0976 14.3166 11.0976 13.6834 10.7071 13.2929C10.3166 12.9024 9.68342 12.9024 9.29289 13.2929L3 19.5858V17Z\" fill=\"#333\"></path> </g></svg>\n                                </div>\n                            </div>\n                            <div class=\"buttoncontainer\">\n                                <div id=\"recenter\" class=\"button tooltip-d\" data-tooltip=\"<%=t('play.gamebuttontooltips.recenter')%>\">\n                                    <svg fill=\"#333\" viewBox=\"-59.11 -59.11 610.81 610.81\" stroke=\"#333\" stroke-width=\"0.0049258900000000005\"><g><path d=\"M468.467,222.168h-28.329c-9.712-89.679-80.46-161.18-169.71-172.258V24.135c0-13.338-10.791-24.134-24.134-24.134 c-13.311,0-24.117,10.796-24.117,24.134V49.91C132.924,60.988,62.177,132.488,52.482,222.168H24.153 C10.806,222.168,0,232.964,0,246.286c0,13.336,10.806,24.132,24.153,24.132h29.228c12.192,86.816,81.551,155.4,168.797,166.229 v31.804c0,13.336,10.806,24.135,24.117,24.135c13.343,0,24.134-10.799,24.134-24.135v-31.804 c87.228-10.829,156.607-79.413,168.775-166.229h29.264c13.33,0,24.122-10.796,24.122-24.132 C492.589,232.964,481.797,222.168,468.467,222.168z M246.294,398.093c-85.345,0-154.804-69.453-154.804-154.813 c0-85.363,69.459-154.813,154.804-154.813c85.376,0,154.823,69.45,154.823,154.813 C401.117,328.639,331.671,398.093,246.294,398.093z\"></path> <path d=\"M246.294,176.93c-36.628,0-66.34,29.704-66.34,66.349c0,36.635,29.711,66.349,66.34,66.349 c36.66,0,66.34-29.713,66.34-66.349C312.634,206.635,282.955,176.93,246.294,176.93z\"></path></g></svg>\n                                </div>\n                            </div>\n                            <!-- Annotation buttons on mobile (pencil, eraser, stack) -->\n                            <div class=\"buttoncontainer annotations\">\n                                <div id=\"annotations\" class=\"button tooltip-d\" data-tooltip=\"<%=t('play.gamebuttontooltips.annotations')%>\">\n                                    <svg class=\"pencil\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"#333\" stroke=\"#333\" stroke-width=\".432\" xmlns:v=\"https://vecta.io/nano\"><path d=\"M17.067 2.272a3.57 3.57 0 0 1 4.662 1.931 3.57 3.57 0 0 1-.773 3.888l-.518.518-5.045-5.045.518-.518a3.57 3.57 0 0 1 1.157-.773zm-3.09 2.705L3.655 15.3a1 1 0 0 0-.258.444l-1.362 4.993a1 1 0 0 0 1.228 1.228l4.993-1.362a1 1 0 0 0 .444-.258l10.323-10.323-5.045-5.045z\"/></svg>\n                                </div>\n                            </div>\n                            <div class=\"buttoncontainer erase\">\n                                <div id=\"erase\" class=\"button tooltip-d\" data-tooltip=\"<%=t('play.gamebuttontooltips.erase')%>\">\n                                    <svg class=\"erase\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"#333\" stroke-width=\"0\" viewBox=\"0 0 240 240\"><g fill=\"#333\" stroke=\"none\"><path d=\"M114 55C131 38 140 30 150 30s18 8 35 25S210 80 210 90s-8 20-25 36l-42 42-70-71 41-42Z\"/><path d=\"m62 108 70 70-6 7-10 10H210a8 8 0 01 0 15H90c-10 0-19-9-35-25C38 168 30 160 30 150s8-20 25-36l7-6Z\"/></g></svg>\n                                </div>\n                            </div>\n                            <div class=\"buttoncontainer collapse hidden\">\n                                <div id=\"collapse\" class=\"button tooltip-d\" data-tooltip=\"<%=t('play.gamebuttontooltips.collapse')%>\">\n                                    <svg class=\"collapse\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#333\" viewBox=\"0 0 256 256\" stroke=\"#333\" stroke-width=\"6.912\" xmlns:v=\"https://vecta.io/nano\"><path d=\"M8 104a8 8 0 0 1 4.031-6.946l112-64a8.004 8.004 0 0 1 7.938 0l112 64a8 8 0 0 1 0 13.893l-112 64a8.001 8.001 0 0 1-7.937 0l-112-64A8 8 0 0 1 8 104zm228.031 33.054L128 198.786 19.969 137.054a8 8 0 0 0-7.937 13.893l112 64a8.001 8.001 0 0 0 7.938 0l112-64a8 8 0 0 0-7.937-13.893z\"/></svg>\n                                </div>\n                            </div>\n                        </div>\n                        <div class=\"coords selectable\">\n                            <div id=\"position\">\n                                <div class=\"x\">X: <input type=\"string\" id='x'></input></div>\n                                <div class=\"y\">Y: <input type=\"string\" id='y'></input></div>\n                            </div>\n                        </div>\n                        <div class=\"right-nav\">\n                            <div class=\"buttoncontainer\">\n                                <div id=\"move-left\" class=\"button opacity-0_5 tooltip-d\" data-tooltip=\"<%=t('play.gamebuttontooltips.rewind_move')%>\">\n                                    <svg fill=\"#333\" viewBox=\"-15.84 -15.84 55.68 55.68\" stroke=\"#333\" stroke-width=\"2.4\"><g stroke-width=\"0\"></g><g stroke-linecap=\"round\" stroke-linejoin=\"round\"></g><g><path d=\"m4.431 12.822 13 9A1 1 0 0 0 19 21V3a1 1 0 0 0-1.569-.823l-13 9a1.003 1.003 0 0 0 0 1.645z\"></path></g></svg>\n                                </div>\n                            </div>\n                            <div class=\"buttoncontainer\">\n                                <div id=\"move-right\" class=\"button opacity-0_5 tooltip-d\" data-tooltip=\"<%=t('play.gamebuttontooltips.forward_move')%>\">\n                                    <svg fill=\"#333\" viewBox=\"-15.84 -15.84 55.68 55.68\" stroke=\"#333\" stroke-width=\"2.4\"><g transform=\"scale(-1, 1) translate(-25.16, 0)\"><path d=\"m4.431 12.822 13 9A1 1 0 0 0 19 21V3a1 1 0 0 0-1.569-.823l-13 9a1.003 1.003 0 0 0 0 1.645z\"></path></g></svg>\n                                </div>\n                            </div>\n                            <!-- Undo and redo buttons in board editor -->\n                            <div class=\"buttoncontainer\">\n                                <div id=\"undo-edit\" class=\"hidden button opacity-0_5 tooltip-d\" data-tooltip=\"<%=t('play.gamebuttontooltips.undo_edit')%>\">\n                                    <svg viewBox=\"-5 -5 26 26\" fill=\"#333\"><g transform=\"translate(0.4, 1.3)\"><path d=\"M6 3.6V0L0 6l6 6V8c6-.27 7.53 3.76 7.88 5.77a.27.27 0 0 0 .53 0C17.08 2.86 6 3.6 6 3.6\"/></g></svg>\n                                </div>\n                            </div>\n                            <div class=\"buttoncontainer\">\n                                <div id=\"redo-edit\" class=\"hidden button opacity-0_5 tooltip-d\" data-tooltip=\"<%=t('play.gamebuttontooltips.redo_edit')%>\">\n                                    <svg viewBox=\"-5 -5 26 26\" fill=\"#333\"><g transform=\"scale(-1, 1) translate(-15.6, 1.3)\"><path d=\"M6 3.6V0L0 6l6 6V8c6-.27 7.53 3.76 7.88 5.77a.27.27 0 0 0 .53 0C17.08 2.86 6 3.6 6 3.6\"/></g></svg>\n                                </div>\n                            </div>\n                            <div class=\"buttoncontainer\">\n                                <div id=\"pause\" class=\"button tooltip-d\" data-tooltip=\"<%=t('play.gamebuttontooltips.pause')%>\">\n                                    <svg viewBox=\"-2.5 -2.5 29 29\" version=\"1.2\" fill=\"#333\" stroke-width=\"2.4\"><g stroke=\"#000000\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 6H20M4 12H20M4 18H20\"></path></g></svg>\n                                </div>\n                            </div>\n                        </div>\n                    </div>\n\n                    <!-- Game info, clocks, player names, whos turn -->\n                    <div id=\"game-info-bar\" class=\"game-info-bar hidden\">\n                        <div class=\"player-container left\">\n                            <div id=\"playerwhite\" class=\"playerwhite\"></div>\n                            <div class=\"timer-container left\">\n                                <div id=\"timer-white\" class=\"timer white\">XX:XX</div>\n                            </div>\n                        </div>\n                        \n                        <div class=\"whosturn\">\n                            <div id=\"whosturn\"></div>\n\n                            <!-- Draw Offer UI -->\n                            <div id=\"draw_offer_ui\" class=\"draw_offer_ui hidden\">\n                                <p class=\"offer_title\"><%=t('play.drawoffer.question')%></p>\n                                <button id=\"acceptdraw\" class=\"acceptdraw button icon\" alt=\"Accept draw offer\">\n                                    <svg width=\"800px\" height=\"800px\" viewBox=\"0 0 24 24\" fill=\"none\">\n                                        <path d=\"M19 7.34189C18.6095 6.95136 17.9763 6.95136 17.5858 7.34189L10.3407 14.587C9.95016 14.9775 9.31699 14.9775 8.92647 14.587L6.38507 12.0456C5.99454 11.6551 5.36138 11.6551 4.97085 12.0456C4.58033 12.4361 4.58033 13.0693 4.97085 13.4598L7.51774 16C8.68969 17.1689 10.5869 17.1677 11.7574 15.9974L19 8.7561C19.3905 8.36558 19.3905 7.73241 19 7.34189Z\" fill=\"#0F0F0F\"/>\n                                    </svg>\n                                </button>\n                                <button id=\"declinedraw\" class=\"declinedraw button icon\" alt=\"Decline draw offer\">\n                                    <svg width=\"800px\" height=\"800px\" viewBox=\"0 0 24 24\" fill=\"none\">\n                                        <path d=\"M17.2929 5.29289C17.6834 4.90237 18.3166 4.90237 18.7071 5.29289C19.0976 5.68342 19.0976 6.31658 18.7071 6.70711L13.4142 12L18.7071 17.2929C19.0976 17.6834 19.0976 18.3166 18.7071 18.7071C18.3166 19.0976 17.6834 19.0976 17.2929 18.7071L12 13.4142L6.70711 18.7071C6.31658 19.0976 5.68342 19.0976 5.29289 18.7071C4.90237 18.3166 4.90237 17.6834 5.29289 17.2929L10.5858 12L5.29289 6.70711C4.90237 6.31658 4.90237 5.68342 5.29289 5.29289C5.68342 4.90237 6.31658 4.90237 6.70711 5.29289L12 10.5858L17.2929 5.29289Z\" fill=\"#000000\"/>\n                                    </svg>\n                                </button>\n                            </div>\n                        </div>\n                        <!-- Practice engine game game undo move + restart buttons -->\n                        <div class=\"practice-engine-buttons hidden\">\n                            <button id=\"undobutton\" class=\"game-control tooltip-u\" data-tooltip=\"<%=t('play.gamebuttontooltips.undo')%>\">\n                                <svg class=\"svg-undo\"><use href=\"#svg-undo\"></use></svg>\n                            </button>\n                            <button id=\"restartbutton\" class=\"game-control tooltip-ul\" data-tooltip=\"<%=t('play.gamebuttontooltips.restart')%>\">\n                                <svg class=\"svg-restart\" fill=\"none\" viewBox=\"0 0 240 240\"><g stroke=\"#555\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"24\" clip-path=\"url(#a)\"><path d=\"M120 30a90 90 0 11-57 20\"/><path d=\"M30 45h40v40\"/></g><defs><clipPath id=\"a\"><path d=\"M0 0h240v240H0\" fill=\"#555\"/></clipPath></defs></svg>\n                            </button>\n                        </div>\n                        <div class=\"player-container right\">\n                            <div id=\"playerblack\" class=\"playerblack justify-content-right\"></div>\n                            <div class=\"timer-container right\">\n                                <div id=\"timer-black\" class=\"timer black\">XX:XX</div>\n                            </div>\n                        </div>\n                    </div>\n\n                    <!-- Promotion UI -->\n                    <div id=\"promote\" class=\"center hidden\">\n                        <div id=\"promotewhite\" class=\"promotecolor hidden\">\n                            <!-- <svg id=\"knightsW\" class=\"promotepiececontainer\"></svg> -->\n                        </div>\n                        <div id=\"promoteblack\" class=\"promotecolor hidden\">\n                            <!-- <svg id=\"knightsB\" class=\"promotepiececontainer\"></svg> -->\n                        </div>\n                    </div>\n\n                    <!-- Reset position UI for Board Editor -->\n                    <div id=\"reset-position-UI\" class=\"center narrow scrolling floating-window hidden\">\n                        <div id=\"reset-position-UI-header\" class=\"window-header\">\n                            <span><%=t('play.editor.reset_header')%></span>\n                            <button id=\"close-reset-position-UI\" class=\"close-floating-window\">&times;</button>\n                        </div>\n\n                        <hr class=\"divider\">\n\n                        <div class=\"flwindow-section\">\n                            <label>\n                                <%=t('play.editor.reset_message')%>\n                            </label>\n                        </div>\n\n                        <div class=\"unselectable confirmation-buttons\">\n                            <button id=\"reset-position-no\" class=\"btn btn-secondary\"><%=t('play.editor.no')%></button>\n                            <button id=\"reset-position-yes\" class=\"btn btn-primary\"><%=t('play.editor.yes')%></button>\n                        </div>\n                    </div>\n\n                    <!-- Clear position UI for Board Editor -->\n                    <div id=\"clear-position-UI\" class=\"center narrow scrolling floating-window hidden\">\n                        <div id=\"clear-position-UI-header\" class=\"window-header\">\n                            <span><%=t('play.editor.clear_header')%></span>\n                            <button id=\"close-clear-position-UI\" class=\"close-floating-window\">&times;</button>\n                        </div>\n\n                        <hr class=\"divider\">\n\n                        <div class=\"flwindow-section\">\n                            <label>\n                                <%=t('play.editor.clear_message')%>\n                            </label>\n                        </div>\n\n                        <div class=\"unselectable confirmation-buttons\">\n                            <button id=\"clear-position-no\" class=\"btn btn-secondary\"><%=t('play.editor.no')%></button>\n                            <button id=\"clear-position-yes\" class=\"btn btn-primary\"><%=t('play.editor.yes')%></button>\n                        </div>\n                    </div>\n\n                    <!-- Load position UI for Board Editor -->\n                    <div id=\"load-position-UI\" class=\"floating-window wide nonscrolling hidden\">\n                        <div id=\"load-position-UI-header\" class=\"window-header\">\n                            <span id=\"load-position-UI-header-text\"><%=t('play.javascript.editor.load_position_header')%></span>\n                            <button id=\"close-load-position-UI\" class=\"close-floating-window\">&times;</button>\n                        </div>\n\n                        <hr class=\"divider\">\n\n                        <!-- Container for Save Position As -->\n                        <div class=\"flwindow-section hidden\" id=\"enter-position-name\">\n                            <div class=\"position-name\">\n                                <label for=\"save-as-position-name\"><%=t('play.editor.enter_position_name')%></label>\n\n                                <div class=\"position-name-row\">\n                                    <input type=\"text\" id=\"save-as-position-name\" maxlength=\"<%=editorPositionNameMaxLength%>\">\n                                    <button id=\"save-position-button\" class=\"position-name-btnsave\"><%=t('play.editor.save_button')%></button>\n                                </div>\n                            </div>\n                            <hr class=\"divider\">\n                        </div>\n\n                        <div class=\"flwindow-section saved-positions\">\n                            <div class=\"saved-position-header\">\n                                <div><%=t('play.editor.name_header')%></div>\n                                <div class=\"piece-count\"><%=t('play.editor.pieces_header')%></div>\n                                <div class=\"date\"><%=t('play.editor.date_header')%></div>\n                                <!-- Spinny Pawn Loading Animation -->\n                                <div>\n                                    <svg id=\"load-position-loading-pawn\" class=\"svg-pawn spinny-pawn hidden\"><use href=\"#svg-pawn\"></use></svg>\n                                </div>\n                            </div>\n                            <div id=\"saved-position-list\" class=\"saved-position-list\">\n                                <div id=\"saved-position-list-empty\" class=\"saved-position-list-empty hidden\"><%=t('play.editor.no_saves')%></div>\n                                <!--\n                                <div class=\"saved-position\">\n                                    <div>POSITION_NAME</div>\n                                    <div class=\"piece-count\">PIECE_COUNT</div>\n                                    <div class=\"date\">DATE</div>\n                                    <button class=\"saved-position-btn\">\n                                        <svg><use href=\"#svg-load\" /></svg>\n                                    </button>\n                                    <button class=\"saved-position-btn\">\n                                        <svg><use href=\"#svg-delete\" /></svg>\n                                    </button>\n                                </div>\n                                -->\n                            </div>\n                        </div>\n\n                        <!-- Local modal overlay to block the load-position-UI -->\n                        <div id=\"load-position-modal-overlay\" class=\"load-position-modal-overlay center hidden\">\n                            <div class=\"load-position-modal\">\n                                <div class=\"window-header load-position-modal-header\">\n                                    <span id=\"load-position-modal-title\"></span>\n                                    <button id=\"close-load-position-modal\" class=\"close-floating-window\">&times;</button>\n                                </div>\n\n                                <hr class=\"divider\">\n\n                                <div class=\"flwindow-section\">\n                                    <label id=\"load-position-modal-message\"></label>\n                                </div>\n\n                                <div class=\"unselectable confirmation-buttons\">\n                                    <button id=\"load-position-modal-no\" class=\"btn btn-secondary\"><%=t('play.editor.no')%></button>\n                                    <button id=\"load-position-modal-yes\" class=\"btn btn-primary\"><%=t('play.editor.yes')%></button>\n                                </div>\n                            </div>\n                        </div>\n                    </div>\n\n                    <!-- Game Rules UI for Board Editor -->\n                    <div id=\"game-rules\" class=\"center narrow scrolling floating-window hidden\">\n                        <div id=\"game-rules-header\" class=\"window-header\">\n                            <span><%=t('play.editor.gamerules_header')%></span>\n                            <button id=\"close-rules\" class=\"close-floating-window\">&times;</button>\n                        </div>\n\n                        <hr class=\"divider\">\n\n                        <div class=\"flwindow-section\">\n                            <label><%=t('play.editor.player_to_move')%></label>\n                            <div class=\"toggle-group\">\n                                <input type=\"radio\" id=\"rules-white\" name=\"player\" value=\"white\" checked>\n                                <label for=\"rules-white\"><%=t('play.editor.white')%></label>\n                                <input type=\"radio\" id=\"rules-black\" name=\"player\" value=\"black\">\n                                <label for=\"rules-black\"><%=t('play.editor.black')%></label>\n                            </div>\n                        </div>\n\n                        <div class=\"flwindow-section\">\n                            <label><%=t('play.editor.en_passant')%></label>\n                            <div class=\"input-pair\">\n                                <input type=\"text\" id=\"rules-enpassant-x\" placeholder=\"x\">\n                                <span>,</span>\n                                <input type=\"text\" id=\"rules-enpassant-y\" placeholder=\"y\">\n                            </div>\n                        </div>\n\n                        <div class=\"flwindow-section\">\n                            <label><%=t('play.editor.move_rule')%></label>\n                            <div class=\"input-pair\">\n                                <input type=\"text\" id=\"rules-moverule-current\" placeholder=\"e.g. 0\">\n                                <span>/</span>\n                                <input type=\"text\" id=\"rules-moverule-max\" placeholder=\"e.g. 100\">\n                            </div>\n                        </div>\n\n                        <div class=\"flwindow-section\">\n                            <label><%=t('play.editor.promotion_ranks_white')%></label>\n                            <input type=\"text\" id=\"rules-promotionranks-white\" placeholder=\"e.g. 8,12\">\n                        </div>\n\n                        <div class=\"flwindow-section\">\n                            <label><%=t('play.editor.promotion_ranks_black')%></label>\n                            <input type=\"text\" id=\"rules-promotionranks-black\" placeholder=\"e.g. 1,-10\">\n                        </div>\n\n                        <div class=\"flwindow-section\">\n                            <label><%=t('play.editor.promotion_pieces')%></label>\n                            <input type=\"text\" id=\"rules-promotionpieces\" placeholder=\"e.g. Q,R,B,N\">\n                        </div>\n\n                        <div class=\"flwindow-section\">\n                            <label><%=t('play.editor.global_special_rights')%></label>\n                            <div class=\"rules-list\">\n                                <div class=\"checkbox-item\">\n                                    <input type=\"checkbox\" id=\"rules-doublepush\">\n                                    <label for=\"rules-doublepush\"><%=t('play.editor.pawn_double_push')%></label>\n                                </div>\n                                <div class=\"checkbox-item\">\n                                    <input type=\"checkbox\" id=\"rules-castling\">\n                                    <label for=\"rules-castling\"><%=t('play.editor.castling_label')%></label>\n                                </div>\n                            </div>\n                        </div>\n\n                        <div class=\"flwindow-section\">\n                            <label><%=t('play.editor.win_conditions')%></label>\n                            <div class=\"rules-list\">\n                                <div class=\"checkbox-item\">\n                                    <input type=\"checkbox\" id=\"rules-checkmate\" checked>\n                                    <label for=\"rules-checkmate\"><%=t('play.editor.checkmate')%></label>\n                                </div>\n                                <div class=\"checkbox-item\">\n                                    <input type=\"checkbox\" id=\"rules-royalcapture\">\n                                    <label for=\"rules-royalcapture\"><%=t('play.editor.royal_capture')%></label>\n                                </div>\n                                <div class=\"checkbox-item\">\n                                    <input type=\"checkbox\" id=\"rules-allroyalscaptured\">\n                                    <label for=\"rules-allroyalscaptured\"><%=t('play.editor.all_royals_captured')%></label>\n                                </div>\n                                <div class=\"checkbox-item\">\n                                    <input type=\"checkbox\" id=\"rules-allpiecescaptured\">\n                                    <label for=\"rules-allpiecescaptured\"><%=t('play.editor.all_pieces_captured')%></label>\n                                </div>\n                            </div>\n                        </div>\n\n                        <div class=\"flwindow-section\">\n                            <label><%=t('play.editor.world_border')%></label>\n                            <div class=\"input-pair\">\n                                <span>X:</span>\n                                <input type=\"text\" id=\"rules-border-left\" placeholder=\"-&infin;\">\n                                <span>-</span>\n                                <input type=\"text\" id=\"rules-border-right\" placeholder=\"&infin;\">\n                            </div>\n                            <div class=\"input-pair\" style=\"margin-top: 6px;\">\n                                <span>Y:</span>\n                                <input type=\"text\" id=\"rules-border-bottom\" placeholder=\"-&infin;\">\n                                <span>-</span>\n                                <input type=\"text\" id=\"rules-border-top\" placeholder=\"&infin;\">\n                            </div>\n                        </div>\n                    </div>\n\n                    <!-- Start local game UI for Board Editor -->\n                    <div id=\"local-game-UI\" class=\"center narrow scrolling floating-window hidden\">\n                        <div id=\"local-game-UI-header\" class=\"window-header\">\n                            <span><%=t('play.editor.start_local_game')%></span>\n                            <button id=\"close-local-game-UI\" class=\"close-floating-window\">&times;</button>\n                        </div>\n\n                        <hr class=\"divider\">\n\n                        <div class=\"flwindow-section\">\n                            <label>\n                                <%=t('play.editor.start_local_game_message')%>\n                            </label>\n                        </div>\n\n                        <div class=\"unselectable confirmation-buttons\">\n                            <button id=\"start-local-game-no\" class=\"btn btn-secondary\"><%=t('play.editor.no')%></button>\n                            <button id=\"start-local-game-yes\" class=\"btn btn-primary\"><%=t('play.editor.yes')%></button>\n                        </div>\n                    </div>\n\n                    <!-- Start engine game UI for Board Editor -->\n                    <div id=\"engine-game-UI\" class=\"center narrow scrolling floating-window hidden\">\n                        <div id=\"engine-game-UI-header\" class=\"window-header\">\n                            <span><%=t('play.editor.start_engine_game')%></span>\n                            <button id=\"close-engine-game-UI\" class=\"close-floating-window\">&times;</button>\n                        </div>\n\n                        <hr class=\"divider\">\n\n\n                        <div class=\"flwindow-section\">\n                            <label><%=t('play.editor.play_as')%></label>\n                            <div class=\"toggle-group\">\n                                <input type=\"radio\" id=\"engine-game-white\" name=\"human-player\" value=\"white\" checked>\n                                <label for=\"engine-game-white\"><%=t('play.editor.white')%></label>\n                                <input type=\"radio\" id=\"engine-game-black\" name=\"human-player\" value=\"black\">\n                                <label for=\"engine-game-black\"><%=t('play.editor.black')%></label>\n                            </div>\n                        </div>\n\n                        <div class=\"flwindow-section\">\n                            <label><%=t('play.editor.time_control')%></label>\n                            <input type=\"text\" id=\"engine-game-timecontrol\" placeholder=\"e.g. 600+6\">\n                        </div>\n\n                        <div class=\"flwindow-section\">\n                            <label><%=t('play.editor.engine_difficulty')%></label>\n                            <div class=\"toggle-group\">\n                                <input type=\"radio\" id=\"engine-game-easy\" name=\"engine-strength\" value=\"easy\" checked>\n                                <label for=\"engine-game-easy\"><%=t('play.editor.easy')%></label>\n                                <input type=\"radio\" id=\"engine-game-medium\" name=\"engine-strength\" value=\"medium\" checked>\n                                <label for=\"engine-game-medium\"><%=t('play.editor.medium')%></label>\n                                <input type=\"radio\" id=\"engine-game-hard\" name=\"engine-strength\" value=\"hard\" checked>\n                                <label for=\"engine-game-hard\"><%=t('play.editor.hard')%></label>\n                            </div>\n                        </div>\n\n                        <div class=\"flwindow-section\">\n                            <label><%=t('play.editor.use_default_border')%></label>\n                            <div class=\"toggle-group\">\n                                <input type=\"radio\" id=\"engine-game-border-no\" name=\"world-border\" value=\"no\" checked>\n                                <label for=\"engine-game-border-no\"><%=t('play.editor.no')%></label>\n                                <input type=\"radio\" id=\"engine-game-border-yes\" name=\"world-border\" value=\"yes\">\n                                <label for=\"engine-game-border-yes\"><%=t('play.editor.yes')%></label>\n                            </div>\n                        </div>\n\n                        <hr class=\"divider\">\n\n                        <div class=\"flwindow-section\">\n                            <label>\n                                <%=t('play.editor.start_engine_game_message')%>\n                            </label>\n                        </div>\n\n                        <div class=\"unselectable confirmation-buttons\">\n                            <button id=\"start-engine-game-no\" class=\"btn btn-secondary\"><%=t('play.editor.no')%></button>\n                            <button id=\"start-engine-game-yes\" class=\"btn btn-primary\"><%=t('play.editor.yes')%></button>\n                        </div>\n                    </div>\n\n                    <!-- Pause UI -->\n                    <div id=\"pauseUI\" class=\"pauseUI hidden\">\n                        <p class=\"paused\"><%=t('play.pause.title')%></p>\n                        <button id=\"resume\" class=\"resume button\"><%=t('play.pause.resume')%></button>\n                        <button id=\"togglepointers\" class=\"togglepointers button\"><%=t('play.pause.arrows')%></button>\n                        <button id=\"toggleperspective\" class=\"toggleperspective button\"><%=t('play.pause.perspective')%></button>\n                        <button id=\"copygame\" class=\"copygame button\"><%=t('play.pause.copy')%></button>\n                        <button id=\"pastegame\" class=\"pastegame button\"><%=t('play.pause.paste')%></button>\n                        <button id=\"offerdraw\" class=\"offerdraw button\"><%=t('play.pause.offer_draw')%></button>\n                        <button id=\"practicemenu\" class=\"practicemenu button hidden\"><%=t('play.pause.practice_menu')%></button>\n                        <button id=\"mainmenu\" class=\"mainmenu button\"><%=t('play.pause.main_menu')%></button>\n                    </div>\n\n                    <!-- Status message -->\n                    <div id=\"toastmessage\" class=\"toastmessage hidden\">\n                        <p id=\"toast\" class=\"toast\"></p>\n                    </div>\n                </div>\n            </div>\n        </main>\n    </body>\n</html>"
  },
  {
    "path": "src/client/views/resetpassword.ejs",
    "content": "<!DOCTYPE html>\r\n<html lang=\"<%=language%>\" dir=\"<%=t('direction')%>\" >\r\n    <head>\r\n        <script>\r\n            const translations = <%-JSON.stringify({\r\n                ...t('reset-password.javascript', {returnObjects: true}),\r\n                ...t('password-validation', {returnObjects: true})\r\n            })%>;\r\n        </script>\r\n        <meta charset=\"utf-8\" />\r\n        <meta http-equiv=\"x-ua-compatible\" content=\"ie=edge\" />\r\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\r\n        <title><%=t('reset_password.title')%></title>\r\n        <link rel='stylesheet' href='/css/header.css'>\r\n        <link rel='stylesheet' href='/css/login.css'> <!-- We can reuse login.css -->\r\n        <link rel='stylesheet' href='/css/footer.css'>\r\n        <link rel=\"icon\" href=\"/img/favicon/favicon-light.png\" media=\"(prefers-color-scheme: light)\" />\r\n        <link rel=\"icon\" href=\"/img/favicon/favicon-dark.png\" media=\"(prefers-color-scheme: dark)\" id=\"favicon\" />\r\n        <script defer src=\"/scripts/esm/views/resetpassword.js\" type=\"module\"></script> <!-- New JS file -->\r\n    </head>\r\n    <body>\r\n        <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>\r\n        <main>\r\n            <div id=\"content\">                  \r\n                <h1 class=\"center\"><%=t('reset_password.title')%></h1>\r\n                <form class=\"center\" id=\"reset-form\">\r\n                    <p class=\"form-instruction\"><%=t('reset_password.instruction')%></p>\r\n                    <div class=\"formfield\">\r\n                        <div id=\"new-password-line\">\r\n                            <label for=\"new-password\"><%=t('reset_password.new_password')%></label>\r\n                            <input id=\"new-password\" type=\"password\" required autofocus>\r\n                        </div>\r\n                        <div id=\"confirm-password-line\">\r\n                            <label for=\"confirm-password\"><%=t('reset_password.confirm_password')%></label>\r\n                            <input id=\"confirm-password\" type=\"password\" required>\r\n                        </div>\r\n                        <!-- Message element will be inserted here by JS -->\r\n                    </div>\r\n                    <input type=\"submit\" value=\"<%=t('reset_password.submit_button')%>\" class=\"unavailable\" id=\"submit-reset\">\r\n                </form>\r\n            </div>\r\n        </main>\r\n        <%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %>\r\n    </body>\r\n</html>"
  },
  {
    "path": "src/client/views/termsofservice.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"<%=language%>\" dir=\"<%=t('direction')%>\" >\n    <head>\n        <meta charset=\"utf-8\" />\n        <meta http-equiv=\"x-ua-compatible\" content=\"ie=edge\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n        <title><%=t('terms.title')%></title>\n        <link rel='stylesheet' href='/css/header.css'>\n        <link rel='stylesheet' href='/css/termsofservice.css'>\n        <link rel='stylesheet' href='/css/footer.css'>\n        <link rel=\"icon\" href=\"/img/favicon/favicon-light.png\" media=\"(prefers-color-scheme: light)\" />\n        <link rel=\"icon\" href=\"/img/favicon/favicon-dark.png\" media=\"(prefers-color-scheme: dark)\" id=\"favicon\" />\n    </head>\n    <body>\n        <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>\n        <main>\n            <div id=\"content\">\n                <h1 class=\"center\"><%=t('terms.title')%></h1>\n                <% if (language !== \"en-US\") { %>\n                    <p style=\"font-weight: bold; color: red; background-color: #feccc9; padding: 5px;\"><%=t('terms.warning.0')%><a style=\"color: red\" href=\"/termsofservice?lng=en-US\"><%=t('terms.warning.1')%></a><%=t('terms.warning.2')%><p>\n                <% } %>\n                <p class=\"center\" style=\"margin-bottom:3em\"><%=t('terms.consent')%></p>\n                <p><%=t('terms.guardian_consent')%></p>\n                <h2><%=t('terms.parents_header')%></h2>\n                <p><%=t('terms.parents_paragraphs.0')%></p>\n                <p><%=t('terms.parents_paragraphs.1')%></p>\n\n                <h2><%=t('terms.fair_play_header')%></h2>\n                <p><%=t('terms.fair_play_paragraph1.0')%></p>\n                <p><%=t('terms.fair_play_paragraph2')%></p>\n                <ul>\n                    <li><p><%=t('terms.fair_play_rules.0')%></p></li>\n                    <li><p><%=t('terms.fair_play_rules.1')%></p></li>\n                    <li><p><%=t('terms.fair_play_rules.2')%></p></li>\n                    <li><p><%=t('terms.fair_play_rules.3')%></p></li>\n                </ul>\n\n                <h2><%=t('terms.cleanliness_header')%></h2>\n                <p><%=t('terms.cleanliness_rules.0')%></p>\n                <p><%=t('terms.cleanliness_rules.1')%></p>\n\n                <h2><%=t('terms.privacy_header')%></h2>\n                <p><%=t('terms.privacy_rules.0')%></p>\n                <p><%=t('terms.privacy_rules.1')%></p>\n                <p><%=t('terms.privacy_rules.2')%></p>\n                <p><%=t('terms.privacy_rules.3')%></p>\n                <p><%=t('terms.privacy_rules.4.0')%> <a href='/news'><%=t('terms.privacy_rules.4.1')%></a> <%=t('terms.privacy_rules.4.2')%></p>\n                <p><%=t('terms.privacy_rules.5')%></p>\n                <p><%=t('terms.privacy_rules.6')%></p>\n                \n                <h2><%=t('terms.cookie_header')%></h2>\n                <p><%=t('terms.cookie_paragraphs.0')%></p>\n                <p><%=t('terms.cookie_paragraphs.1')%></p>\n\n                <h2><%=t('terms.conclusion_header')%></h2>\n                <p><%=t('terms.conclusion_paragraphs.0')%></p>\n                <p><%=t('terms.conclusion_paragraphs.1.0')%> <a href='/news'><%=t('terms.conclusion_paragraphs.1.1')%></a> <%=t('terms.conclusion_paragraphs.1.2')%></p>\n                <p><%=t('terms.conclusion_paragraphs.2.0')%> <a href='https://github.com/Infinite-Chess/infinitechess.org/blob/main/docs/COPYING.md' target='_blank'><%=t('terms.conclusion_paragraphs.2.1')%></a><%=t('terms.conclusion_paragraphs.2.2')%></p>\n                <p><%=t('terms.conclusion_paragraphs.3')%></p>\n                <p><%=t('terms.conclusion_paragraphs.4')%></p>\n                <p><%=t('terms.conclusion_paragraphs.5.0')%> <a href='mailto:support@infinitechess.org'><%=t('terms.conclusion_paragraphs.5.1')%></a></p>\n                \n                <h3><%=t('terms.thanks')%></h3>\n            </div>\n        </main>\n        <%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %>\n    </body>\n</html>\n"
  },
  {
    "path": "src/server/api/AdminPanel.ts",
    "content": "// src/server/api/AdminPanel.ts\n\n/**\n * This script handles all incoming commands send from the admin console page\n * /admin\n */\n\nimport type { Request, Response } from 'express';\n\nimport validators from '../../shared/util/validators.js';\n\nimport { deleteAccount } from '../controllers/deleteAccountController.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\nimport { manuallyVerifyUser } from '../controllers/verifyAccountController.js';\nimport { getMemberDataByCriteria } from '../database/memberManager.js';\nimport { areRolesHigherInPriority } from '../controllers/roles.js';\nimport { refreshGitHubContributorsList } from './GitHub.js';\nimport { deleteAllRefreshTokensForUser } from '../database/refreshTokenManager.js';\nimport { addToBlacklist, removeFromBlacklist } from '../database/blacklistManager.js';\n\n// Constants -------------------------------------------------------------------------\n\nconst validCommands = [\n\t'ban',\n\t'unban',\n\t'delete',\n\t'username',\n\t'logout',\n\t'verify',\n\t'userinfo',\n\t'updatecontributors',\n\t'help',\n] as const;\n\n// Functions -------------------------------------------------------------------------\n\nfunction processCommand(req: Request, res: Response): void {\n\tconst command = req.params['command']!;\n\n\tconst commandAndArgs = parseArgumentsFromCommand(command);\n\n\tif (!req.memberInfo || !req.memberInfo.signedIn) {\n\t\tres.status(401).send('Cannot send commands while logged out.');\n\t\treturn;\n\t}\n\tif (!(req.memberInfo.roles?.includes('admin') ?? false)) {\n\t\tres.status(403).send('Cannot send commands without the admin role');\n\t\treturn;\n\t}\n\t// TODO prevent affecting accounts with equal or higher roles\n\tswitch (commandAndArgs[0]) {\n\t\tcase 'ban':\n\t\t\tbanEmailCommand(command, commandAndArgs, req, res);\n\t\t\treturn;\n\t\tcase 'unban':\n\t\t\tunbanEmailCommand(command, commandAndArgs, req, res);\n\t\t\treturn;\n\t\tcase 'delete':\n\t\t\tdeleteCommand(command, commandAndArgs, req, res);\n\t\t\treturn;\n\t\tcase 'username':\n\t\t\tusernameCommand(command, commandAndArgs, req, res);\n\t\t\treturn;\n\t\tcase 'logout':\n\t\t\tlogoutUser(command, commandAndArgs, req, res);\n\t\t\treturn;\n\t\tcase 'verify':\n\t\t\tverify(command, commandAndArgs, req, res);\n\t\t\treturn;\n\t\tcase 'userinfo':\n\t\t\tgetUserInfo(command, commandAndArgs, req, res);\n\t\t\treturn;\n\t\tcase 'updatecontributors':\n\t\t\tupdateContributorsCommand(command, req, res);\n\t\t\treturn;\n\t\tcase 'help':\n\t\t\thelpCommand(commandAndArgs, res);\n\t\t\treturn;\n\t\tdefault:\n\t\t\tres.status(422).send('Unknown command.');\n\t\t\treturn;\n\t}\n}\n\nfunction parseArgumentsFromCommand(command: string): string[] {\n\t// Parse command\n\tconst commandAndArgs: string[] = [];\n\tlet inQuote: boolean = false;\n\tlet temp: string = '';\n\tfor (let i = 0; i < command.length; i++) {\n\t\tif (command[i] === '\"') {\n\t\t\tif (i === 0 || command[i - 1] !== '\\\\') {\n\t\t\t\tinQuote = !inQuote;\n\t\t\t} else {\n\t\t\t\ttemp += '\"';\n\t\t\t}\n\t\t} else if (command[i] === ' ' && !inQuote) {\n\t\t\tcommandAndArgs.push(temp);\n\t\t\ttemp = '';\n\t\t} else if (inQuote || (command[i] !== '\"' && command[i] !== ' ')) {\n\t\t\ttemp += command[i];\n\t\t}\n\t}\n\tcommandAndArgs.push(temp);\n\n\treturn commandAndArgs;\n}\n\nfunction deleteCommand(\n\tcommand: string,\n\tcommandAndArgs: string[],\n\treq: Request,\n\tres: Response,\n): void {\n\tif (commandAndArgs.length < 3) {\n\t\tres.status(422).send(\n\t\t\t'Invalid number of arguments, expected 2, got ' + (commandAndArgs.length - 1) + '.',\n\t\t);\n\t\treturn;\n\t}\n\t// Valid Syntax\n\tlogCommand(command, req);\n\tconst reason = commandAndArgs[2]!;\n\tconst usernameArgument = commandAndArgs[1]!;\n\tconst record = getMemberDataByCriteria(\n\t\t['user_id', 'username', 'roles'],\n\t\t'username',\n\t\tusernameArgument,\n\t);\n\tif (record === undefined)\n\t\treturn sendAndLogResponse(res, 404, 'User ' + usernameArgument + ' does not exist.');\n\n\t// They were found...\n\tconst adminsRoles = req.memberInfo?.signedIn ? req.memberInfo.roles : null;\n\tconst rolesOfAffectedUser = record.roles === null ? null : JSON.parse(record.roles);\n\t// Don't delete them if they are equal or higher than your status\n\tif (!areRolesHigherInPriority(adminsRoles, rolesOfAffectedUser))\n\t\treturn sendAndLogResponse(res, 403, 'Forbidden to delete ' + record.username + '.');\n\n\ttry {\n\t\tdeleteAccount(record.user_id, reason);\n\t\tsendAndLogResponse(res, 200, 'Successfully deleted user ' + record.username + '.');\n\t} catch (error: unknown) {\n\t\tconst errorMessage = error instanceof Error ? error.message : String(error);\n\t\tsendAndLogResponse(res, 500, `Failed to delete user (${record.username}): ${errorMessage}`);\n\t}\n}\n\nfunction banEmailCommand(\n\tcommand: string,\n\tcommandAndArgs: string[],\n\treq: Request,\n\tres: Response,\n): void {\n\tif (commandAndArgs.length !== 2) {\n\t\tres.status(422).send(\n\t\t\t'Invalid number of arguments, expected 1, got ' + (commandAndArgs.length - 1) + '.',\n\t\t);\n\t\treturn;\n\t}\n\t// Valid Syntax\n\tlogCommand(command, req);\n\tconst email = commandAndArgs[1]!.toLowerCase();\n\n\t// Validate email format\n\tconst validationResult = validators.validateEmail(email);\n\tif (validationResult !== validators.EmailValidationResult.Ok) {\n\t\tconst errorKey = validators.getEmailErrorTranslation(validationResult);\n\t\tsendAndLogResponse(res, 422, `Invalid email format: ${errorKey ?? 'unknown error'}`);\n\t\treturn;\n\t}\n\n\ttry {\n\t\taddToBlacklist(email, 'banned');\n\t\tsendAndLogResponse(res, 200, `Successfully banned ${email}.`);\n\t} catch (error: unknown) {\n\t\tconst errorMessage = error instanceof Error ? error.message : String(error);\n\t\tsendAndLogResponse(res, 500, `Failed to ban email (${email}): ${errorMessage}`);\n\t}\n}\n\nfunction unbanEmailCommand(\n\tcommand: string,\n\tcommandAndArgs: string[],\n\treq: Request,\n\tres: Response,\n): void {\n\tif (commandAndArgs.length !== 2) {\n\t\tres.status(422).send(\n\t\t\t'Invalid number of arguments, expected 1, got ' + (commandAndArgs.length - 1) + '.',\n\t\t);\n\t\treturn;\n\t}\n\t// Valid Syntax\n\tlogCommand(command, req);\n\tconst email = commandAndArgs[1]!.toLowerCase();\n\n\t// Validate email format\n\tconst validationResult = validators.validateEmail(email);\n\tif (validationResult !== validators.EmailValidationResult.Ok) {\n\t\tconst errorKey = validators.getEmailErrorTranslation(validationResult);\n\t\tsendAndLogResponse(res, 422, `Invalid email format: ${errorKey ?? 'unknown error'}`);\n\t\treturn;\n\t}\n\n\ttry {\n\t\tremoveFromBlacklist(email);\n\t\tsendAndLogResponse(res, 200, `Successfully unbanned ${email}.`);\n\t} catch (error: unknown) {\n\t\tconst errorMessage = error instanceof Error ? error.message : String(error);\n\t\tsendAndLogResponse(res, 500, `Failed to unban email (${email}): ${errorMessage}`);\n\t}\n}\n\nfunction usernameCommand(\n\tcommand: string,\n\tcommandAndArgs: string[],\n\treq: Request,\n\tres: Response,\n): void {\n\tif (commandAndArgs[1] === 'get') {\n\t\tif (commandAndArgs.length < 3) {\n\t\t\tres.status(422).send(\n\t\t\t\t'Invalid number of arguments, expected 2, got ' + (commandAndArgs.length - 1) + '.',\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tconst parsedId = Number.parseInt(commandAndArgs[2]!);\n\t\tif (Number.isNaN(parsedId)) {\n\t\t\tres.status(422).send('User id must be an integer.');\n\t\t\treturn;\n\t\t}\n\t\t// Valid Syntax\n\t\tlogCommand(command, req);\n\t\tconst record = getMemberDataByCriteria(['username'], 'user_id', parsedId);\n\t\tif (record === undefined)\n\t\t\tsendAndLogResponse(res, 404, 'User with id ' + parsedId + ' does not exist.');\n\t\telse sendAndLogResponse(res, 200, record.username);\n\t} else if (commandAndArgs[1] === 'set') {\n\t\tif (commandAndArgs.length < 4) {\n\t\t\tres.status(422).send(\n\t\t\t\t'Invalid number of arguments, expected 3, got ' + (commandAndArgs.length - 1) + '.',\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\t// TODO add username changing logic\n\t\tres.status(503).send('Changing usernames is not yet supported.');\n\t} else if (commandAndArgs[1] === undefined) {\n\t\tres.status(422).send('Expected either get or set as a subcommand.');\n\t} else {\n\t\tres.status(422).send(\n\t\t\t'Invalid subcommand, expected either get or set, got ' + commandAndArgs[1] + '.',\n\t\t);\n\t}\n}\n\nfunction logoutUser(command: string, commandAndArgs: string[], req: Request, res: Response): void {\n\tif (commandAndArgs.length < 2) {\n\t\tres.status(422).send(\n\t\t\t'Invalid number of arguments, expected 1, got ' + (commandAndArgs.length - 1) + '.',\n\t\t);\n\t\treturn;\n\t}\n\t// Valid Syntax\n\tlogCommand(command, req);\n\tconst usernameArgument = commandAndArgs[1]!;\n\tconst record = getMemberDataByCriteria(['user_id', 'username'], 'username', usernameArgument);\n\tif (record === undefined) {\n\t\tsendAndLogResponse(res, 404, 'User ' + usernameArgument + ' does not exist.');\n\t\treturn;\n\t}\n\n\ttry {\n\t\t// Effectively terminates all login sessions of the user\n\t\tdeleteAllRefreshTokensForUser(record.user_id);\n\t} catch (e) {\n\t\tconst errorMessage = e instanceof Error ? e.stack : String(e);\n\t\tlogEventsAndPrint(\n\t\t\t`Error during admin-manual-logout of user \"${record.username}\": ${errorMessage}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tsendAndLogResponse(\n\t\t\tres,\n\t\t\t500,\n\t\t\t`Failed to log out user \"${record.username}\" due to internal error.`,\n\t\t);\n\t\treturn;\n\t}\n\tsendAndLogResponse(res, 200, 'User ' + record.username + ' successfully logged out.'); // Use their case-sensitive username\n}\n\nfunction verify(command: string, commandAndArgs: string[], req: Request, res: Response): void {\n\tif (commandAndArgs.length < 2) {\n\t\tres.status(422).send(\n\t\t\t'Invalid number of arguments, expected 1, got ' + (commandAndArgs.length - 1) + '.',\n\t\t);\n\t\treturn;\n\t}\n\t// Valid Syntax\n\tlogCommand(command, req);\n\tconst email = commandAndArgs[1]!.toLowerCase();\n\n\t// Validate email format\n\tconst validationResult = validators.validateEmail(email);\n\tif (validationResult !== validators.EmailValidationResult.Ok) {\n\t\tconst errorKey = validators.getEmailErrorTranslation(validationResult);\n\t\tsendAndLogResponse(res, 422, `Invalid email format: ${errorKey ?? 'unknown error'}`);\n\t\treturn;\n\t}\n\n\t// This method works without us having to confirm they exist first\n\tconst result = manuallyVerifyUser(email); // { success, username, reason }\n\tif (result.success)\n\t\tsendAndLogResponse(res, 200, 'User ' + result.username + ' has been verified!');\n\telse sendAndLogResponse(res, 500, result.reason); // Failure message\n}\n\nfunction getUserInfo(command: string, commandAndArgs: string[], req: Request, res: Response): void {\n\tif (commandAndArgs.length < 2) {\n\t\tres.status(422).send(\n\t\t\t'Invalid number of arguments, expected 1, got ' + (commandAndArgs.length - 1) + '.',\n\t\t);\n\t\treturn;\n\t}\n\t// Valid Syntax\n\tlogCommand(command, req);\n\tconst username = commandAndArgs[1]!;\n\tconst record = getMemberDataByCriteria(\n\t\t[\n\t\t\t'user_id',\n\t\t\t'username',\n\t\t\t'roles',\n\t\t\t'joined',\n\t\t\t'last_seen',\n\t\t\t'preferences',\n\t\t\t'is_verified',\n\t\t\t'is_verification_notified',\n\t\t\t'username_history',\n\t\t\t'checkmates_beaten',\n\t\t],\n\t\t'username',\n\t\tusername,\n\t);\n\tif (record === undefined) sendAndLogResponse(res, 404, 'User ' + username + ' does not exist.');\n\telse sendAndLogResponse(res, 200, JSON.stringify(record));\n}\n\nfunction updateContributorsCommand(command: string, req: Request, res: Response): void {\n\tlogCommand(command, req);\n\trefreshGitHubContributorsList();\n\tsendAndLogResponse(res, 200, 'Contributors should now be updated!');\n}\n\nfunction helpCommand(commandAndArgs: string[], res: Response): void {\n\tif (commandAndArgs.length === 1) {\n\t\tres.status(200).send(\n\t\t\t'Commands: ' +\n\t\t\t\tvalidCommands.join(', ') +\n\t\t\t\t'\\nUse help <command> to get more information about a command.',\n\t\t);\n\t\treturn;\n\t}\n\tswitch (commandAndArgs[1]) {\n\t\tcase 'ban':\n\t\t\tres.status(200).send('Syntax: ban <email>\\nBans the given email address.');\n\t\t\treturn;\n\t\tcase 'unban':\n\t\t\tres.status(200).send('Syntax: unban <email>\\nUnbans the given email address.');\n\t\t\treturn;\n\t\tcase 'delete':\n\t\t\tres.status(200).send(\n\t\t\t\t\"Syntax: delete <username> [reason]\\nDeletes the given user's account for an optional reason.\",\n\t\t\t);\n\t\t\treturn;\n\t\tcase 'username':\n\t\t\tres.status(200).send(\n\t\t\t\t'Syntax: username get <userid>\\n        username set <userid> <newUsername>\\nGets or sets the username of the account with the given userid',\n\t\t\t);\n\t\t\treturn;\n\t\tcase 'logout':\n\t\t\tres.status(200).send(\n\t\t\t\t'Syntax: logout <username>\\nLogs out all sessions of the account with the given username.',\n\t\t\t);\n\t\t\treturn;\n\t\tcase 'verify':\n\t\t\tres.status(200).send(\n\t\t\t\t'Syntax: verify <email>\\nVerifies the account with the given email address.',\n\t\t\t);\n\t\t\treturn;\n\t\tcase 'userinfo':\n\t\t\tres.status(200).send('Syntax: userinfo <username>\\nPrints info about a user.');\n\t\t\treturn;\n\t\tcase 'updatecontributors':\n\t\t\tres.status(200).send(\n\t\t\t\t'Syntax: updatecontributors\\nManually update to the most recent contributors list from the Github API. Should be used for testing',\n\t\t\t);\n\t\t\treturn;\n\t\tcase 'help':\n\t\t\tres.status(200).send(\n\t\t\t\t'Syntax: help [command]\\nPrints the list of commands or information about a command.',\n\t\t\t);\n\t\t\treturn;\n\t\tdefault:\n\t\t\tres.status(422).send('Unknown command.');\n\t\t\treturn;\n\t}\n}\n\nfunction logCommand(command: string, req: Request): void {\n\tif (req.memberInfo?.signedIn) {\n\t\tlogEventsAndPrint(\n\t\t\t`Command executed by admin \"${req.memberInfo.username}\" of id \"${req.memberInfo.user_id}\":   ` +\n\t\t\t\tcommand,\n\t\t\t'adminCommands.txt',\n\t\t);\n\t} else throw new Error('Admin SHOULD have been logged in by this point. DANGEROUS');\n}\n\nfunction sendAndLogResponse(res: Response, code: number, message: any): void {\n\tres.status(code).send(message);\n\t// Also log the sent response\n\tlogEventsAndPrint('Result:   ' + message + '\\n', 'adminCommands.txt');\n}\n\nexport { processCommand };\n"
  },
  {
    "path": "src/server/api/EditorSavesAPI.int.test.ts",
    "content": "// src/server/api/EditorSavesAPI.int.test.ts\n\n/**\n * Integration tests for the EditorSavesAPI endpoints.\n *\n * This test suite verifies that the editor saves API endpoints work correctly,\n * including authentication, validation, quota limits, and ownership verification.\n */\n\nimport { describe, it, expect, beforeEach, beforeAll } from 'vitest';\n\nimport editorutil from '../../shared/util/editorutil.js';\n\nimport { testRequest } from '../../tests/testRequest.js';\nimport integrationUtils from '../../tests/integrationUtils.js';\n\nimport editorSavesManager from '../database/editorSavesManager.js';\nimport { generateTables, clearAllTables } from '../database/databaseTables.js';\n\ndescribe('EditorSavesAPI Integration', () => {\n\t// Runs once at the very start of this file\n\tbeforeAll(() => {\n\t\tgenerateTables();\n\t});\n\n\t// Runs before EVERY single 'it' block\n\tbeforeEach(() => {\n\t\tclearAllTables();\n\t});\n\n\tdescribe('GET /api/editor-saves', () => {\n\t\tit('should return all saved positions for authenticated user', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t\tconst position1 = {\n\t\t\t\tname: 'A simple position',\n\t\t\t\tpiece_count: 32,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t\ticn: 'icn-data-1',\n\t\t\t\tcompression: 'none',\n\t\t\t\tpawn_double_push: true,\n\t\t\t\tcastling: true,\n\t\t\t};\n\n\t\t\tconst position2 = {\n\t\t\t\tname: 'Another simple position',\n\t\t\t\tpiece_count: 76,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t\ticn: 'icn-data-2',\n\t\t\t\tcompression: 'none',\n\t\t\t\tpawn_double_push: false,\n\t\t\t\tcastling: true,\n\t\t\t};\n\n\t\t\t// Save positions to the database through the API\n\t\t\tawait testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send(position1);\n\n\t\t\tawait testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send(position2);\n\n\t\t\tconst response = await testRequest()\n\t\t\t\t.get('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie);\n\n\t\t\texpect(response.status).toBe(200);\n\t\t\texpect(response.body.saves).toMatchObject([\n\t\t\t\t{\n\t\t\t\t\tname: position1.name,\n\t\t\t\t\tpiece_count: position1.piece_count,\n\t\t\t\t\ttimestamp: position1.timestamp,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname: position2.name,\n\t\t\t\t\tpiece_count: position2.piece_count,\n\t\t\t\t\ttimestamp: position2.timestamp,\n\t\t\t\t},\n\t\t\t]);\n\t\t});\n\n\t\tit('should return 401 if user is not authenticated', async () => {\n\t\t\tconst response = await testRequest().get('/api/editor-saves');\n\n\t\t\texpect(response.status).toBe(401);\n\t\t});\n\t});\n\n\tdescribe('POST /api/editor-saves', () => {\n\t\tit('should save a new position successfully', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t\tconst position = {\n\t\t\t\tname: 'Test Position',\n\t\t\t\tpiece_count: 32,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t\ticn: 'test-icn-data',\n\t\t\t\tcompression: 'none',\n\t\t\t\tpawn_double_push: true,\n\t\t\t\tcastling: false,\n\t\t\t};\n\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send(position);\n\n\t\t\texpect(response.status).toBe(201);\n\t\t\texpect(response.body).toMatchObject({ success: true });\n\n\t\t\t// Verify the position was actually saved to the database\n\t\t\tconst saves = editorSavesManager.getAllSavedPositionsForUser(user.user_id);\n\t\t\texpect(saves[0]).toMatchObject({\n\t\t\t\tname: position.name,\n\t\t\t\tpiece_count: position.piece_count,\n\t\t\t\ttimestamp: position.timestamp,\n\t\t\t});\n\t\t});\n\n\t\tit('should save and retrieve undefined (indeterminate) tristate for pawn_double_push and castling', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: 'Tristate Position',\n\t\t\t\t\tpiece_count: 5,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\ticn: 'test-icn-tristate',\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: undefined,\n\t\t\t\t\tcastling: undefined,\n\t\t\t\t});\n\n\t\t\texpect(response.status).toBe(201);\n\n\t\t\t// Verify raw DB values: -1 = indeterminate\n\t\t\tconst icnData = editorSavesManager.getSavedPositionICN(\n\t\t\t\t'Tristate Position',\n\t\t\t\tuser.user_id,\n\t\t\t);\n\t\t\texpect(icnData?.pawn_double_push).toBe(-1);\n\t\t\texpect(icnData?.castling).toBe(-1);\n\t\t});\n\n\t\tit('should return 400 if name is missing', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tpiece_count: 10,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\ticn: 'test-icn-data',\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: true,\n\t\t\t\t\tcastling: true,\n\t\t\t\t});\n\n\t\t\texpect(response.status).toBe(400);\n\t\t});\n\n\t\tit('should return 400 if name is empty', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: '',\n\t\t\t\t\tpiece_count: 13,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\ticn: 'test-icn-data',\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: false,\n\t\t\t\t\tcastling: false,\n\t\t\t\t});\n\n\t\t\texpect(response.status).toBe(400);\n\t\t});\n\n\t\tit('should return 400 if name exceeds max length', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\t\t\tconst longName = 'a'.repeat(editorutil.MAX_POSITION_NAME_LENGTH + 1);\n\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: longName,\n\t\t\t\t\tpiece_count: 13,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\ticn: 'test-icn-data',\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: true,\n\t\t\t\t\tcastling: true,\n\t\t\t\t});\n\n\t\t\texpect(response.status).toBe(400);\n\t\t});\n\n\t\tit('should return 400 if icn is missing', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: 'Test Position',\n\t\t\t\t\tpiece_count: 13,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: true,\n\t\t\t\t\tcastling: true,\n\t\t\t\t});\n\n\t\t\texpect(response.status).toBe(400);\n\t\t});\n\n\t\tit('should return 400 if icn is empty', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: 'Test Position',\n\t\t\t\t\tpiece_count: 0,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\ticn: '',\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: false,\n\t\t\t\t\tcastling: false,\n\t\t\t\t});\n\n\t\t\texpect(response.status).toBe(400);\n\t\t});\n\n\t\tit('should return 400 if icn exceeds max length', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\t\t\tconst longIcn = 'a'.repeat(editorutil.MAX_ICN_LENGTH + 1);\n\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: 'Test Position',\n\t\t\t\t\tpiece_count: 278_569,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\ticn: longIcn,\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: true,\n\t\t\t\t\tcastling: false,\n\t\t\t\t});\n\n\t\t\texpect(response.status).toBe(400);\n\t\t});\n\n\t\tit('should return 403 if quota is exceeded', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t\t// Add 50 positions to reach the quota limit\n\t\t\tfor (let i = 0; i < editorSavesManager.MAX_SAVED_POSITIONS; i++) {\n\t\t\t\tawait testRequest()\n\t\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t\t.send({\n\t\t\t\t\t\tname: `Position ${i}`,\n\t\t\t\t\t\tpiece_count: 8,\n\t\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\t\ticn: 'test-icn',\n\t\t\t\t\t\tcompression: 'none',\n\t\t\t\t\t\tpawn_double_push: true,\n\t\t\t\t\t\tcastling: true,\n\t\t\t\t\t});\n\t\t\t}\n\n\t\t\t// Try to add one more, should fail\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: 'Test Position',\n\t\t\t\t\tpiece_count: 13,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\ticn: 'test-icn-data',\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: false,\n\t\t\t\t\tcastling: false,\n\t\t\t\t});\n\n\t\t\texpect(response.status).toBe(403);\n\t\t});\n\n\t\tit('should overwrite position if name already exists', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t\t// Save first position\n\t\t\tawait testRequest().post('/api/editor-saves').set('Cookie', user.cookie).send({\n\t\t\t\tname: 'Duplicate Name',\n\t\t\t\tpiece_count: 10,\n\t\t\t\ttimestamp: 1000,\n\t\t\t\ticn: 'test-icn-1',\n\t\t\t\tcompression: 'none',\n\t\t\t\tpawn_double_push: true,\n\t\t\t\tcastling: false,\n\t\t\t});\n\n\t\t\t// Save another position with the same name but different data\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: 'Duplicate Name',\n\t\t\t\t\tpiece_count: 20,\n\t\t\t\t\ttimestamp: 2000,\n\t\t\t\t\ticn: 'test-icn-2',\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: false,\n\t\t\t\t\tcastling: true,\n\t\t\t\t});\n\n\t\t\t// Should succeed\n\t\t\texpect(response.status).toBe(201);\n\n\t\t\t// Verify only one position exists with the new data\n\t\t\tconst saves = editorSavesManager.getAllSavedPositionsForUser(user.user_id);\n\t\t\texpect(saves).toMatchObject([\n\t\t\t\t{\n\t\t\t\t\tname: 'Duplicate Name',\n\t\t\t\t\tpiece_count: 20,\n\t\t\t\t\ttimestamp: 2000,\n\t\t\t\t},\n\t\t\t]);\n\n\t\t\t// Verify the ICN was also overwritten\n\t\t\tconst icnData = editorSavesManager.getSavedPositionICN('Duplicate Name', user.user_id);\n\t\t\texpect(icnData?.icn).toBe('test-icn-2');\n\t\t\texpect(icnData?.pawn_double_push).toBe(0);\n\t\t\texpect(icnData?.castling).toBe(1);\n\t\t});\n\n\t\tit('should return 401 if user is not authenticated', async () => {\n\t\t\tconst response = await testRequest().post('/api/editor-saves').send({\n\t\t\t\tname: 'Test Position',\n\t\t\t\tpiece_count: 13,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t\ticn: 'test-icn-data',\n\t\t\t\tcompression: 'none',\n\t\t\t\tpawn_double_push: true,\n\t\t\t\tcastling: true,\n\t\t\t});\n\n\t\t\texpect(response.status).toBe(401);\n\t\t});\n\n\t\tit('should return 400 if timestamp is missing', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: 'Test Position',\n\t\t\t\t\tpiece_count: 13,\n\t\t\t\t\ticn: 'test-icn-data',\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: true,\n\t\t\t\t\tcastling: true,\n\t\t\t\t});\n\n\t\t\texpect(response.status).toBe(400);\n\t\t});\n\n\t\tit('should return 400 if piece_count is missing', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: 'Test Position',\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\ticn: 'test-icn-data',\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: true,\n\t\t\t\t\tcastling: true,\n\t\t\t\t});\n\n\t\t\texpect(response.status).toBe(400);\n\t\t});\n\n\t\tit('should treat missing pawn_double_push as indeterminate and save successfully', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: 'Test Position',\n\t\t\t\t\tpiece_count: 13,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\ticn: 'test-icn-data',\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tcastling: true,\n\t\t\t\t});\n\n\t\t\texpect(response.status).toBe(201);\n\n\t\t\t// Omitted field should be stored as -1 (indeterminate)\n\t\t\tconst icnData = editorSavesManager.getSavedPositionICN('Test Position', user.user_id);\n\t\t\texpect(icnData?.pawn_double_push).toBe(-1);\n\t\t});\n\n\t\tit('should treat missing castling as indeterminate and save successfully', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: 'Test Position',\n\t\t\t\t\tpiece_count: 13,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\ticn: 'test-icn-data',\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: true,\n\t\t\t\t});\n\n\t\t\texpect(response.status).toBe(201);\n\n\t\t\t// Omitted field should be stored as -1 (indeterminate)\n\t\t\tconst icnData = editorSavesManager.getSavedPositionICN('Test Position', user.user_id);\n\t\t\texpect(icnData?.castling).toBe(-1);\n\t\t});\n\t});\n\n\tdescribe('GET /api/editor-saves/:position_name', () => {\n\t\tit('should return position ICN if user owns it', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t\t// Save a position first\n\t\t\tawait testRequest().post('/api/editor-saves').set('Cookie', user.cookie).send({\n\t\t\t\tname: 'Test Position',\n\t\t\t\tpiece_count: 13,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t\ticn: 'test-icn-data',\n\t\t\t\tcompression: 'none',\n\t\t\t\tpawn_double_push: true,\n\t\t\t\tcastling: false,\n\t\t\t});\n\n\t\t\tconst response = await testRequest()\n\t\t\t\t.get(`/api/editor-saves/${encodeURIComponent('Test Position')}`)\n\t\t\t\t.set('Cookie', user.cookie);\n\n\t\t\texpect(response.status).toBe(200);\n\t\t\texpect(response.body).toMatchObject({\n\t\t\t\ticn: 'test-icn-data',\n\t\t\t\tpawn_double_push: true,\n\t\t\t\tcastling: false,\n\t\t\t});\n\t\t\texpect(typeof response.body.timestamp).toBe('number');\n\t\t});\n\n\t\tit('should return 404 if position not found or not owned', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t\tconst response = await testRequest()\n\t\t\t\t.get(`/api/editor-saves/${encodeURIComponent('Nonexistent Position')}`)\n\t\t\t\t.set('Cookie', user.cookie);\n\n\t\t\texpect(response.status).toBe(404);\n\t\t});\n\n\t\tit('should handle position names with spaces', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t\t// Save a position with spaces in the name\n\t\t\tawait testRequest().post('/api/editor-saves').set('Cookie', user.cookie).send({\n\t\t\t\tname: 'Position With Spaces',\n\t\t\t\tpiece_count: 16,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t\ticn: 'test-icn-spaces',\n\t\t\t\tcompression: 'none',\n\t\t\t\tpawn_double_push: false,\n\t\t\t\tcastling: true,\n\t\t\t});\n\n\t\t\tconst response = await testRequest()\n\t\t\t\t.get(`/api/editor-saves/${encodeURIComponent('Position With Spaces')}`)\n\t\t\t\t.set('Cookie', user.cookie);\n\n\t\t\texpect(response.status).toBe(200);\n\t\t\texpect(response.body).toMatchObject({\n\t\t\t\ticn: 'test-icn-spaces',\n\t\t\t\tpawn_double_push: false,\n\t\t\t\tcastling: true,\n\t\t\t});\n\t\t\texpect(typeof response.body.timestamp).toBe('number');\n\t\t});\n\n\t\tit('should return undefined for indeterminate (tristate) pawn_double_push and castling', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t\tawait testRequest().post('/api/editor-saves').set('Cookie', user.cookie).send({\n\t\t\t\tname: 'Tristate Get Test',\n\t\t\t\tpiece_count: 3,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t\ticn: 'test-icn-tristate',\n\t\t\t\tcompression: 'none',\n\t\t\t\tpawn_double_push: undefined,\n\t\t\t\tcastling: undefined,\n\t\t\t});\n\n\t\t\tconst response = await testRequest()\n\t\t\t\t.get(`/api/editor-saves/${encodeURIComponent('Tristate Get Test')}`)\n\t\t\t\t.set('Cookie', user.cookie);\n\n\t\t\texpect(response.status).toBe(200);\n\t\t\texpect(response.body.pawn_double_push).toBeUndefined();\n\t\t\texpect(response.body.castling).toBeUndefined();\n\t\t});\n\n\t\tit('should return 401 if user is not authenticated', async () => {\n\t\t\tconst response = await testRequest().get(\n\t\t\t\t`/api/editor-saves/${encodeURIComponent('Test Position')}`,\n\t\t\t);\n\n\t\t\texpect(response.status).toBe(401);\n\t\t});\n\t});\n\n\tdescribe('DELETE /api/editor-saves/:position_name', () => {\n\t\tit('should delete position successfully', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t\t// Save a position first\n\t\t\tawait testRequest().post('/api/editor-saves').set('Cookie', user.cookie).send({\n\t\t\t\tname: 'Test Position',\n\t\t\t\tpiece_count: 13,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t\ticn: 'test-icn-data',\n\t\t\t\tcompression: 'none',\n\t\t\t\tpawn_double_push: true,\n\t\t\t\tcastling: true,\n\t\t\t});\n\n\t\t\tconst response = await testRequest()\n\t\t\t\t.delete(`/api/editor-saves/${encodeURIComponent('Test Position')}`)\n\t\t\t\t.set('Cookie', user.cookie);\n\n\t\t\texpect(response.status).toBe(200);\n\t\t\texpect(response.body).toMatchObject({ success: true, saves: [] });\n\n\t\t\t// Verify the position was actually deleted from the database\n\t\t\tconst saves = editorSavesManager.getAllSavedPositionsForUser(user.user_id);\n\t\t\texpect(saves).toHaveLength(0);\n\t\t});\n\n\t\tit('should return 404 if position not found or not owned', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t\tconst response = await testRequest()\n\t\t\t\t.delete(`/api/editor-saves/${encodeURIComponent('Nonexistent Position')}`)\n\t\t\t\t.set('Cookie', user.cookie);\n\n\t\t\texpect(response.status).toBe(404);\n\t\t});\n\n\t\tit('should handle position names with spaces', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t\t// Save a position with spaces\n\t\t\tawait testRequest().post('/api/editor-saves').set('Cookie', user.cookie).send({\n\t\t\t\tname: 'Position With Spaces',\n\t\t\t\tpiece_count: 8,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t\ticn: 'test-icn',\n\t\t\t\tcompression: 'none',\n\t\t\t\tpawn_double_push: false,\n\t\t\t\tcastling: false,\n\t\t\t});\n\n\t\t\tconst response = await testRequest()\n\t\t\t\t.delete(`/api/editor-saves/${encodeURIComponent('Position With Spaces')}`)\n\t\t\t\t.set('Cookie', user.cookie);\n\n\t\t\texpect(response.status).toBe(200);\n\t\t});\n\n\t\tit('should return 401 if user is not authenticated', async () => {\n\t\t\tconst response = await testRequest().delete(\n\t\t\t\t`/api/editor-saves/${encodeURIComponent('Test Position')}`,\n\t\t\t);\n\n\t\t\texpect(response.status).toBe(401);\n\t\t});\n\t});\n\n\tdescribe('Edge cases and integration', () => {\n\t\tit('should handle very long ICN within limit', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t\tconst maxLengthIcn = 'a'.repeat(editorutil.MAX_ICN_LENGTH);\n\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: 'Test',\n\t\t\t\t\tpiece_count: 250_592,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\ticn: maxLengthIcn,\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: true,\n\t\t\t\t\tcastling: false,\n\t\t\t\t});\n\n\t\t\texpect(response.status).toBe(201);\n\n\t\t\t// Verify it was saved correctly\n\t\t\tconst save = editorSavesManager.getSavedPositionICN('Test', user.user_id);\n\t\t\texpect(save?.icn).toBe(maxLengthIcn);\n\t\t});\n\n\t\tit('should handle name at max length', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t\tconst maxLengthName = 'a'.repeat(editorutil.MAX_POSITION_NAME_LENGTH);\n\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: maxLengthName,\n\t\t\t\t\tpiece_count: 4,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\ticn: 'test',\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: false,\n\t\t\t\t\tcastling: true,\n\t\t\t\t});\n\n\t\t\texpect(response.status).toBe(201);\n\n\t\t\t// Verify it was saved correctly\n\t\t\tconst saves = editorSavesManager.getAllSavedPositionsForUser(user.user_id);\n\t\t\texpect(saves[0]?.name).toBe(maxLengthName);\n\t\t});\n\n\t\tit('should receive piece_count from client', async () => {\n\t\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t\tconst icn = '12345';\n\n\t\t\tconst response = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: 'Test',\n\t\t\t\t\tpiece_count: 100,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\ticn,\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: true,\n\t\t\t\t\tcastling: true,\n\t\t\t\t});\n\n\t\t\texpect(response.status).toBe(201);\n\n\t\t\t// Verify the piece_count was set correctly from client\n\t\t\tconst saves = editorSavesManager.getAllSavedPositionsForUser(user.user_id);\n\t\t\texpect(saves[0]?.piece_count).toBe(100);\n\t\t});\n\n\t\tit('should allow two different users to have positions with the same name', async () => {\n\t\t\tconst user1 = await integrationUtils.createAndLoginUser();\n\t\t\tconst user2 = await integrationUtils.createAndLoginUser();\n\n\t\t\t// Both users save a position with the same name\n\t\t\tconst response1 = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user1.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: 'Same Name',\n\t\t\t\t\tpiece_count: 10,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\ticn: 'icn-user1',\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: true,\n\t\t\t\t\tcastling: false,\n\t\t\t\t});\n\n\t\t\tconst response2 = await testRequest()\n\t\t\t\t.post('/api/editor-saves')\n\t\t\t\t.set('Cookie', user2.cookie)\n\t\t\t\t.send({\n\t\t\t\t\tname: 'Same Name',\n\t\t\t\t\tpiece_count: 10,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\ticn: 'icn-user2',\n\t\t\t\t\tcompression: 'none',\n\t\t\t\t\tpawn_double_push: false,\n\t\t\t\t\tcastling: true,\n\t\t\t\t});\n\n\t\t\texpect(response1.status).toBe(201);\n\t\t\texpect(response2.status).toBe(201);\n\n\t\t\t// Verify both positions exist independently\n\t\t\tconst saves1 = editorSavesManager.getAllSavedPositionsForUser(user1.user_id);\n\t\t\tconst saves2 = editorSavesManager.getAllSavedPositionsForUser(user2.user_id);\n\n\t\t\texpect(saves1[0]?.name).toBe('Same Name');\n\t\t\texpect(saves2[0]?.name).toBe('Same Name');\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "src/server/api/EditorSavesAPI.ts",
    "content": "// src/server/api/EditorSavesAPI.ts\n\n/**\n * API endpoints for managing saved positions in the editor.\n */\n\nimport type { Request, Response } from 'express';\n\nimport * as z from 'zod';\n\nimport editorutil from '../../shared/util/editorutil.js';\n\nimport { logZodError } from '../utility/zodlogger.js';\nimport editorSavesManager from '../database/editorSavesManager.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\n\n// Zod Schemas -------------------------------------------------------------------------------\n\n/** Schema for validating the body of POST /api/editor-saves (save position) */\nconst SavePositionBodySchema = z.strictObject({\n\tname: z\n\t\t.string()\n\t\t.trim()\n\t\t.min(1, 'Name is required')\n\t\t.max(\n\t\t\teditorutil.MAX_POSITION_NAME_LENGTH,\n\t\t\t`Name must be ${editorutil.MAX_POSITION_NAME_LENGTH} characters or less`,\n\t\t),\n\tpiece_count: z\n\t\t.number()\n\t\t.int('Piece count must be an integer')\n\t\t.nonnegative('Piece count must be 0+'),\n\ttimestamp: z.number().int('Timestamp must be an integer').nonnegative('Timestamp must be 0+'),\n\ticn: z\n\t\t.string()\n\t\t.min(1, 'ICN is required')\n\t\t.max(\n\t\t\teditorutil.MAX_ICN_LENGTH,\n\t\t\t`ICN must be ${editorutil.MAX_ICN_LENGTH} characters or less`,\n\t\t),\n\tcompression: z.enum(['none', 'deflate-raw']),\n\t// undefined represents the indeterminate (third) state\n\tpawn_double_push: z.boolean().optional(),\n\tcastling: z.boolean().optional(),\n});\n\n/** Schema for validating position_name in URL params */\nconst PositionNameParamSchema = z.strictObject({\n\tposition_name: z\n\t\t.string()\n\t\t.trim()\n\t\t.min(1, 'Position name is required')\n\t\t.max(\n\t\t\teditorutil.MAX_POSITION_NAME_LENGTH,\n\t\t\t`Position name must be ${editorutil.MAX_POSITION_NAME_LENGTH} characters or less`,\n\t\t),\n});\n\n// API Endpoints -----------------------------------------------------------------------------\n\n/**\n * API endpoint to get all saved positions for the current user.\n * Returns { saves: EditorSavesListRecord[] } with position_id, name, and size.\n * Requires authentication.\n */\nfunction getSavedPositions(req: Request, res: Response): void {\n\tif (!req.memberInfo) {\n\t\tres.status(500).json({ error: 'Server error' }); // `memberInfo` should have been set by auth middleware, even if not signed in\n\t\treturn;\n\t}\n\n\t// Check if user is authenticated\n\tif (!req.memberInfo.signedIn) {\n\t\tres.status(401).json({ error: 'Must be signed in' });\n\t\treturn;\n\t}\n\n\tconst userId = req.memberInfo.user_id;\n\n\ttry {\n\t\t// Get all saved positions for this user\n\t\tconst saves = editorSavesManager.getAllSavedPositionsForUser(userId);\n\t\tres.json({ saves });\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error retrieving saved positions for user_id ${userId}: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tres.status(500).json({ error: 'Failed to retrieve saved positions' });\n\t}\n}\n\n/**\n * API endpoint to save a new position for the current user.\n * If a position with the same name already exists, it will be overwritten.\n * Expects { name: string, piece_count: number, timestamp: number, icn: string, pawn_double_push?: boolean, castling?: boolean } in request body.\n * Returns { success: true } on success.\n * Requires authentication.\n */\nfunction savePosition(req: Request, res: Response): void {\n\tif (!req.memberInfo) {\n\t\tres.status(500).json({ error: 'Server error' }); // memberInfo should have been set by auth middleware, even if not signed in\n\t\treturn;\n\t}\n\n\t// Check if user is authenticated\n\tif (!req.memberInfo.signedIn) {\n\t\tres.status(401).json({ error: 'Must be signed in' });\n\t\treturn;\n\t}\n\n\tconst userId = req.memberInfo.user_id;\n\n\t// Validate request body with Zod\n\tconst parseResult = SavePositionBodySchema.safeParse(req.body);\n\tif (!parseResult.success) {\n\t\tconst firstError = parseResult.error.issues[0];\n\t\tconst errorMessage = firstError?.message || 'Invalid request body';\n\t\tres.status(400).json({ error: errorMessage });\n\t\tlogZodError(req.body, parseResult.error, `Invalid save position request body.`);\n\t\treturn;\n\t}\n\n\tconst { name, piece_count, timestamp, icn, compression, pawn_double_push, castling } =\n\t\tparseResult.data;\n\n\ttry {\n\t\t// Add the saved position to the database (throws on quota exceeded)\n\t\teditorSavesManager.addSavedPosition(\n\t\t\tuserId,\n\t\t\tname,\n\t\t\tpiece_count,\n\t\t\ttimestamp,\n\t\t\ticn,\n\t\t\tcompression,\n\t\t\tpawn_double_push,\n\t\t\tcastling,\n\t\t);\n\n\t\tconst saves = editorSavesManager.getAllSavedPositionsForUser(userId);\n\t\tres.status(201).json({ success: true, saves });\n\t} catch (error: unknown) {\n\t\t// Handle the specific quota error\n\t\tif (error instanceof Error && error.message === editorSavesManager.QUOTA_EXCEEDED_ERROR) {\n\t\t\tres.status(403).json({ error: `Maximum saved positions exceeded` });\n\t\t\treturn;\n\t\t}\n\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(`Error saving position for user_id ${userId}: ${message}`, 'errLog.txt');\n\t\tres.status(500).json({ error: 'Failed to save position' });\n\t}\n}\n\n/**\n * API endpoint to get a specific saved position by position_name.\n * Returns { icn: string, pawn_double_push: number, castling: number } on success.\n * Requires authentication and ownership of the position.\n */\nfunction getPosition(req: Request, res: Response): void {\n\tif (!req.memberInfo) {\n\t\tres.status(500).json({ error: 'Server error' }); // memberInfo should have been set by auth middleware, even if not signed in\n\t\treturn;\n\t}\n\n\t// Check if user is authenticated\n\tif (!req.memberInfo.signedIn) {\n\t\tres.status(401).json({ error: 'Must be signed in' });\n\t\treturn;\n\t}\n\n\tconst userId = req.memberInfo.user_id;\n\n\t// Validate position_name from URL params with Zod\n\tconst parseResult = PositionNameParamSchema.safeParse(req.params);\n\tif (!parseResult.success) {\n\t\tres.status(400).json({ error: 'Invalid position_name' });\n\t\tlogZodError(req.params, parseResult.error, `Invalid get position request params.`);\n\t\treturn;\n\t}\n\n\tconst positionName = parseResult.data.position_name;\n\n\ttry {\n\t\t// Get the position from the database (filtered by user_id)\n\t\tconst position = editorSavesManager.getSavedPositionICN(positionName, userId);\n\n\t\tif (!position) {\n\t\t\tres.status(404).json({ error: 'Position not found' });\n\t\t\treturn;\n\t\t}\n\n\t\tres.json({\n\t\t\ttimestamp: position.timestamp,\n\t\t\ticn: position.icn,\n\t\t\tcompression: position.compression,\n\t\t\t// Decode tristate: -1 → undefined, 0 → false, 1 → true\n\t\t\tpawn_double_push:\n\t\t\t\tposition.pawn_double_push === -1 ? undefined : Boolean(position.pawn_double_push),\n\t\t\tcastling: position.castling === -1 ? undefined : Boolean(position.castling),\n\t\t});\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error retrieving position for name \"${positionName}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tres.status(500).json({ error: 'Failed to retrieve position' });\n\t}\n}\n\n/**\n * API endpoint to delete a specific saved position by position_name.\n * Returns { success: true } on success.\n * Requires authentication and ownership of the position.\n */\nfunction deletePosition(req: Request, res: Response): void {\n\tif (!req.memberInfo) {\n\t\tres.status(500).json({ error: 'Server error' }); // memberInfo should have been set by auth middleware, even if not signed in\n\t\treturn;\n\t}\n\n\t// Check if user is authenticated\n\tif (!req.memberInfo.signedIn) {\n\t\tres.status(401).json({ error: 'Must be signed in' });\n\t\treturn;\n\t}\n\n\tconst userId = req.memberInfo.user_id;\n\n\t// Validate position_name from URL params with Zod\n\tconst parseResult = PositionNameParamSchema.safeParse(req.params);\n\tif (!parseResult.success) {\n\t\tres.status(400).json({ error: 'Invalid position_name' });\n\t\tlogZodError(req.params, parseResult.error, `Invalid delete position request params.`);\n\t\treturn;\n\t}\n\n\tconst positionName = parseResult.data.position_name;\n\n\ttry {\n\t\t// Delete the position from the database (filtered by user_id)\n\t\tconst result = editorSavesManager.deleteSavedPosition(positionName, userId);\n\n\t\tif (result.changes === 0) {\n\t\t\tres.status(404).json({ error: 'Position not found' });\n\t\t\treturn;\n\t\t}\n\n\t\tconst saves = editorSavesManager.getAllSavedPositionsForUser(userId);\n\t\tres.json({ success: true, saves });\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error deleting position \"${positionName}\" for user_id ${userId}: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tres.status(500).json({ error: 'Failed to delete position' });\n\t}\n}\n\n// Exports -----------------------------------------------------------------------------------\n\nexport default {\n\t// Endpoints\n\tgetSavedPositions,\n\tsavePosition,\n\tgetPosition,\n\tdeletePosition,\n};\n"
  },
  {
    "path": "src/server/api/GitHub.ts",
    "content": "// src/server/api/GitHub.ts\n\n/*\n * This module, in the future, where be where we connect to GitHub's API\n * to dynamically refresh a list of github contributors on the webiste,\n * probably below our patron donors.\n *\n * INSTRUCTIONS:\n * In ANY github account (does not need to be a maintainer of the project),\n * create a classic access token with ZERO permissions (that is enough),\n * and paste it in the GITHUB_API_KEY field in the .env file.\n */\n\nimport fs from 'fs';\nimport path from 'path';\nimport * as z from 'zod';\nimport process from 'node:process';\nimport { writeFile } from 'node:fs/promises';\nimport AbortController from 'abort-controller';\nimport { fileURLToPath } from 'node:url';\nimport { request, RequestOptions } from 'node:https';\n\nimport { logZodError } from '../utility/zodlogger.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\n/** A GitHub contributor on the infinitechess.org repository. */\ninterface Contributor {\n\tname: string;\n\ticonUrl: string;\n\tlinkUrl: string;\n\tcontributionCount: number;\n}\n\n// Variables ---------------------------------------------------------------------------\n\nconst GitHubContributorSchema = z.array(\n\tz.object({\n\t\tlogin: z.string(),\n\t\tavatar_url: z.string(),\n\t\thtml_url: z.string(),\n\t\tcontributions: z.number(),\n\t}),\n);\n\nconst PATH_TO_CONTRIBUTORS_FILE = path.join(__dirname, '../../../database/contributors.json');\n\n/** A list of contributors on the infinitechess.org [repository](https://github.com/Infinite-Chess/infinitechess.org).\n * This should be periodically refreshed.\n * \n * example contributor:\n * ```js\n * {\n\tname: 'Naviary2',\n    iconUrl: 'https://avatars.githubusercontent.com/u/163621561?v=4',\n    linkUrl: 'https://github.com/Naviary2',\n    contributionCount: 1502\n  }\n  ```\n */\nlet contributors: Contributor[] = (() => {\n\tif (!fs.existsSync(PATH_TO_CONTRIBUTORS_FILE)) return [];\n\tconst file = fs.readFileSync(PATH_TO_CONTRIBUTORS_FILE).toString();\n\treturn JSON.parse(file);\n})();\n// console.log(contributors);\n\n/** The interval, in milliseconds, to use GitHub's API to refresh the contributor list. */\nconst intervalToRefreshContributorsMillis = 1000 * 60 * 60 * 3; // 3 hours\n// const intervalToRefreshContributorsMillis = 1000 * 5; // 5s for dev testing\n\n/** The id of the interval to update contributors. Can be used to cancel it if the API token isn't specified. */\nconst intervalId = setInterval(refreshGitHubContributorsList, intervalToRefreshContributorsMillis);\n// refreshGitHubContributorsList(); // Initial refreshal for dev testing\n\n// Functions ---------------------------------------------------------------------------\n\n/**\n * Uses GitHub's API to fetch all contributors on the infinitechess.org [repository](https://github.com/Infinite-Chess/infinitechess.org),\n * and updates our list!\n */\nfunction refreshGitHubContributorsList(): void {\n\tconst { GITHUB_API_KEY, GITHUB_REPO } = process.env;\n\n\tif (\n\t\tGITHUB_API_KEY === undefined ||\n\t\tGITHUB_REPO === undefined ||\n\t\tGITHUB_API_KEY.length === 0 ||\n\t\tGITHUB_REPO.length === 0\n\t) {\n\t\tlogEventsAndPrint(\n\t\t\t'Either Github API key not detected, or repository not specified. Stopping updating contributor list.',\n\t\t\t'errLog.txt',\n\t\t);\n\t\tclearInterval(intervalId);\n\t\treturn;\n\t}\n\n\t// Create an AbortController for the request\n\tconst controller = new AbortController();\n\tconst signal: AbortSignal = controller.signal as AbortSignal;\n\n\tconst options: RequestOptions = {\n\t\tmethod: 'GET',\n\t\thostname: 'api.github.com',\n\t\t// \"port\": null,\n\t\tpath: `/repos/${GITHUB_REPO}/contributors`,\n\t\theaders: {\n\t\t\tAccept: 'application/vnd.github+json',\n\t\t\tAuthorization: `Bearer ${GITHUB_API_KEY}`,\n\t\t\t'X-GitHub-Api-Version': '2022-11-28',\n\t\t\t'User-Agent': process.env['APP_BASE_URL'],\n\t\t\t// \"Content-Length\": \"0\"\n\t\t},\n\t\tsignal, // Pass the signal to the request options\n\t};\n\n\tconst req = request(options, function (res) {\n\t\t// The type of this is Uint8Array because Buffer.concat() expects it.\n\t\tconst chunks: Uint8Array[] = [];\n\n\t\tres.on('data', (chunk) => chunks.push(chunk));\n\t\tres.on('end', async () => {\n\t\t\tconst body = Buffer.concat(chunks);\n\t\t\tif (res.statusCode !== 200)\n\t\t\t\treturn logEventsAndPrint(\n\t\t\t\t\t`Response from GitHub when using API to get contributor list: ${body.toString()}`,\n\t\t\t\t\t'errLog.txt',\n\t\t\t\t);\n\n\t\t\tconst response = body.toString();\n\t\t\tlet unvalidatedJson: any;\n\t\t\ttry {\n\t\t\t\tunvalidatedJson = JSON.parse(response);\n\t\t\t} catch (error: unknown) {\n\t\t\t\tconst errMsg = error instanceof Error ? error.message : String(error);\n\t\t\t\tlogEventsAndPrint('Error parsing contributors JSON: ' + errMsg, 'errLog.txt');\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst zod_result = GitHubContributorSchema.safeParse(unvalidatedJson);\n\t\t\tif (!zod_result.success) {\n\t\t\t\tlogZodError(\n\t\t\t\t\tunvalidatedJson,\n\t\t\t\t\tzod_result.error,\n\t\t\t\t\t'Invalid GitHub API response for contributors.',\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst currentContributors: Contributor[] = zod_result.data.map((c) => ({\n\t\t\t\tname: c.login,\n\t\t\t\ticonUrl: c.avatar_url,\n\t\t\t\tlinkUrl: c.html_url,\n\t\t\t\tcontributionCount: c.contributions,\n\t\t\t}));\n\n\t\t\tcontributors = currentContributors;\n\t\t\tawait writeFile(PATH_TO_CONTRIBUTORS_FILE, JSON.stringify(contributors, null, 2));\n\t\t\t// console.log('Contributors updated!');\n\t\t});\n\t});\n\n\t// Handle request errors\n\treq.on('error', (err) => {\n\t\tif (err.name === 'AbortError') {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t'GitHub contributor request was aborted due to timeout.',\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t} else {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`Request error while fetching GitHub contributors: ${err.message}`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t}\n\t});\n\n\t// Add a timeout using AbortController if request takes too long\n\tconst abortTimeout = setTimeout(() => {\n\t\tcontroller.abort();\n\t\tlogEventsAndPrint('GitHub API request timed out.', 'errLog.txt');\n\t}, 10000);\n\n\treq.on('response', () => {\n\t\tclearTimeout(abortTimeout); // Clear timeout once the request gets a response\n\t});\n\n\treq.end();\n}\n\n/**\n * Returns a list of contributors on the infinitechess.org [repository](https://github.com/Infinite-Chess/infinitechess.org),\n * updated every {@link intervalToRefreshContributorsMillis}.\n */\nfunction getContributors(): Contributor[] {\n\treturn contributors;\n}\n\nexport { refreshGitHubContributorsList, getContributors };\n"
  },
  {
    "path": "src/server/api/LeaderboardAPI.ts",
    "content": "// src/server/api/LeaderboardAPI.ts\n\n/**\n * Route\n * Fetched by leaderboard script.\n * Sends the client the information about the leaderboard they are currently profile viewing.\n */\n\nimport type { Request, Response } from 'express';\n\nimport { Leaderboard } from '../../shared/chess/variants/validleaderboard.js';\n\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\nimport { getMemberDataByCriteria } from '../database/memberManager.js';\nimport {\n\tgetTopPlayersForLeaderboard,\n\tgetPlayerRankInLeaderboard,\n\tgetEloOfPlayerInLeaderboard,\n} from '../database/leaderboardsManager.js';\n\n/** Maximum number of players allowed to be requested in a single request. */\nconst MAX_N_PLAYERS_REQUEST_CAP = 100;\n\n// Functions -------------------------------------------------------------\n\n/**\n * Responds to the request to fetch top (N = n_players) players of leaderboard\n * leaderboard_id, starting from start_rank, and also find rank of requester if find_requester_rank === 1\n */\nconst getLeaderboardData = async (req: Request, res: Response): Promise<void> => {\n\t// route: /leaderboard/top/:leaderboard_id/:start_rank/:n_players/:find_requester_rank\n\n\t/** ID of leaderboard to be fetched */\n\tconst leaderboard_id = Number(req.params['leaderboard_id']) as Leaderboard;\n\n\t/** Highest rank of player to fetch from leaderboard */\n\tconst start_rank = Number(req.params['start_rank']);\n\n\t/** Number of players to fetch from leaderboard */\n\tconst n_players = Number(req.params['n_players']);\n\n\t/** Whether the server should also look for and return the rank of the user making the request */\n\tconst find_requester_rank = Number(req.params['find_requester_rank']) as 0 | 1;\n\n\tif (\n\t\tNumber.isNaN(start_rank) ||\n\t\tNumber.isNaN(n_players) ||\n\t\tNumber.isNaN(leaderboard_id) ||\n\t\tNumber.isNaN(find_requester_rank)\n\t) {\n\t\tres.status(404).json({ message: 'Request incorrectly formatted.' });\n\t\treturn;\n\t}\n\tif (n_players > MAX_N_PLAYERS_REQUEST_CAP) {\n\t\tres.status(404).json({ message: 'Too many leaderboard positions requested at once.' });\n\t\treturn;\n\t}\n\n\t/** Username of user whose global ranking should be returned. Set to undefined if its global rank should not be found. */\n\tconst requester_username =\n\t\tfind_requester_rank && req.memberInfo?.signedIn ? req.memberInfo.username : undefined;\n\n\t// Query leaderboard database\n\tconst top_players = getTopPlayersForLeaderboard(leaderboard_id, start_rank, n_players);\n\tif (top_players === undefined) {\n\t\tlogEventsAndPrint(\n\t\t\t`Retrieval of top ${n_players} players from start rank ${start_rank} of leaderboard ${leaderboard_id} upon user request failed.`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tres.status(500).json({ message: 'Server error.' }); // Generic message for database retrieval failed\n\t\treturn;\n\t}\n\n\t// Populate leaderboardData object with usernames and elos of players\n\t// Also look out for requester_username among usernames in order to set the value of requester_rank if possible\n\tlet requester_rank: number | undefined = undefined;\n\tlet running_rank = start_rank;\n\tconst leaderboardData: Object[] = [];\n\tfor (const player of top_players) {\n\t\tconst record = getMemberDataByCriteria(['username'], 'user_id', player.user_id!);\n\t\tif (record === undefined) {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`Username of user with user_id ${player.user_id} could not be found in members table, even though it was found in leaderboard table by getTopPlayersForLeaderboard().`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t\tcontinue;\n\t\t}\n\t\tconst playerData = {\n\t\t\tusername: record.username,\n\t\t\telo: String(Math.round(player.elo!)),\n\t\t};\n\t\tleaderboardData.push(playerData);\n\t\tif (record.username === requester_username) requester_rank = running_rank; // We can now set requester_rank without a seperate query\n\t\trunning_rank++;\n\t}\n\n\t// Construct rank_string of user\n\t// If there is a requester_username, but requester_rank is still undefined, we need another database query\n\tlet rank_string: string | undefined = undefined;\n\trank_string_constructor: if (requester_username !== undefined && requester_rank === undefined) {\n\t\tconst requesterRecord = getMemberDataByCriteria(\n\t\t\t['user_id'],\n\t\t\t'username',\n\t\t\trequester_username,\n\t\t);\n\t\tif (requesterRecord === undefined) break rank_string_constructor;\n\n\t\tconst requester_rank = getPlayerRankInLeaderboard(requesterRecord.user_id, leaderboard_id);\n\t\tif (requester_rank !== undefined) {\n\t\t\trank_string = `#${requester_rank}`;\n\n\t\t\t// If the display elo contains a ?, then the rank_string should also contain a ?\n\t\t\tconst requester_elo = getEloOfPlayerInLeaderboard(\n\t\t\t\trequesterRecord.user_id,\n\t\t\t\tleaderboard_id,\n\t\t\t); // { value: number, confident: boolean }\n\t\t\tif (!requester_elo.confident) rank_string += '?';\n\t\t} else rank_string = '?';\n\t} else if (requester_username !== undefined) rank_string = `#${requester_rank}`; // case where the requester_username was already contained in the top leaderboard ranks\n\n\tconst requesterData = {\n\t\trank_string: rank_string,\n\t};\n\n\tconst sendData = {\n\t\tleaderboardData: leaderboardData,\n\t\trequesterData: requesterData,\n\t};\n\n\t// Return data\n\tres.json(sendData);\n};\n\nexport { getLeaderboardData };\n"
  },
  {
    "path": "src/server/api/MemberAPI.ts",
    "content": "// src/server/api/MemberAPI.ts\n\nimport type { Request, Response } from 'express';\n\nimport { format, formatDistance } from 'date-fns';\n\nimport timeutil from '../../shared/util/timeutil.js';\nimport metadatautil from '../../shared/chess/util/metadatautil.js';\nimport { Leaderboards } from '../../shared/chess/variants/validleaderboard.js';\n\nimport { localeMap } from '../config/dateLocales.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\nimport { getLanguageToServe } from '../utility/translate.js';\nimport { getMemberDataByCriteria, updateMemberColumns } from '../database/memberManager.js';\nimport {\n\tgetPlayerLeaderboardRating,\n\tgetEloOfPlayerInLeaderboard,\n\tgetPlayerRankInLeaderboard,\n} from '../database/leaderboardsManager.js';\n\n// Define the structure of the JSON response body\ninterface MemberResponse {\n\tuser_id: number;\n\tusername: string;\n\tjoined: string;\n\tseen: string;\n\tcheckmates_beaten: string;\n\tranked_elo: string;\n\tinfinity_leaderboard_position: number | undefined;\n\tinfinity_leaderboard_rating_deviation: number | undefined;\n\temail?: string;\n\tverified?: boolean;\n\tverified_notified?: boolean;\n}\n\n/**\n * API route: /member/:member/data\n * This is fetched from the profile page,\n * and serves info about the requested member.\n *\n * SHOULD ONLY ever return a JSON.\n */\nconst getMemberData = async (req: Request, res: Response): Promise<Response> => {\n\t// What member are we getting data from?\n\tconst claimedUsername = req.params['member'];\n\tif (!claimedUsername) {\n\t\tlogEventsAndPrint('No member username provided to MemberAPI.getMemberData', 'errLog.txt');\n\t\treturn res.status(400).json({ message: 'No member username provided' });\n\t}\n\n\tconst record = getMemberDataByCriteria(\n\t\t[\n\t\t\t'user_id',\n\t\t\t'username',\n\t\t\t'email',\n\t\t\t'joined',\n\t\t\t'is_verified',\n\t\t\t'is_verification_notified',\n\t\t\t'last_seen',\n\t\t\t'checkmates_beaten',\n\t\t],\n\t\t'username',\n\t\tclaimedUsername,\n\t);\n\n\tif (record === undefined) return res.status(404).json({ message: 'Member not found' });\n\n\t// Get the player's display elo string from the INFINITY leaderboard\n\tconst ranked_elo = getEloOfPlayerInLeaderboard(record.user_id, Leaderboards.INFINITY); // { value: number, confident: boolean }\n\n\t// Get the player's position from the INFINITY leaderboard\n\tconst infinity_leaderboard_position = getPlayerRankInLeaderboard(\n\t\trecord.user_id,\n\t\tLeaderboards.INFINITY,\n\t);\n\n\t// Get the player's RD from the INFINITY leaderboard\n\tlet infinity_leaderboard_rating_deviation = getPlayerLeaderboardRating(\n\t\trecord.user_id,\n\t\tLeaderboards.INFINITY,\n\t)?.rating_deviation;\n\tif (infinity_leaderboard_rating_deviation !== undefined) {\n\t\tinfinity_leaderboard_rating_deviation = Math.round(infinity_leaderboard_rating_deviation);\n\t}\n\n\t// Load their data\n\tconst joinedPhrase = format(new Date(record.joined), 'PP');\n\n\tconst lastSeenDate = new Date(timeutil.sqliteToISO(record.last_seen));\n\tconst language = getLanguageToServe(req);\n\t// Use type assertion here since we check for localeStr's existence in locales\n\tconst seenPhrase = formatDistance(lastSeenDate, new Date(), {\n\t\tlocale: localeMap[language],\n\t\taddSuffix: true,\n\t});\n\n\tconst sendData: MemberResponse = {\n\t\tuser_id: record.user_id,\n\t\tusername: record.username,\n\t\tjoined: joinedPhrase,\n\t\tseen: seenPhrase,\n\t\tcheckmates_beaten: record.checkmates_beaten,\n\t\tranked_elo: metadatautil.getFormattedElo(ranked_elo),\n\t\tinfinity_leaderboard_position,\n\t\tinfinity_leaderboard_rating_deviation,\n\t};\n\n\t// If they are the same person as who their requesting data, also include these.\n\tif (req.memberInfo === undefined) {\n\t\tlogEventsAndPrint(\n\t\t\t'req.memberInfo must be defined when requesting member data from API!',\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn res.status(500).send('Internal Server Error');\n\t}\n\n\tif (\n\t\treq.memberInfo.signedIn &&\n\t\treq.memberInfo.username.toLowerCase() === claimedUsername.toLowerCase()\n\t) {\n\t\t// Their page\n\t\tsendData.email = record.email; // This is their account, include their email with the response\n\n\t\tsendData.verified = record.is_verified === 1;\n\t\tsendData.verified_notified = record.is_verification_notified === 1;\n\n\t\t// If they are verified but haven't been notified yet, this is the moment to do so.\n\t\tif (record.is_verified === 1 && record.is_verification_notified === 0) {\n\t\t\t// console.log(`Thanking member ${record.username} for verifying their account!`);\n\t\t\ttry {\n\t\t\t\t// Mark them as notified in the database.\n\t\t\t\tupdateMemberColumns(record.user_id, { is_verification_notified: 1 });\n\t\t\t} catch (error: unknown) {\n\t\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\t\tlogEventsAndPrint(\n\t\t\t\t\t`Failed to update member of ID \"${record.user_id}\" verification notified status: ${message}`,\n\t\t\t\t\t'errLog.txt',\n\t\t\t\t);\n\t\t\t}\n\t\t} else if (record.is_verified === 0) {\n\t\t\t// console.log(`Requesting member ${record.username} to verify their account!`);\n\t\t}\n\t}\n\n\t// Return data\n\treturn res.json(sendData);\n};\n\nexport { getMemberData };\n"
  },
  {
    "path": "src/server/api/NewsAPI.ts",
    "content": "// src/server/api/NewsAPI.ts\n\n/**\n * API endpoints for news-related functionality.\n */\n\nimport type { Request, Response } from 'express';\n\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\nimport { getMemberDataByCriteria, updateMemberColumns } from '../database/memberManager.js';\nimport { countUnreadNews, getLatestNewsDate, getUnreadNewsDates } from '../utility/newsUtil.js';\n\n/**\n * API endpoint to get the count of unread news posts for the current user.\n * Returns { count: number } or { count: 0 } if not logged in.\n */\nfunction getUnreadNewsCount(req: Request, res: Response): void {\n\t// Check if user is authenticated\n\tif (!req.memberInfo?.signedIn) {\n\t\t// Not logged in - return 0 unread\n\t\tres.json({ count: 0 });\n\t\treturn;\n\t}\n\n\tconst userId = req.memberInfo.user_id;\n\n\t// Get user's last read news date\n\tconst record = getMemberDataByCriteria(['last_read_news_date'], 'user_id', userId);\n\n\tif (!record?.last_read_news_date) {\n\t\t// For some reason the cell was null or record not found\n\t\tres.json({ count: 0 });\n\t\treturn;\n\t}\n\n\t// Count unread news posts\n\tconst unreadCount = countUnreadNews(record.last_read_news_date);\n\n\tres.json({ count: unreadCount });\n}\n\n/**\n * Gets the list of unread news dates for the current user.\n * Returns { dates: string[] } with dates in YYYY-MM-DD format.\n */\nfunction getUnreadNewsDatesEndpoint(req: Request, res: Response): void {\n\tif (!req.memberInfo?.signedIn) {\n\t\t// Not logged in - no unread news\n\t\tres.json({ dates: [] });\n\t\treturn;\n\t}\n\n\tconst userId = req.memberInfo.user_id;\n\n\t// Get user's last read news date\n\tconst record = getMemberDataByCriteria(['last_read_news_date'], 'user_id', userId);\n\n\tif (!record?.last_read_news_date) {\n\t\t// For some reason the cell was null or undefined\n\t\tres.json({ dates: [] });\n\t\treturn;\n\t}\n\n\t// Get unread news dates\n\tconst unreadDates = getUnreadNewsDates(record.last_read_news_date);\n\n\tres.json({ dates: unreadDates });\n}\n\n/**\n * Updates the user's last read news date to the current latest news post.\n * This should be called when the user visits the news page.\n */\nfunction markNewsAsRead(req: Request, res: Response): void {\n\tif (!req.memberInfo || !req.memberInfo.signedIn) {\n\t\t// Not logged in - nothing to update\n\t\tres.status(200).json({ success: true });\n\t\treturn;\n\t}\n\n\tconst userId = req.memberInfo.user_id;\n\n\tconst latestNewsDate = getLatestNewsDate();\n\n\ttry {\n\t\tconst result = updateMemberColumns(userId, { last_read_news_date: latestNewsDate });\n\n\t\tif (result.changeMade) {\n\t\t\tres.status(200).json({ success: true });\n\t\t} else {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`Failed to update last read news date for member of ID \"${userId}\". No changes made. Do they exist?`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t\tres.status(500).json({\n\t\t\t\tsuccess: false,\n\t\t\t\tmessage: 'Failed to update last read news date.',\n\t\t\t});\n\t\t}\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error updating last read news date for member of ID \"${userId}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tres.status(500).json({\n\t\t\tsuccess: false,\n\t\t\tmessage: `Server error updating last read news date`,\n\t\t});\n\t}\n}\n\nexport { getUnreadNewsCount, getUnreadNewsDatesEndpoint, markNewsAsRead };\n"
  },
  {
    "path": "src/server/api/PracticeProgress.int.test.ts",
    "content": "// src/server/api/PracticeProgress.int.test.ts\n\nimport { describe, it, expect, beforeEach, beforeAll } from 'vitest';\n\nimport validcheckmates from '../../shared/chess/util/validcheckmates.js';\n\nimport { testRequest } from '../../tests/testRequest.js';\nimport integrationUtils from '../../tests/integrationUtils.js';\n\nimport { getMemberDataByCriteria } from '../database/memberManager.js';\nimport { generateTables, clearAllTables } from '../database/databaseTables.js';\n\n// We'll use the first easy checkmate as our valid test case\nconst VALID_CHECKMATE_ID = validcheckmates.validCheckmates.easy[0];\n\nif (!VALID_CHECKMATE_ID) throw new Error('No valid checkmate IDs found for testing!');\n\ndescribe('Practice Progress Integration', () => {\n\t// Runs once at the very start of this file\n\tbeforeAll(() => {\n\t\tgenerateTables();\n\t});\n\n\t// Runs before EVERY single 'it' block\n\tbeforeEach(() => {\n\t\tclearAllTables();\n\t});\n\n\tit('should reject requests with no body', async () => {\n\t\tconst cookie = (await integrationUtils.createAndLoginUser()).cookie;\n\n\t\tconst response = await testRequest()\n\t\t\t.post('/api/update-checkmatelist')\n\t\t\t.set('Cookie', cookie);\n\n\t\texpect(response.status).toBe(400);\n\t});\n\n\tit('should reject requests with missing new_checkmate_beaten', async () => {\n\t\tconst cookie = (await integrationUtils.createAndLoginUser()).cookie;\n\n\t\tconst response = await testRequest()\n\t\t\t.post('/api/update-checkmatelist')\n\t\t\t.set('Cookie', cookie)\n\t\t\t.send({}); // No new_checkmate_beaten\n\n\t\texpect(response.status).toBe(400);\n\t});\n\n\tit('should reject requests with non-string new_checkmate_beaten', async () => {\n\t\tconst cookie = await integrationUtils.createAndLoginUser();\n\n\t\tconst response = await testRequest()\n\t\t\t.post('/api/update-checkmatelist')\n\t\t\t.set('Cookie', cookie.cookie)\n\t\t\t.send({ new_checkmate_beaten: 12345 }); // Non-string\n\n\t\texpect(response.status).toBe(400);\n\t});\n\n\tit('should reject requests from unauthenticated users', async () => {\n\t\tconst response = await testRequest()\n\t\t\t.post('/api/update-checkmatelist')\n\t\t\t.send({ new_checkmate_beaten: VALID_CHECKMATE_ID });\n\n\t\texpect(response.status).toBe(401);\n\t});\n\n\tit('should reject invalid checkmate IDs', async () => {\n\t\tconst cookie = (await integrationUtils.createAndLoginUser()).cookie;\n\n\t\tconst response = await testRequest()\n\t\t\t.post('/api/update-checkmatelist')\n\t\t\t.set('Cookie', cookie)\n\t\t\t.send({ new_checkmate_beaten: 'INVALID-ID-123' });\n\n\t\texpect(response.status).toBe(400);\n\t\t// expect(response.body.message).toBe('Invalid checkmate ID');\n\t});\n\n\tit('should allow a logged-in user to save a new checkmate', async () => {\n\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\tconst response = await testRequest()\n\t\t\t.post('/api/update-checkmatelist')\n\t\t\t.set('Cookie', user.cookie)\n\t\t\t.send({ new_checkmate_beaten: VALID_CHECKMATE_ID });\n\t\texpect(response.status).toBe(200);\n\n\t\t// Check DB Side Effect\n\t\tconst record = getMemberDataByCriteria(['checkmates_beaten'], 'username', user.username);\n\t\texpect(record?.checkmates_beaten).toBe(VALID_CHECKMATE_ID);\n\n\t\t// Verify the response set the updated cookie\n\t\tconst newCookies = response.headers['set-cookie'] as unknown as string[]; // set-cookie is actually an array\n\t\texpect(\n\t\t\tnewCookies.some((c) =>\n\t\t\t\tc.startsWith(`checkmates_beaten=${encodeURIComponent(VALID_CHECKMATE_ID)}`),\n\t\t\t),\n\t\t).toBe(true);\n\t});\n\n\tit('should correctly store multiple checkmates', async () => {\n\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\tconst secondCheckmateId = validcheckmates.validCheckmates.easy[1];\n\t\tif (!secondCheckmateId) throw new Error('Not enough valid checkmate IDs for this test!');\n\n\t\t// 1. Submit First Checkmate\n\t\tawait testRequest()\n\t\t\t.post('/api/update-checkmatelist')\n\t\t\t.set('Cookie', user.cookie)\n\t\t\t.send({ new_checkmate_beaten: VALID_CHECKMATE_ID });\n\n\t\t// 2. Submit Second Checkmate\n\t\tconst response = await testRequest()\n\t\t\t.post('/api/update-checkmatelist')\n\t\t\t.set('Cookie', user.cookie)\n\t\t\t.send({ new_checkmate_beaten: secondCheckmateId });\n\n\t\texpect(response.status).toBe(200);\n\n\t\t// DB should have both IDs stored correctly\n\t\tconst record = getMemberDataByCriteria(['checkmates_beaten'], 'username', user.username);\n\t\texpect(record?.checkmates_beaten).toBe([VALID_CHECKMATE_ID, secondCheckmateId].join(','));\n\t});\n\n\tit('should handle duplicate checkmate submissions gracefully', async () => {\n\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t// 1. Submit First Time\n\t\tawait testRequest()\n\t\t\t.post('/api/update-checkmatelist')\n\t\t\t.set('Cookie', user.cookie)\n\t\t\t.send({ new_checkmate_beaten: VALID_CHECKMATE_ID });\n\n\t\t// 2. Submit Same ID Again\n\t\tconst response = await testRequest()\n\t\t\t.post('/api/update-checkmatelist')\n\t\t\t.set('Cookie', user.cookie)\n\t\t\t.send({ new_checkmate_beaten: VALID_CHECKMATE_ID });\n\n\t\t// Should now be 204 No Content, indicating no change in state\n\t\texpect(response.status).toBe(204);\n\n\t\t// DB should still only have it once (no duplicates like \"ID,ID\")\n\t\tconst record = getMemberDataByCriteria(['checkmates_beaten'], 'username', user.username);\n\t\texpect(record?.checkmates_beaten).toBe(VALID_CHECKMATE_ID);\n\t});\n});\n"
  },
  {
    "path": "src/server/api/PracticeProgress.ts",
    "content": "// src/server/api/PracticeProgress.ts\n\n/**\n * This script updates the checkmates_beaten list in the database when a user submits a newly completed checkmate\n */\n\nimport type { Request, Response } from 'express';\nimport type { ParsedCookies } from '../types.js';\n\nimport jsutil from '../../shared/util/jsutil.js';\nimport validcheckmates from '../../shared/chess/util/validcheckmates.js';\n\nimport { logEvents, logEventsAndPrint } from '../middleware/logEvents.js';\nimport { getMemberDataByCriteria, updateMemberColumns } from '../database/memberManager.js';\n\n// Functions -------------------------------------------------------------\n\n/**\n * Middleware to set the checkmates_beaten cookie for logged-in users based on their memberInfo cookie.\n * Only sets the checkmates_beaten cookie on HTML requests (requests without an origin header).\n *\n * It is possible for the memberInfo cookie to be tampered with, but checkmates_beaten can be public information anyway.\n * We are reading the memberInfo cookie instead of verifying their session token\n * because that could take a little bit longer as it requires a database look up.\n * @param req - The Express request object.\n * @param res - The Express response object.\n * @param next - The Express next middleware function.\n */\nfunction setPracticeProgressCookie(req: Request, res: Response, next: Function): void {\n\t// We don't have to worry about the request being for a resource because those have already been served.\n\t// The only scenario this request could be for now is an HTML or fetch API request\n\t// The 'is-fetch-request' header is a custom header we add on all fetch requests to let us know is is a fetch request.\n\tif (req.headers['is-fetch-request'] === 'true' || !req.accepts('html')) return next(); // Not an HTML request (but a fetch), don't set the cookie\n\n\t// We give everyone this cookie as soon as they login.\n\t// Since it is modifiable by JavaScript it's possible for them to\n\t// grab checkmates_beaten of other users this way, but there's no harm in that.\n\tconst cookies: ParsedCookies = req.cookies;\n\tconst memberInfoCookieStringified = cookies.memberInfo;\n\tif (memberInfoCookieStringified === undefined) return next(); // No cookie is present, not logged in\n\n\tlet memberInfoCookie: { user_id: number; username: string };\n\ttry {\n\t\tmemberInfoCookie = JSON.parse(memberInfoCookieStringified);\n\t} catch (error) {\n\t\tlogEventsAndPrint(\n\t\t\t`memberInfo cookie was not JSON parse-able when attempting to set checkmates_beaten cookie. Maybe it was tampered? The cookie: \"${jsutil.ensureJSONString(memberInfoCookieStringified)}\" The error: ${(error as Error).stack}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn next(); // Don't set the checkmates_beaten cookie, but allow their request to continue as normal\n\t}\n\n\tif (typeof memberInfoCookie !== 'object') {\n\t\tlogEventsAndPrint(\n\t\t\t`memberInfo cookie did not parse into an object when attempting to set checkmates_beaten cookie. Maybe it was tampered? The cookie: \"${jsutil.ensureJSONString(memberInfoCookieStringified)}\"`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn next(); // Don't set the checkmates_beaten cookie, but allow their request to continue as normal\n\t}\n\n\tconst user_id = memberInfoCookie.user_id;\n\tif (typeof user_id !== 'number') {\n\t\tlogEventsAndPrint(\n\t\t\t`memberInfo cookie user_id property was not a number when attempting to set checkmates_beaten cookie. Maybe it was tampered? The cookie: \"${jsutil.ensureJSONString(memberInfoCookieStringified)}\"`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn next(); // Don't set the checkmates_beaten cookie, but allow their request to continue as normal\n\t}\n\n\tconst checkmates_beaten = getCheckmatesBeaten(user_id); // Fetch their checkmates_beaten from the database\n\n\tcreatePracticeProgressCookie(res, checkmates_beaten);\n\n\t// console.log(`Set checkmates_beaten cookie for member \"${ensureJSONString(memberInfoCookie.username)}\" for url: ` + req.url);\n\n\tnext();\n}\n\n/**\n * Sets the checkmates_beaten cookie for the user.\n * @param res - The Express response object.\n * @param checkmates_beaten - The checkmates_beaten object to be saved in the cookie.\n */\nfunction createPracticeProgressCookie(res: Response, checkmates_beaten: string): void {\n\t// Set or update the checkmates_beaten cookie\n\tres.cookie('checkmates_beaten', checkmates_beaten, {\n\t\thttpOnly: false,\n\t\tsecure: true,\n\t});\n}\n\n/**\n * Deletes the checkmates_beaten progress cookie for the user.\n * Typically called when they log out.\n * Even though the cookie only lasts 10 seconds, this is still helpful\n * @param {Object} res - The Express response object.\n */\nfunction deletePracticeProgressCookie(res: Response): void {\n\tres.clearCookie('checkmates_beaten', {\n\t\thttpOnly: false,\n\t\tsecure: true,\n\t});\n}\n\n/**\n * Fetches the checkmates_beaten for a given user from the database, as a delimited string.\n * @param userId - The ID of the user whose checkmates_beaten are to be fetched.\n * @returns - Returns the checkmates_beaten string if found, otherwise undefined. (e.g. \"2Q-1k,3R-1k,1Q1R1B-1k\")\n */\nfunction getCheckmatesBeaten(userId: number): string {\n\tconst record = getMemberDataByCriteria(['checkmates_beaten'], 'user_id', userId);\n\treturn record?.checkmates_beaten ?? '';\n}\n\n/**\n * Converts a string of checkmates_beaten delimited by commas into an array of strings.\n */\nfunction checkmatesBeatenToStringArray(checkmates_beaten: string): string[] {\n\treturn checkmates_beaten.match(/[^,]+/g) || []; // match() returns null if no matches\n}\n\n/**\n * Route that Handles a POST request to update user checkmates_beaten in the database.\n * @param req - Express request object\n * @param res - Express response object\n */\nfunction postCheckmateBeaten(req: Request, res: Response): void {\n\tif (!req.memberInfo?.signedIn) {\n\t\tlogEventsAndPrint(\n\t\t\t\"User tried to save checkmates_beaten when they weren't signed in!\",\n\t\t\t'errLog.txt',\n\t\t);\n\t\tres.status(401).json({ message: \"Can't save checkmates_beaten, not signed in.\" });\n\t\treturn;\n\t}\n\n\tconst { user_id, username } = req.memberInfo;\n\tconst new_checkmate_beaten: string = req.body.new_checkmate_beaten;\n\n\t// Validate the new checkmate ID\n\tif (typeof new_checkmate_beaten !== 'string') {\n\t\t// Not a string\n\t\tres.status(400).json({ message: 'Invalid checkmate ID' });\n\t\treturn;\n\t}\n\tif (!Object.values(validcheckmates.validCheckmates).flat().includes(new_checkmate_beaten)) {\n\t\t// Not a valid checkmate\n\t\tres.status(400).json({ message: 'Invalid checkmate ID' });\n\t\treturn;\n\t}\n\n\t// Checkmate is valid...\n\n\tlet checkmates_beaten: string = getCheckmatesBeaten(user_id);\n\tconst checkmates_beaten_array: string[] = checkmatesBeatenToStringArray(checkmates_beaten);\n\n\tif (checkmates_beaten_array.includes(new_checkmate_beaten)) {\n\t\t// Already beaten\n\t\tres.status(204).json({ message: 'Checkmate already beaten' });\n\t\treturn;\n\t}\n\n\t// Checkmate not already beaten (until now)...\n\n\t// Update the new list\n\tcheckmates_beaten_array.push(new_checkmate_beaten);\n\tcheckmates_beaten = checkmates_beaten_array.join(',');\n\n\ttry {\n\t\t// Save the new list to the database\n\t\tconst result = updateMemberColumns(user_id, { checkmates_beaten });\n\n\t\t// Send appropriate response\n\t\tif (result.changeMade) {\n\t\t\tlogEvents(\n\t\t\t\t`Member \"${username}\" of id \"${user_id}\" has beaten practice checkmate ${new_checkmate_beaten}. Beaten count: ${checkmates_beaten_array.length}. New checkmates_beaten: ${checkmates_beaten}`,\n\t\t\t\t'checkmates_beaten.txt',\n\t\t\t);\n\t\t\t// Create a new cookie with the updated checkmate list for the user\n\t\t\tcreatePracticeProgressCookie(res, checkmates_beaten);\n\t\t\tres.status(200).json({ message: 'Checkmate recorded successfully' });\n\t\t} else {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`Failed to save new practice checkmate for member \"${username}\" id \"${user_id}\". No change made. Do they exist?`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t\tres.status(500).json({ message: 'Failed to update practice checkmate' });\n\t\t}\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error updating practice checkmate for member \"${username}\" of ID \"${user_id}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tres.status(500).json({ message: 'Server error updating practice checkmate' });\n\t}\n}\n\nexport { setPracticeProgressCookie, deletePracticeProgressCookie, postCheckmateBeaten };\n"
  },
  {
    "path": "src/server/api/Prefs.int.test.ts",
    "content": "// src/server/api/Prefs.int.test.ts\n\nimport { describe, it, expect, beforeEach, beforeAll } from 'vitest';\n\nimport { testRequest } from '../../tests/testRequest.js';\nimport integrationUtils from '../../tests/integrationUtils.js';\n\nimport { getMemberDataByCriteria } from '../database/memberManager.js';\nimport { generateTables, clearAllTables } from '../database/databaseTables.js';\n\n/** An example of valid preferences. */\nconst VALID_PREFS_1 = {\n\ttheme: 'wood_light',\n\tlegal_moves: 'dots',\n\tanimations: false,\n\tlingering_annotations: true,\n} as const;\n\n/** Another example of valid preferences. */\nconst VALID_PREFS_2 = {\n\ttheme: 'sandstone',\n\tlegal_moves: 'squares',\n\tanimations: true,\n\tlingering_annotations: false,\n} as const;\n\ndescribe('Preferences Integration', () => {\n\t// Runs once at the very start of this file\n\tbeforeAll(() => {\n\t\tgenerateTables();\n\t});\n\n\t// Runs before EVERY single 'it' block\n\tbeforeEach(() => {\n\t\tclearAllTables();\n\t});\n\n\tit('should verify middleware sets preferences cookie on GET request', async () => {\n\t\tconst cookie = (await integrationUtils.createAndLoginUser()).cookie;\n\n\t\t// 1. Manually set prefs in DB first (so we have something to fetch)\n\t\t// Since we can't easily inject into DB without the API, we'll use the API first\n\t\tawait testRequest()\n\t\t\t.post('/api/set-preferences')\n\t\t\t.set('Cookie', cookie)\n\t\t\t.send({ preferences: VALID_PREFS_1 });\n\n\t\t// 2. Now test the GET request (HTML request)\n\t\tconst response = await testRequest()\n\t\t\t.get('/') // Hitting the homepage (or any HTML route)\n\t\t\t.set('Cookie', cookie);\n\t\t// .set('Accept', 'text/html');\n\n\t\t// CAN'T KEEP THIS, because if `dist/` is not built, it will 404. Tests should NOT depend on the build process.\n\t\t// Luckily, the cookie is still set before then.\n\t\t// expect(response.status).toBe(200);\n\n\t\tconst cookies = response.headers['set-cookie'] as unknown as string[]; // set-cookie is actually an array\n\t\t// Verify 'preferences' cookie is set and matches what we saved\n\t\tconst prefCookie = cookies.find((c) => c.startsWith('preferences='));\n\t\texpect(prefCookie).toBeDefined();\n\n\t\tconst prefValue = JSON.parse(decodeURIComponent(prefCookie!.split(';')[0]!.split('=')[1]!));\n\t\texpect(prefValue).toMatchObject(VALID_PREFS_1);\n\t});\n\n\tit('should reject request with no body', async () => {\n\t\tconst cookie = (await integrationUtils.createAndLoginUser()).cookie;\n\n\t\tconst response = await testRequest().post('/api/set-preferences').set('Cookie', cookie);\n\n\t\texpect(response.status).toBe(400);\n\t});\n\n\tit('should reject request with missing preferences', async () => {\n\t\tconst cookie = (await integrationUtils.createAndLoginUser()).cookie;\n\n\t\tconst response = await testRequest()\n\t\t\t.post('/api/set-preferences')\n\t\t\t.set('Cookie', cookie)\n\t\t\t.send({}); // No preferences\n\n\t\texpect(response.status).toBe(400);\n\t});\n\n\tit('should reject requests from unauthenticated users', async () => {\n\t\tconst response = await testRequest()\n\t\t\t.post('/api/set-preferences')\n\t\t\t.send({ preferences: VALID_PREFS_1 });\n\n\t\texpect(response.status).toBe(401);\n\t});\n\n\tit('should reject invalid preferences', async () => {\n\t\tconst cookie = (await integrationUtils.createAndLoginUser()).cookie;\n\n\t\tconst invalidPrefs = {\n\t\t\ttheme: 'invalid-theme-name',\n\t\t\tlegal_moves: 'triangles', // Invalid shape\n\t\t\tanimations: 'yes', // Should be boolean\n\t\t};\n\n\t\tconst response = await testRequest()\n\t\t\t.post('/api/set-preferences')\n\t\t\t.set('Cookie', cookie)\n\t\t\t.send({ preferences: invalidPrefs });\n\n\t\texpect(response.status).toBe(400);\n\t});\n\n\tit('should allow logged-in user to save valid preferences', async () => {\n\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\tconst response = await testRequest()\n\t\t\t.post('/api/set-preferences')\n\t\t\t.set('Cookie', user.cookie)\n\t\t\t.send({ preferences: VALID_PREFS_1 });\n\n\t\texpect(response.status).toBe(200);\n\n\t\t// Verify DB update\n\t\tconst record = getMemberDataByCriteria(['preferences'], 'username', user.username);\n\t\texpect(record).toBeDefined();\n\t\tconst savedPrefs = record!.preferences === null ? null : JSON.parse(record!.preferences);\n\t\texpect(savedPrefs).toMatchObject(VALID_PREFS_1);\n\t});\n\n\tit('should overwrite existing preferences', async () => {\n\t\tconst user = await integrationUtils.createAndLoginUser();\n\n\t\t// 1. Save initial preferences\n\t\tawait testRequest()\n\t\t\t.post('/api/set-preferences')\n\t\t\t.set('Cookie', user.cookie)\n\t\t\t.send({ preferences: VALID_PREFS_1 });\n\n\t\t// 2. Save new preferences to overwrite\n\t\tconst response = await testRequest()\n\t\t\t.post('/api/set-preferences')\n\t\t\t.set('Cookie', user.cookie)\n\t\t\t.send({ preferences: VALID_PREFS_2 });\n\n\t\texpect(response.status).toBe(200);\n\n\t\t// Verify DB update\n\t\tconst record = getMemberDataByCriteria(['preferences'], 'username', user.username);\n\t\texpect(record).toBeDefined();\n\t\tconst savedPrefs = record!.preferences === null ? null : JSON.parse(record!.preferences);\n\t\texpect(savedPrefs).toMatchObject(VALID_PREFS_2);\n\t});\n});\n"
  },
  {
    "path": "src/server/api/Prefs.ts",
    "content": "// src/server/api/Prefs.ts\n\n/**\n * This script sets the preferences cookie on any request to an HTML file.\n * And it has an API for setting your preferences in the database.\n */\n\nimport type { NextFunction, Request, Response } from 'express';\n\nimport z from 'zod';\n\nimport themes from '../../shared/components/header/themes.js';\nimport jsutil from '../../shared/util/jsutil.js';\n\nimport { logZodError } from '../utility/zodlogger.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\nimport { getMemberDataByCriteria, updateMemberColumns } from '../database/memberManager.js';\n\n// Types -------------------------------------------------------------------------------\n\ntype Preferences = z.infer<typeof prefsSchema>;\n\n// Variables -----------------------------------------------------------------------------\n\n/** Zod schema to validate preferences object structure. */\nconst prefsSchema = z\n\t.strictObject({\n\t\ttheme: z.string().refine((val) => themes.isThemeValid(val)),\n\t\tlegal_moves: z.enum(['squares', 'dots']),\n\t\tanimations: z.boolean(),\n\t\tlingering_annotations: z.boolean(),\n\t})\n\t.partial();\n\n/** The client has this long to read the cookie and update preferences in memory. */\nconst lifetimeOfPrefsCookieMillis = 1000 * 10; // 10 seconds\n\n// Functions -----------------------------------------------------------------------------\n\n/**\n * Middleware to set the preferences cookie for logged-in users based on their memberInfo cookie.\n * Only sets the preferences cookie on HTML requests (requests without an origin header).\n *\n * It is possible for the memberInfo cookie to be tampered with, but preferences can be public information anyway.\n * We are reading the memberInfo cookie instead of verifying their session token\n * because that could take a little bit longer as it requires a database look up.\n */\nfunction setPrefsCookie(req: Request, res: Response, next: NextFunction): void {\n\t// We don't have to worry about the request being for a resource because those have already been served.\n\t// The only scenario this request could be for now is an HTML or fetch API request\n\t// The 'is-fetch-request' header is a custom header we add on all fetch requests to let us know is is a fetch request.\n\tif (req.headers['is-fetch-request'] === 'true' || !req.accepts('html')) return next(); // Not an HTML request (but a fetch), don't set the cookie\n\n\t// We give everyone this cookie as soon as they login.\n\t// Since it is modifiable by JavaScript it's possible for them to\n\t// grab preferences of other users this way, but there's no harm in that.\n\tconst cookies = req.cookies;\n\tconst memberInfoCookieStringified = cookies['memberInfo'];\n\tif (memberInfoCookieStringified === undefined) return next(); // No cookie is present, not logged in\n\n\tlet memberInfoCookie; // { user_id, username }\n\ttry {\n\t\tmemberInfoCookie = JSON.parse(memberInfoCookieStringified);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`memberInfo cookie was not JSON parse-able when attempting to set preferences cookie. Maybe it was tampered? The cookie: \"${jsutil.ensureJSONString(memberInfoCookieStringified)}\" The error: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn next(); // Don't set the preferences cookie, but allow their request to continue as normal\n\t}\n\n\tif (typeof memberInfoCookie !== 'object') {\n\t\tlogEventsAndPrint(\n\t\t\t`memberInfo cookie did not parse into an object when attempting to set preferences cookie. Maybe it was tampered? The cookie: \"${jsutil.ensureJSONString(memberInfoCookieStringified)}\"`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn next(); // Don't set the preferences cookie, but allow their request to continue as normal\n\t}\n\n\tconst user_id = memberInfoCookie.user_id;\n\tif (typeof user_id !== 'number') {\n\t\tlogEventsAndPrint(\n\t\t\t`memberInfo cookie user_id property was not a number when attempting to set preferences cookie. Maybe it was tampered? The cookie: \"${jsutil.ensureJSONString(memberInfoCookieStringified)}\"`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn next(); // Don't set the preferences cookie, but allow their request to continue as normal\n\t}\n\n\tconst preferences = getPrefs(user_id); // Fetch their preferences from the database\n\tif (!preferences) return next(); // No preferences set for this user, or the user doesn't exist.\n\n\tcreatePrefsCookie(res, preferences);\n\n\t// console.log(`Set preferences cookie for member \"${ensureJSONString(memberInfoCookie.username)}\" for url: ` + req.url);\n\n\tnext();\n}\n\n/**  Sets the preferences cookie for the user. */\nfunction createPrefsCookie(res: Response, preferences: Preferences): void {\n\t// Set or update the preferences cookie\n\tres.cookie('preferences', JSON.stringify(preferences), {\n\t\thttpOnly: false,\n\t\tsecure: true,\n\t\tmaxAge: lifetimeOfPrefsCookieMillis,\n\t});\n}\n\n/**\n * Deletes the preferences cookie for the user.\n * Typically called when they log out.\n * Even though the cookie only lasts 10 seconds, this is still helpful\n */\nfunction deletePreferencesCookie(res: Response): void {\n\tres.clearCookie('preferences', {\n\t\thttpOnly: false,\n\t\tsecure: true,\n\t});\n}\n\n/**\n * Fetches the preferences for a given user from the database.\n * @param userId - The ID of the user whose preferences are to be fetched.\n * @returns The preferences object if found, otherwise undefined.\n */\nfunction getPrefs(userId: number): Preferences | undefined {\n\tconst record = getMemberDataByCriteria(['preferences'], 'user_id', userId);\n\tif (record === undefined) return;\n\tif (record.preferences === null) return;\n\treturn JSON.parse(record.preferences);\n}\n\n/** Route that Handles a POST request to update user preferences in the database. */\nfunction postPrefs(req: Request, res: Response): void {\n\tif (!req.memberInfo?.signedIn) {\n\t\tlogEventsAndPrint(\n\t\t\t\"User tried to save preferences when they weren't signed in!\",\n\t\t\t'errLog.txt',\n\t\t);\n\t\tres.status(401).json({ message: \"Can't save preferences, not signed in.\" });\n\t\treturn;\n\t}\n\n\tconst { user_id, username } = req.memberInfo;\n\n\tconst preferences = req.body.preferences;\n\n\t// Validate preferences using Zod schema\n\tconst parseResult = prefsSchema.safeParse(preferences);\n\tif (!parseResult.success) {\n\t\tlogZodError(\n\t\t\tpreferences,\n\t\t\tparseResult.error,\n\t\t\t`Member \"${username}\" of id \"${user_id}\" tried to save invalid preferences to the database.`,\n\t\t);\n\t\tres.status(400).json({ message: 'Preferences not valid, cannot save on the server.' });\n\t\treturn;\n\t}\n\n\ttry {\n\t\t// Update the preferences column in the database\n\t\tconst result = updateMemberColumns(user_id, {\n\t\t\tpreferences: JSON.stringify(parseResult.data),\n\t\t});\n\n\t\t// Send appropriate response\n\t\tif (result.changeMade) {\n\t\t\t// console.log(\n\t\t\t// \t`Successfully saved member \"${username}\" of id \"${user_id}\"s user preferences.`,\n\t\t\t// );\n\t\t\tres.status(200).json({ message: 'Preferences updated successfully' });\n\t\t} else {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`Failed to save preferences for member \"${username}\" id \"${user_id}\". No change made. Do they exist?`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t\tres.status(500).json({ message: 'Failed to update preferences' });\n\t\t}\n\t} catch (error) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error occurred while saving preferences for member \"${username}\" of ID \"${user_id}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tres.status(500).json({ message: 'Server error while updating preferences' });\n\t}\n}\n\nexport { setPrefsCookie, postPrefs, deletePreferencesCookie };\n"
  },
  {
    "path": "src/server/app.ts",
    "content": "// src/server/app.ts\n\n/**\n * Defines and configures the Express application instance.\n */\n\nimport ejs from 'ejs';\nimport express from 'express';\n\nimport { initTranslations } from './config/i18n.js';\nimport { configureMiddleware } from './middleware/middleware.js';\n\nconst app = express();\n\n// This ensures that req.ip will give us the real user's IP instead of the Cloudflare proxy's IP.\napp.set('trust proxy', 1); // '1' means trust the first proxy hop (Cloudflare)\napp.disable('x-powered-by'); // This removes the 'x-powered-by' header from all responses.\n\n// Set EJS as the view engine\napp.engine('html', ejs.renderFile);\napp.set('view engine', 'html');\n\n// This is in here so integration tests work, as otherwise if\n// this is in server.js, i18next is never initialized for tests.\ninitTranslations();\n\nconfigureMiddleware(app); // Setup the middleware waterfall\n\nexport default app;\n"
  },
  {
    "path": "src/server/config/certOptions.ts",
    "content": "// src/server/config/certOptions.ts\n\nimport fs from 'fs';\nimport path from 'path';\n\nconst pathToCertFolder = path.resolve('cert'); // Resolve results in an absolute path\n\n/**\n * Retrieves SSL/TLS certificate options based on the application's\n * build environment, including the certificate and private key.\n */\nexport function getCertOptions(): { key: Buffer; cert: Buffer } {\n\tif (process.env['NODE_ENV'] !== 'production') {\n\t\t// Use self-signed certificates for development environment\n\t\treturn {\n\t\t\tkey: fs.readFileSync(path.join(pathToCertFolder, 'cert.key')),\n\t\t\tcert: fs.readFileSync(path.join(pathToCertFolder, 'cert.pem')),\n\t\t};\n\t} else {\n\t\t// Use officially signed certificates for production environment\n\t\treturn {\n\t\t\tkey: fs.readFileSync(path.join(process.env['CERT_PATH'] ?? '', 'privkey.pem')),\n\t\t\tcert: fs.readFileSync(path.join(process.env['CERT_PATH'] ?? '', 'fullchain.pem')),\n\t\t};\n\t}\n}\n"
  },
  {
    "path": "src/server/config/dateLocales.ts",
    "content": "// src/server/config/dateLocales.ts\n\nimport type { Locale } from 'date-fns';\n\nimport de from 'date-fns/locale/de/index.js';\nimport fr from 'date-fns/locale/fr/index.js';\nimport pl from 'date-fns/locale/pl/index.js';\nimport es from 'date-fns/locale/es/index.js';\nimport el from 'date-fns/locale/el/index.js';\nimport ja from 'date-fns/locale/ja/index.js';\nimport ru from 'date-fns/locale/ru/index.js';\nimport it from 'date-fns/locale/it/index.js';\nimport hi from 'date-fns/locale/hi/index.js';\nimport ko from 'date-fns/locale/ko/index.js';\nimport tr from 'date-fns/locale/tr/index.js';\nimport fi from 'date-fns/locale/fi/index.js';\nimport enUS from 'date-fns/locale/en-US/index.js';\nimport ptBR from 'date-fns/locale/pt-BR/index.js';\nimport zhTW from 'date-fns/locale/zh-TW/index.js';\nimport zhCN from 'date-fns/locale/zh-CN/index.js';\nimport arSA from 'date-fns/locale/ar-SA/index.js';\n\n/** Maps i18n language codes to date-fns locales. */\nexport const localeMap: Record<string, Locale> = {\n\t'en-US': enUS,\n\t'es-ES': es,\n\t'fr-FR': fr,\n\t'pl-PL': pl,\n\t'pt-BR': ptBR,\n\t'zh-CN': zhCN,\n\t'zh-TW': zhTW,\n\t'de-DE': de,\n\t'el-GR': el,\n\t'ru-RU': ru,\n\t'it-IT': it,\n\t'fi-FI': fi,\n\t'ja-JP': ja,\n\t'ar-SA': arSA,\n\t'hi-IN': hi,\n\t'ko-KR': ko,\n\t'tr-TR': tr,\n};\n"
  },
  {
    "path": "src/server/config/generateCert.ts",
    "content": "// src/server/config/generateCert.ts\n\nimport fs from 'fs';\nimport path from 'path';\nimport forge from 'node-forge';\nimport { fileURLToPath } from 'node:url';\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\nconst certDir = path.join(__dirname, '..', '..', '..', 'cert');\n\n// Define the paths for the key and certificate files\nconst keyPath = path.join(certDir, 'cert.key');\nconst certPath = path.join(certDir, 'cert.pem');\n\n/** Generates a self-signed certificate. */\nfunction generateSelfSignedCertificate(): void {\n\tconst pki = forge.pki;\n\tconst keys = pki.rsa.generateKeyPair(2048);\n\tconst cert = pki.createCertificate();\n\n\tcert.publicKey = keys.publicKey;\n\tcert.serialNumber = '01';\n\tcert.validity.notBefore = new Date();\n\tcert.validity.notAfter = new Date();\n\tcert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);\n\n\tconst attrs = [\n\t\t{\n\t\t\tname: 'commonName',\n\t\t\tvalue: 'localhost',\n\t\t},\n\t];\n\n\tcert.setSubject(attrs);\n\tcert.setIssuer(attrs);\n\n\tcert.sign(keys.privateKey, forge.md.sha256.create());\n\n\t// Convert the PEM-formatted keys to strings\n\tconst privateKeyPem = pki.privateKeyToPem(keys.privateKey);\n\tconst certPem = pki.certificateToPem(cert);\n\n\t// Write the private key and certificate to the specified paths\n\tfs.writeFileSync(keyPath, privateKeyPem);\n\tfs.writeFileSync(certPath, certPem);\n\n\tconsole.log('Generated self-signed certificate.');\n}\n\n/**\n * Ensure that a self-signed certificate exists in the cert directory.\n * If cert.key and cert.pem do not exist, generate them.\n */\nexport function ensureSelfSignedCertificate(): void {\n\t// Create the cert directory if it doesn't exist\n\tfs.mkdirSync(certDir, { recursive: true });\n\n\tif (fs.existsSync(keyPath) && fs.existsSync(certPath)) return; // Self-signed certificate already exists\n\n\tgenerateSelfSignedCertificate();\n}\n"
  },
  {
    "path": "src/server/config/i18n.ts",
    "content": "// src/server/config/i18n.ts\n\nimport i18next from 'i18next';\nimport { LanguageDetector } from 'i18next-http-middleware';\n\nimport translationLoader from './translationLoader.js';\n\n/** Initializes i18next for the server process, loading languages from .toml files. */\nfunction initTranslations(): void {\n\tconst translations = translationLoader.loadTranslations();\n\tconst supportedLngs = Object.keys(translations);\n\n\ti18next.use(LanguageDetector).init({\n\t\tresources: translations,\n\t\tsupportedLngs,\n\t\tdefaultNS: 'default',\n\t\t// fallbackLng: DEFAULT_LANGUAGE, // Fallback is handled by deepMerge() in translationLoader\n\t\t// debug: true, // Enable debug mode to see logs for missing keys and other details\n\t});\n}\n\nexport { initTranslations };\n"
  },
  {
    "path": "src/server/config/paths.ts",
    "content": "// src/server/config/paths.ts\n\n/**\n * This file defines absolute paths to important directories in the project\n */\n\nimport path from 'path';\nimport { fileURLToPath } from 'node:url';\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\n/** Absolute path to the project-root `logs/` directory. */\nconst LOGS_DIR = path.join(__dirname, '..', '..', '..', 'logs');\n\nexport default { LOGS_DIR };\n"
  },
  {
    "path": "src/server/config/setupDev.ts",
    "content": "// src/server/config/setupDev.ts\n\nimport validcheckmates from '../../shared/chess/util/validcheckmates.js';\n\nimport { giveRole } from '../controllers/roles.js';\nimport { generateAccount } from '../controllers/createAccountController.js';\nimport { ensureSelfSignedCertificate } from './generateCert.js';\nimport { isUsernameTaken, updateMemberColumns } from '../database/memberManager.js';\n\nimport 'dotenv/config'; // Imports all properties of process.env, if it exists\n\nexport function initDevEnvironment(): void {\n\tif (process.env['NODE_ENV'] === 'production') return;\n\n\tensureSelfSignedCertificate();\n\n\tensureDevelopmentAccounts();\n\n\t// Display the url to the page\n\tconsole.log(`Local website is hosted at https://localhost:${process.env['HTTPSPORT_LOCAL']}/`);\n}\n\nasync function ensureDevelopmentAccounts(): Promise<void> {\n\tif (!isUsernameTaken('owner')) {\n\t\tconst user_id = await generateAccount({\n\t\t\tusername: 'Owner',\n\t\t\temail: 'email1',\n\t\t\tpassword: '1',\n\t\t\tautoVerify: true,\n\t\t});\n\t\tgiveRole(user_id, 'owner');\n\t\tgiveRole(user_id, 'admin');\n\n\t\t// Give Owner checkmate progression for debugging purposes\n\t\t// Bronze\n\t\t// const checkmates_beaten = Object.values(validcheckmates.validCheckmates.easy).toString()\n\t\t// \t+ \",\" + Object.values(validcheckmates.validCheckmates.medium).toString();\n\t\t// Silver\n\t\t// const checkmates_beaten = Object.values(validcheckmates.validCheckmates.easy).toString()\n\t\t// \t+ \",\" + Object.values(validcheckmates.validCheckmates.medium).toString()\n\t\t// \t+ \",\" + Object.values(validcheckmates.validCheckmates.hard).toString();\n\t\t// Gold\n\t\tconst checkmates_beaten = Object.values(validcheckmates.validCheckmates).flat().join(',');\n\t\tupdateMemberColumns(user_id, { checkmates_beaten });\n\t}\n\tif (!isUsernameTaken('admin')) {\n\t\tconst user_id = await generateAccount({\n\t\t\tusername: 'Admin',\n\t\t\temail: 'email5',\n\t\t\tpassword: '1',\n\t\t\tautoVerify: true,\n\t\t});\n\t\tgiveRole(user_id, 'admin');\n\t}\n\tif (!isUsernameTaken('patron')) {\n\t\tconst user_id = await generateAccount({\n\t\t\tusername: 'Patron',\n\t\t\temail: 'email2',\n\t\t\tpassword: '1',\n\t\t\tautoVerify: true,\n\t\t});\n\t\tgiveRole(user_id, 'patron');\n\t}\n\tif (!isUsernameTaken('member')) {\n\t\tawait generateAccount({\n\t\t\tusername: 'Member',\n\t\t\temail: 'email3',\n\t\t\tpassword: '1',\n\t\t\tautoVerify: true,\n\t\t});\n\t}\n\n\t// Populate leaderboard with dummy accounts for testing\n\t// for (let i = 0; i < 230; i++) {\n\t// \tif (!doesMemberOfUsernameExist(`Player${i}`)) {\n\t// \t\tconst user_id = (await generateAccount({ username: `Player${i}`, email: `playeremail${i}`, password: \"1\", autoVerify: true })).user_id;\n\t// \t\taddUserToLeaderboard(user_id, Leaderboards.INFINITY);\n\t// \t\tupdatePlayerLeaderboardRating(user_id, Leaderboards.INFINITY, 1800 - 10 * i, 100 + i);\n\t// \t}\n\t// }\n}\n"
  },
  {
    "path": "src/server/config/translationLoader.ts",
    "content": "// src/server/config/translationLoader.ts\n\n/**\n * Handles loading and sanitizing translation TOML files.\n */\n\nimport fs from 'fs';\nimport path from 'path';\nimport * as z from 'zod';\nimport { marked } from 'marked';\nimport { fileURLToPath } from 'node:url';\nimport { parse, TomlTable } from 'smol-toml';\nimport { format, parseISO } from 'date-fns';\nimport { FilterXSS, IFilterXSSOptions } from 'xss';\n\nimport { localeMap } from './dateLocales.js';\nimport { DEFAULT_LANGUAGE } from '../utility/translate.js';\n\n// Types ---------------------------------------------------------------------\n\n/** All translations for every single language. */\ntype Translations = Record<string, LanguageTranslations>;\n/** All translations for a single language. */\ntype LanguageTranslations = { default: Record<string, any> };\n\nconst changelogSchema = z.record(\n\tz.string().refine((val) => Number.isInteger(Number(val)), {\n\t\tmessage: 'Key must be an integer string',\n\t}),\n\tz.object({\n\t\t// note: ,\n\t\tnote: z.union([\n\t\t\tz.string().min(1, 'Note cannot be empty'),\n\t\t\tz.array(z.string().min(1, 'Note cannot be empty')).min(1, 'Note cannot be empty'),\n\t\t]),\n\t\tchanges: z.array(z.string()).optional(),\n\t}),\n);\ntype Changelog = z.infer<typeof changelogSchema>;\n\n// Constants -----------------------------------------------------------------\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\n/** The folder path containing translation TOML files. */\nconst translationsFolder = path.join(__dirname, '../../../translation');\n/** The changelog file path for tracking the English TOML version changes. */\nconst changesFile = path.join(translationsFolder, 'changes.json');\n\n/** The folder path containing news markdown files for various languages. */\nconst newsFolder = path.join(translationsFolder, 'news');\n/** The folder path containing English markdown news posts. */\nconst englishNewsFolder = path.join(newsFolder, DEFAULT_LANGUAGE);\n\nconst xss_options: IFilterXSSOptions = {\n\t// Allows using these html tags in translation key strings for formatting.\n\twhiteList: {\n\t\tem: [],\n\t\tstrong: [],\n\t\tb: [],\n\t\ti: [],\n\t\tbr: [],\n\t},\n};\nconst custom_xss = new FilterXSS(xss_options);\n\n// Functions -----------------------------------------------------------------\n\n/** Loads and processes all translation TOML files into one object. */\nfunction loadTranslations(): Translations {\n\tconst translations: Translations = {};\n\n\tconst tomlFiles = fs.readdirSync(translationsFolder).filter((f) => f.endsWith('.toml'));\n\tconst changelog = loadChangelog();\n\n\ttomlFiles.forEach((file) => {\n\t\tconst languageCode = file.replace('.toml', '');\n\t\tconst tomlPath = path.join(translationsFolder, file);\n\t\tconst toml = fs.readFileSync(tomlPath).toString(); // Load\n\t\tconst toml_parsed = parse(toml); // Parse\n\t\tconst toml_updated = removeOutdated(toml_parsed, changelog); // Version\n\t\tconst toml_sanitized = html_escape(toml_updated); // Sanitize\n\n\t\ttranslations[languageCode] = { default: toml_sanitized };\n\t});\n\n\t// Deep-merge the English (fallback) translations into every other language so that\n\t// missing nested keys are always present. i18next's fallbackLng only handles leaf-key\n\t// lookups; when an EJS template calls t('some.section', { returnObjects: true }) it\n\t// receives the language's partial object with no further fallback for missing sub-trees.\n\tconst englishTranslations = translations[DEFAULT_LANGUAGE]!.default;\n\tfor (const [languageCode, languageTranslations] of Object.entries(translations)) {\n\t\tif (languageCode === DEFAULT_LANGUAGE) continue;\n\t\ttranslations[languageCode] = {\n\t\t\tdefault: deepMerge(englishTranslations, languageTranslations.default),\n\t\t};\n\t}\n\n\treturn translations;\n}\n\n/**\n * Deep-merges `source` into `target`, returning a new object.\n * Keys present in `source` but absent in `target` are copied from `source` (English fallback).\n * Keys present in both are recursively merged when both values are plain objects;\n * otherwise the `target` value takes precedence.\n */\nfunction deepMerge(source: Record<string, any>, target: Record<string, any>): Record<string, any> {\n\tconst result: Record<string, any> = { ...source };\n\tfor (const [key, targetValue] of Object.entries(target)) {\n\t\tconst sourceValue = result[key];\n\t\tif (\n\t\t\ttargetValue !== null &&\n\t\t\ttypeof targetValue === 'object' &&\n\t\t\t!Array.isArray(targetValue) &&\n\t\t\tsourceValue !== null &&\n\t\t\ttypeof sourceValue === 'object' &&\n\t\t\t!Array.isArray(sourceValue)\n\t\t) {\n\t\t\tresult[key] = deepMerge(sourceValue, targetValue);\n\t\t} else {\n\t\t\tresult[key] = targetValue;\n\t\t}\n\t}\n\treturn result;\n}\n\n/** Loads the English TOML changelog file into an object. */\nfunction loadChangelog(): Changelog {\n\tconst changelogRaw = fs.readFileSync(changesFile).toString();\n\tconst changelogParsed = JSON.parse(changelogRaw);\n\treturn changelogSchema.parse(changelogParsed);\n}\n\n/**\n * Loads news posts from markdown files into an object.\n * @param supportedLanguages - A list of all languages with a TOML file.\n * @returns An object mapping language codes to their compiled news HTML.\n */\nfunction loadNews(supportedLanguages: string[]): Record<string, string> {\n\tconst newsPosts: Record<string, string> = {};\n\n\t/** Sorted English news posts filenames */\n\tconst englishNewsPosts = fs\n\t\t.readdirSync(englishNewsFolder)\n\t\t.filter((n) => n !== '.DS_Store') // Hidden macOS file\n\t\t.sort((a, b) => {\n\t\t\tconst dateA = new Date(a.replace('.md', ''));\n\t\t\tconst dateB = new Date(b.replace('.md', ''));\n\t\t\treturn dateB.getTime() - dateA.getTime();\n\t\t});\n\n\tsupportedLanguages.forEach((languageCode) => {\n\t\t// Generate News posts HTML for this language\n\t\tnewsPosts[languageCode] = englishNewsPosts\n\t\t\t.map((fileName) => {\n\t\t\t\tconst fullPath = path.join(newsFolder, languageCode, fileName);\n\n\t\t\t\t// Read news post (fallback to default language)\n\t\t\t\tconst content = fs.existsSync(fullPath)\n\t\t\t\t\t? fs.readFileSync(fullPath)\n\t\t\t\t\t: fs.readFileSync(path.join(englishNewsFolder, fileName));\n\t\t\t\t// Compile markdown to HTML\n\t\t\t\tconst parsedHTML = marked.parse(content.toString());\n\n\t\t\t\t// Date Formatting\n\t\t\t\tconst dateISO = fileName.replace('.md', ''); // YYYY-MM-DD\n\t\t\t\tconst date = format(parseISO(dateISO), 'PP', { locale: localeMap[languageCode] });\n\n\t\t\t\treturn `<div class='news-post' data-date='${dateISO}'>\n\t\t\t\t\t\t\t<span class='news-post-date'>${date}</span>\n\t\t\t\t\t\t\t<div class='news-post-markdown'>${parsedHTML}</div>\n\t\t\t\t\t\t</div>`;\n\t\t\t})\n\t\t\t.join('\\n<hr>\\n');\n\t});\n\n\treturn newsPosts;\n}\n\n/** Removes outdated translations from one language's toml object, according to the changelog. */\nfunction removeOutdated(object: TomlTable, changelog: Changelog): TomlTable {\n\tconst version = object['version'] as string;\n\t// Filter out versions that are older than version of current language\n\tconst filtered_entries = Object.entries(changelog).filter(\n\t\t([change]) => Number(version) < Number(change),\n\t);\n\n\t// Collect all keys to be removed\n\tlet key_strings: string[] = [];\n\tfor (const [, value] of filtered_entries) {\n\t\tif (value.changes === undefined) continue;\n\t\tkey_strings = key_strings.concat(value.changes);\n\t}\n\tkey_strings = [...new Set(key_strings)]; // Remove duplicates\n\n\tlet object_copy = object;\n\tfor (const key_string of key_strings) {\n\t\tobject_copy = remove_key(key_string, object_copy);\n\t}\n\n\treturn object_copy;\n}\n\n/**\n * Removes keys from `object` based on string of format 'foo.bar'.\n * @param key_string - String representing key that has to be deleted in format 'foo.bar'.\n * @param object - Object that is target of the removal.\n * @returns Copy of `object` with deleted values\n * @example\n * const obj = { foo: { bar: 42, baz: 100 }, qux: 7 };\n * const result = remove_key('foo.bar', obj); // { foo: { baz: 100 }, qux: 7 }\n */\nfunction remove_key(key_string: string, object: Record<string, any>): Record<string, any> {\n\tconst keys = key_string.split('.');\n\n\tlet currentObj = object;\n\tfor (let i = 0; i < keys.length - 1; i++) {\n\t\tif (currentObj[keys[i]!] !== undefined) currentObj = currentObj[keys[i]!];\n\t}\n\n\tif (currentObj[keys.at(-1)!] !== undefined) delete currentObj[keys.at(-1)!];\n\treturn object;\n}\n\n/**\n * Recursively traverses a data structure (array or object) and sanitizes all contained\n * strings using an XSS filter. This prevents malicious content from translation files\n * from being rendered in a user's browser.\n * @param value - The input value (e.g., the parsed content of a TOML file).\n * @returns A deep copy of the input with all string values sanitized.\n */\nfunction html_escape(value: any): any {\n\tif (Array.isArray(value)) {\n\t\tconst escaped = [];\n\t\tfor (const member of value) {\n\t\t\tescaped.push(html_escape(member));\n\t\t}\n\t\treturn escaped;\n\t}\n\tif (value !== null && typeof value === 'object') {\n\t\tconst escaped: Record<any, any> = {};\n\t\tfor (const [valueKey, valueValue] of Object.entries(value)) {\n\t\t\tescaped[valueKey] = html_escape(valueValue);\n\t\t}\n\t\treturn escaped;\n\t}\n\tif (typeof value === 'string') {\n\t\treturn custom_xss.process(value);\n\t}\n\treturn value; // numbers, booleans, etc.\n}\n\n// Exports -------------------------------------------------------------------\n\nexport default {\n\tloadTranslations,\n\tloadNews,\n};\n"
  },
  {
    "path": "src/server/controllers/authController.ts",
    "content": "// src/server/controllers/authController.ts\n\n/**\n * This controller is used to process login form data,\n * returning tru if username and password is correct.\n *\n * This also rate limits a members login attempts.\n */\n\nimport type { Request, Response } from 'express';\n\nimport bcrypt from 'bcrypt';\n\nimport { logEvents } from '../middleware/logEvents.js';\nimport { getTranslationForReq } from '../utility/translate.js';\nimport { getMemberDataByCriteria } from '../database/memberManager.js';\nimport {\n\tgetBrowserAgent,\n\tonCorrectPassword,\n\tonIncorrectPassword,\n\trateLimitLogin,\n} from './authRatelimiter.js';\n\n/**\n * Called when any fetch request submits login form data.\n * The req body needs to have the `username` and `password` properties.\n * If the req body does not have `username`, req.params must have the `member` property.\n * If the password is correct, this returns true.\n * Otherwise this sends a response to the client saying it was incorrect.\n * This is also rate limited.\n * @returns true if the password was correct\n */\nasync function testPasswordForRequest(req: Request, res: Response): Promise<boolean> {\n\tif (!verifyBodyHasLoginFormData(req, res)) return false; // If false, it will have already sent a response.\n\n\t// eslint-disable-next-line prefer-const\n\tlet { username: claimedUsername, password: claimedPassword } = req.body;\n\tclaimedUsername = claimedUsername || req.params['member'];\n\n\tconst record = getMemberDataByCriteria(\n\t\t['user_id', 'username', 'hashed_password'],\n\t\t'username',\n\t\tclaimedUsername,\n\t);\n\tif (record === undefined) {\n\t\t// User not found\n\t\tres.status(401).json({\n\t\t\tmessage: getTranslationForReq('server.javascript.ws-invalid_username', req),\n\t\t}); // Unauthorized, username not found\n\t\treturn false;\n\t}\n\n\tconst browserAgent = getBrowserAgent(req, record.username);\n\tif (!rateLimitLogin(req, res, browserAgent)) return false; // They are being rate limited from enterring incorrectly too many times\n\n\t// Test the password\n\tconst match = await bcrypt.compare(claimedPassword, record.hashed_password);\n\tif (!match) {\n\t\tlogEvents(`Incorrect password for user ${record.username}!`, 'loginAttempts.txt');\n\t\tres.status(401).json({\n\t\t\tmessage: getTranslationForReq('server.javascript.ws-incorrect_password', req),\n\t\t}); // Unauthorized, password not found\n\t\tonIncorrectPassword(browserAgent, record.username);\n\t\treturn false;\n\t}\n\n\tonCorrectPassword(browserAgent);\n\n\treturn true;\n}\n\n/**\n * Tests if the request body has valid `username` and `password` properties.\n * If not, this auto-sends a response to the client with an error.\n * @returns true if the body is valid\n */\nfunction verifyBodyHasLoginFormData(req: Request, res: Response): boolean {\n\tif (!req.body) {\n\t\t// Missing body\n\t\tconsole.log(`User sent a bad login request missing the body!`);\n\t\tres.status(400).send('Bad Request'); // 400 Bad request\n\t\treturn false;\n\t}\n\n\tconst { username, password } = req.body;\n\n\tif (!username || !password) {\n\t\tconsole.log(\n\t\t\t`User ${username} sent a bad login request missing either username or password!`,\n\t\t);\n\t\tres.status(400).json({ message: 'Username and password are required.' }); // 400 Bad request\n\t\treturn false;\n\t}\n\n\tif (typeof username !== 'string' || typeof password !== 'string') {\n\t\tconsole.log(\n\t\t\t`User ${username} sent a bad login request with either username or password not a string!`,\n\t\t);\n\t\tres.status(400).json({ message: 'Username and password must be a string.' }); // 400 Bad request\n\t\treturn false;\n\t}\n\n\treturn true;\n}\n\nexport { testPasswordForRequest };\n"
  },
  {
    "path": "src/server/controllers/authRatelimiter.ts",
    "content": "// src/server/controllers/authRatelimiter.ts\n\n/**\n * The script rate limits login/authentication attempts by a combination of username and IP address\n */\n\nimport type { Request, Response } from 'express';\n\nimport { getClientIP } from '../utility/IP.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\nimport { getTranslationForReq } from '../utility/translate.js';\n\n// Types ----------------------------------------------------------------------------\n\ntype LoginAttemptData = {\n\tattempts: number;\n\tcooldownTimeSecs: number;\n\tlastAttemptTime: Date;\n\tdeleteTimeoutID?: NodeJS.Timeout;\n};\n\n// Variables ----------------------------------------------------------------------------\n\n/** Maximum consecutive login attempts allowed for each username-IP\n * combination before they will be locked out temporarily. */\nconst maxLoginAttempts = 3;\n/** The amount of time the cooldown is incremented by, after failing by {@link maxLoginAttempts} *again*... */\nconst loginCooldownIncrementorSecs = 5;\n/**\n * A hash that stores login attempts for each ip and user.\n * `{\n *  \"username_IP\": {\n*      attempts: 0,\n*      cooldownTimeSecs: 0,\n*      lastAttemptTime: 0,\n       deleteTimeoutID,\n *  }\n * }`\n */\nconst loginAttemptData: Record<string, LoginAttemptData> = {};\n/**\n * The time, in milliseconds, to delete a browser agent from the\n * login attempt data, if they have stopped trying to login.\n */\nconst timeToDeleteBrowserAgentAfterNoAttemptsMillis = 1000 * 60 * 5; // 5 minutes\n\n// Functions ----------------------------------------------------------------------------\n\n/**\n * Prevents a user-IP combination from entering login attempts too fast.\n * @returns true if the attempt is allowed\n */\nfunction rateLimitLogin(req: Request, res: Response, browserAgent: string): boolean {\n\tconst now = new Date();\n\tloginAttemptData[browserAgent] = loginAttemptData[browserAgent] || {\n\t\tattempts: 0,\n\t\tcooldownTimeSecs: 0,\n\t\tlastAttemptTime: now,\n\t};\n\n\tconst timeSinceLastAttemptsSecs =\n\t\t(now.getTime() - loginAttemptData[browserAgent].lastAttemptTime.getTime()) / 1000;\n\n\tif (loginAttemptData[browserAgent].attempts < maxLoginAttempts) {\n\t\tincrementBrowserAgentLoginAttemptCounter(browserAgent, now);\n\t\treturn true; // Attempt allowed\n\t}\n\n\t// Too many attempts!\n\n\tif (timeSinceLastAttemptsSecs <= loginAttemptData[browserAgent].cooldownTimeSecs) {\n\t\t// Still on cooldown\n\n\t\tlet translation = getTranslationForReq('server.javascript.ws-login_failure_retry_in', req);\n\t\tconst login_cooldown = Math.floor(\n\t\t\tloginAttemptData[browserAgent].cooldownTimeSecs - timeSinceLastAttemptsSecs,\n\t\t);\n\t\tconst seconds_plurality =\n\t\t\tlogin_cooldown === 1\n\t\t\t\t? getTranslationForReq('server.javascript.ws-second', req)\n\t\t\t\t: getTranslationForReq('server.javascript.ws-seconds', req);\n\t\ttranslation += ` ${login_cooldown} ${seconds_plurality}.`;\n\n\t\tres.status(401).json({ message: translation }); // \"Failed to log in, try again in 3 seconds.\"\"\n\n\t\t// Reset the timer to auto-delete them from the login attempt data\n\t\t// if they haven't tried in a while.\n\t\t// This is so it doesn't get cluttered over time\n\t\t// as more and more people try to login and fail.\n\t\tresetTimerToDeleteBrowserAgent(browserAgent);\n\t\treturn false; // Attempt not allowed\n\t}\n\n\t// No longer on cooldown\n\tresetBrowserAgentLoginAttemptCounter(browserAgent);\n\tincrementBrowserAgentLoginAttemptCounter(browserAgent, now);\n\treturn true; // Attempt allowed\n}\n\n/**\n * Generates a unique browser agent string using the request object and username.\n * @param req - The request object.\n * @param username - The username.\n * @returns The browser agent string, `${usernameLowercase}${clientIP}`\n */\nfunction getBrowserAgent(req: Request, username: string): string {\n\tconst clientIP = getClientIP(req);\n\treturn `${username}${clientIP}`;\n}\n\n/**\n * Increments the login attempt counter in the login attempt data for a browser agent.\n * @param browserAgent - The browser agent string.\n * @param now - The current date and time.\n */\nfunction incrementBrowserAgentLoginAttemptCounter(browserAgent: string, now: Date): void {\n\tloginAttemptData[browserAgent]!.attempts += 1;\n\tloginAttemptData[browserAgent]!.lastAttemptTime = now;\n\t// Reset the timer to auto-delete them from the login attempt data\n\t// if they haven't tried in a while.\n\t// This is so it doesn't get cluttered over time\n\t// as more and more people try to login and fail.\n\tresetTimerToDeleteBrowserAgent(browserAgent);\n}\n\n/**\n * Resets the login attempt counter in the login attempt data for a browser agent.\n * @param browserAgent - The browser agent string.\n */\nfunction resetBrowserAgentLoginAttemptCounter(browserAgent: string): void {\n\tloginAttemptData[browserAgent]!.attempts = 0;\n}\n\n/**\n * Resets the timer to delete a browser agent from the login attempt data.\n * @param browserAgent - The browser agent string.\n */\nfunction resetTimerToDeleteBrowserAgent(browserAgent: string): void {\n\tcancelTimerToDeleteBrowserAgent(browserAgent);\n\tstartTimerToDeleteBrowserAgent(browserAgent);\n}\n\n/**\n * Cancels the timer to delete a browser agent from the login attempt data.\n * @param browserAgent - The browser agent string.\n */\nfunction cancelTimerToDeleteBrowserAgent(browserAgent: string): void {\n\tclearTimeout(loginAttemptData[browserAgent]?.deleteTimeoutID);\n\tdelete loginAttemptData[browserAgent]?.deleteTimeoutID;\n}\n\n/**\n * Starts the timer that will delete a browser agent from the login attempt data\n * after they have given up on trying passwords.\n * @param browserAgent - The browser agent string.\n */\nfunction startTimerToDeleteBrowserAgent(browserAgent: string): void {\n\tloginAttemptData[browserAgent]!.deleteTimeoutID = setTimeout(() => {\n\t\tdelete loginAttemptData[browserAgent];\n\t\tconsole.log(`Allowing browser agent \"${browserAgent}\" to login without cooldown again!`);\n\t}, timeToDeleteBrowserAgentAfterNoAttemptsMillis);\n}\n\n/**\n * Handles the rate limiting scenario when an incorrect password is entered.\n * Temporarily locks them out if they've entered too many incorrect passwords.\n * @param browserAgent - The browser agent string.\n * @param username - The username.\n */\nfunction onIncorrectPassword(browserAgent: string, username: string): void {\n\tif (loginAttemptData[browserAgent]!.attempts < maxLoginAttempts) return; // Don't lock them yet\n\t// Lock them!\n\tloginAttemptData[browserAgent]!.cooldownTimeSecs += loginCooldownIncrementorSecs;\n\tlogEventsAndPrint(\n\t\t`${username} got login locked for ${loginAttemptData[browserAgent]!.cooldownTimeSecs} seconds`,\n\t\t'loginAttempts.txt',\n\t);\n}\n\n/**\n * Handles the rate limiting scenario when a correct password is entered.\n * Deletes their browser agent from the login attempt data.\n * @param browserAgent - The browser agent string.\n */\nfunction onCorrectPassword(browserAgent: string): void {\n\tcancelTimerToDeleteBrowserAgent(browserAgent);\n\t// Delete now\n\tdelete loginAttemptData[browserAgent];\n}\n\nexport { rateLimitLogin, onCorrectPassword, onIncorrectPassword, getBrowserAgent };\n"
  },
  {
    "path": "src/server/controllers/authenticationTokens/accessTokenIssuer.ts",
    "content": "// src/server/controllers/authenticationTokens/accessTokenIssuer.ts\n\n// Route\n// Returns a new access token if refresh token hasn't expired.\n// Called by a fetch(). ALWAYS RETURN a json!\n\nimport type { Request, Response } from 'express';\n\nimport { signAccessToken } from './tokenSigner.js';\n\n/**\n * How long until the cookie containing their new access token\n * will last until expiring, in milliseconds.\n * This is NOT when the token itself expires, only the cookie.\n */\nconst expireTimeOfTokenCookieMillis = 1000 * 10; // 10 seconds\n\n/**\n * Called when the browser uses the /api/get-access-token API request. This reads any refresh token cookie present,\n * and gives them a new access token if they are signed in.\n * If they are not, it gives them a browser-id cookie to verify their identity.\n */\nfunction accessTokenIssuer(req: Request, res: Response): void {\n\tif (!req.memberInfo || !req.memberInfo.signedIn) {\n\t\tres.status(403).json({\n\t\t\tmessage: 'Invalid or missing refresh token (logged out), cannot issue access token.',\n\t\t}); // Forbidden\n\t\treturn;\n\t}\n\n\t// Token is valid! Send them new access token!\n\n\tconst { user_id, username, roles } = req.memberInfo;\n\tconst accessToken = signAccessToken(user_id, username, roles);\n\n\t// SEND the token as a cookie!\n\tcreateAccessTokenCookie(res, accessToken); // 10 second expiry time\n\tres.json({ message: 'Issued access token!' }); // Their member information is now stored in a cookie when the refreshed token cookie is generated\n\t// console.log(`Issued access token for member \"${username}\" --------`);\n}\n\n/** Creates and sets an HTTP-only cookie containing the refresh token. */\nfunction createAccessTokenCookie(res: Response, accessToken: string): void {\n\t// Cross-site usage requires we set sameSite to none! Also requires secure (https) true\n\tres.cookie('token', accessToken, {\n\t\tsameSite: 'none',\n\t\tsecure: true,\n\t\tmaxAge: expireTimeOfTokenCookieMillis, // 10 second time limit. JavaScript needs to read it in that time!\n\t});\n}\n\nexport { accessTokenIssuer };\n"
  },
  {
    "path": "src/server/controllers/authenticationTokens/sessionManager.ts",
    "content": "// src/server/controllers/authenticationTokens/sessionManager.ts\n\n/**\n * This module handles the creation, renewal, and revocation of user login sessions.\n * It uses secure cookies and interacts with the `refreshTokenManager` for database operations.\n */\n\nimport type { Request, Response } from 'express';\nimport type { Role } from '../roles.js';\nimport type { RefreshTokenRecord } from '../../database/refreshTokenManager.js';\n\nimport { deletePreferencesCookie } from '../../api/Prefs.js';\nimport { deletePracticeProgressCookie } from '../../api/PracticeProgress.js';\nimport { refreshTokenExpiryMillis, signRefreshToken } from './tokenSigner.js';\nimport { addRefreshToken, markRefreshTokenAsConsumed } from '../../database/refreshTokenManager.js';\n\nconst minTimeToWaitToRenewRefreshTokensMillis = 1000 * 60 * 60 * 24; // 1 day\n// const minTimeToWaitToRenewRefreshTokensMillis = 1000 * 10; // 10s\n\n// Renewing & Revoking Sessions --------------------------------------------------------------------\n\n/** Makes sure a user's session is still fresh, renewing it if it's older than a day. */\nexport function freshenSession(\n\treq: Request,\n\tres: Response,\n\tuser_id: number,\n\tusername: string,\n\troles: Role[] | null,\n\ttokenRecord: RefreshTokenRecord,\n): void {\n\t// If the token is already consumed (a new one was issued),\n\t// do not renew it again. Let this request finish using the \"dying\" token.\n\tif (tokenRecord.consumed_at) return;\n\n\tconst timeSinceCreated = Date.now() - tokenRecord.created_at;\n\tif (timeSinceCreated < minTimeToWaitToRenewRefreshTokensMillis) return;\n\n\t// console.log(\n\t// \t`Renewing member \"${username}\"s session by issuing them new login cookies! -------`,\n\t// );\n\n\t// Create the new token.\n\tconst newToken = signRefreshToken(user_id, username, roles);\n\n\t// Mark old token as consumed so it has a short grace period before it is fully invalidated.\n\tmarkRefreshTokenAsConsumed(tokenRecord.token);\n\t// Add the new token to the database.\n\taddRefreshToken(req, user_id, newToken);\n\n\t// Send the new token to the user in their cookies.\n\tcreateSessionCookies(res, user_id, username, newToken);\n}\n\n/**\n * Creates a new login session for a user when they login.\n * @param req - The Request object.\n * @param res - The Response object.\n * @param user_id - The unique id of the user in the database.\n * @param username - The username of the user.\n * @param roles - The roles the user has.\n */\nexport function createNewSession(\n\treq: Request,\n\tres: Response,\n\tuser_id: number,\n\tusername: string,\n\troles: Role[] | null,\n): void {\n\t// The payload can be an object with their username and their roles.\n\tconst refreshToken = signRefreshToken(user_id, username, roles);\n\n\t// Save the refresh token to the database\n\taddRefreshToken(req, user_id, refreshToken);\n\n\tcreateSessionCookies(res, user_id, username, refreshToken);\n}\n\n/**\n * Terminates the session of a client by deleting their session, preferences, and practice progress cookies.\n *\n * NOTE: This only clears the cookies from the user's browser.\n * To invalidate the token on the server side, you must also call `deleteRefreshToken(token)`.\n * This is typically done in a logout route handler.\n * @param res - The response object.\n */\nexport function revokeSession(res: Response): void {\n\tdeleteSessionCookies(res);\n\tdeletePreferencesCookie(res); // Even though this cookie expires after 10 seconds, it's good to delete it here anyway.\n\tdeletePracticeProgressCookie(res);\n}\n\n// Cookies storing session information --------------------------------------------------------------------\n\n/**\n * Creates and sets the cookies:\n * * `memberInfo` containing user info (user ID and username),\n * * `jwt` containing our refresh token.\n * @param res - The response object.\n * @param userId - The ID of the user.\n * @param username - The username of the user.\n * @param refreshToken - The refresh token to be stored in the cookie.\n */\nfunction createSessionCookies(\n\tres: Response,\n\tuserId: number,\n\tusername: string,\n\trefreshToken: string,\n): void {\n\t// Create and sets an HTTP-only cookie containing the refresh token.\n\t// Cross-site usage requires we set sameSite to none! Also requires secure (https) true.\n\tres.cookie('jwt', refreshToken, {\n\t\thttpOnly: true,\n\t\tsameSite: 'none',\n\t\tsecure: true,\n\t\tmaxAge: refreshTokenExpiryMillis,\n\t});\n\tcreateMemberInfoCookie(res, userId, username);\n}\n\n/**\n * Creates and sets a cookie containing user info (user ID and username),\n * accessible by JavaScript, with the same expiration as the refresh token.\n * @param res - The response object.\n * @param userId - The ID of the user.\n * @param username - The username of the user.\n */\nfunction createMemberInfoCookie(res: Response, userId: number, username: string): void {\n\t// Create an object with member info\n\tconst now = Date.now();\n\tconst expires = now + refreshTokenExpiryMillis;\n\tconst memberInfo = JSON.stringify({ user_id: userId, username, issued: now, expires });\n\n\t// Set the cookie (readable by JavaScript, not HTTP-only).\n\t// Cross-site usage requires we set sameSite to 'None'! Also requires secure (https) true.\n\tres.cookie('memberInfo', memberInfo, {\n\t\thttpOnly: false,\n\t\tsameSite: 'none',\n\t\tsecure: true,\n\t\tmaxAge: refreshTokenExpiryMillis,\n\t});\n}\n\n/**\n * Deletes the cookies that store session information.\n * @param res - The response object.\n */\nfunction deleteSessionCookies(res: Response): void {\n\t// Clear the HTTP-only 'jwt' cookie by setting the same options as when it was created.\n\tres.clearCookie('jwt', { httpOnly: true, sameSite: 'none', secure: true });\n\t// Clear the 'memberInfo' cookie by setting the same options as when it was created.\n\tres.clearCookie('memberInfo', { httpOnly: false, sameSite: 'none', secure: true });\n}\n"
  },
  {
    "path": "src/server/controllers/authenticationTokens/tokenSigner.ts",
    "content": "// src/server/controllers/authenticationTokens/tokenSigner.ts\n\n/**\n * Tokens can be signed with the payload that includes any information we want!\n * We like to use user ID, username and roles.\n *\n * The benefit of signing access tokens with information is when we verify the tokens,\n * we don't have to do a database lookup to know who they are!\n */\n\nimport type { Role } from '../roles.js';\n\nimport jwt from 'jsonwebtoken';\n\nimport tokenConfig from '../../../shared/util/tokenConfig.js';\n\nimport 'dotenv/config'; // Imports all properties of process.env, if it exists\n\n/** The payload of the JWT token, containing user information. */\ninterface TokenPayload {\n\tuser_id: number;\n\tusername: string;\n\troles: Role[] | null;\n}\n\nif (!process.env['ACCESS_TOKEN_SECRET']) throw new Error('Missing ACCESS_TOKEN_SECRET');\nif (!process.env['REFRESH_TOKEN_SECRET']) throw new Error('Missing REFRESH_TOKEN_SECRET');\nconst ACCESS_TOKEN_SECRET = process.env['ACCESS_TOKEN_SECRET'];\nconst REFRESH_TOKEN_SECRET = process.env['REFRESH_TOKEN_SECRET'];\n\n// Session tokens expiry times ------------------------------------------------------\n\nconst refreshTokenExpiryMillis = 1000 * 60 * 60 * 24 * 5; // 5 days\n// const refreshTokenExpiryMillis = 1000 * 20; // 20 seconds, for testing purposes.\n\n/** The window where a \"consumed\" token is still accepted. */\nconst refreshTokenGracePeriodMillis = 1000 * 10; // 10 seconds\n\n// Signing Tokens ------------------------------------------------------------------------------------\n\n/**\n * Signs and generates an access token for the user.\n */\nfunction signAccessToken(user_id: number, username: string, roles: Role[] | null): string {\n\tconst payload = generatePayload(user_id, username, roles);\n\tconst accessTokenExpirySecs = tokenConfig.ACCESS_TOKEN_EXPIRY_MILLIS / 1000;\n\treturn jwt.sign(payload, ACCESS_TOKEN_SECRET, { expiresIn: accessTokenExpirySecs }); // Typically short-lived, for in-memory storage only.\n}\n\n/**\n * Signs and generates a refresh token for the user.\n * The refresh token is long-lived (hours-days) and should be stored in an httpOnly cookie (not accessible via JS).\n */\nfunction signRefreshToken(user_id: number, username: string, roles: Role[] | null): string {\n\tconst payload = generatePayload(user_id, username, roles);\n\tconst refreshTokenExpirySecs = refreshTokenExpiryMillis / 1000;\n\treturn jwt.sign(payload, REFRESH_TOKEN_SECRET, { expiresIn: refreshTokenExpirySecs }); // Longer-lived, stored in an httpOnly cookie.\n}\n\n/** Generates the payload object for a JWT based on the user ID and username. */\nfunction generatePayload(user_id: number, username: string, roles: Role[] | null): TokenPayload {\n\treturn { user_id, username, roles };\n}\n\nexport {\n\trefreshTokenExpiryMillis,\n\trefreshTokenGracePeriodMillis,\n\tsignAccessToken,\n\tsignRefreshToken,\n};\n\nexport type { TokenPayload };\n"
  },
  {
    "path": "src/server/controllers/authenticationTokens/tokenValidator.ts",
    "content": "// src/server/controllers/authenticationTokens/tokenValidator.ts\n\n/**\n * This script tests provided tokens for validation,\n * returning the decoded user information if they are,\n * renews their session if possible,\n * and updates their last_seen property in the database.\n */\n\nimport jwt from 'jsonwebtoken';\n\nimport { logEventsAndPrint } from '../../middleware/logEvents.js';\nimport { doesMemberOfIDExist, updateLastSeen } from '../../database/memberManager.js';\nimport { refreshTokenGracePeriodMillis, TokenPayload } from './tokenSigner.js';\nimport {\n\tdeleteRefreshToken,\n\tfindRefreshToken,\n\tupdateRefreshTokenIP,\n\ttype RefreshTokenRecord,\n} from '../../database/refreshTokenManager.js';\n\nif (!process.env['ACCESS_TOKEN_SECRET']) throw new Error('Missing ACCESS_TOKEN_SECRET');\nif (!process.env['REFRESH_TOKEN_SECRET']) throw new Error('Missing REFRESH_TOKEN_SECRET');\nconst ACCESS_TOKEN_SECRET = process.env['ACCESS_TOKEN_SECRET'];\nconst REFRESH_TOKEN_SECRET = process.env['REFRESH_TOKEN_SECRET'];\n\n// Validating Tokens ---------------------------------------------------------------------------------\n\n/**\n * Checks if an access token is valid => not expired,\n * nor tampered, and the user account still exists.\n */\nfunction isAccessTokenValid(token: string):\n\t| {\n\t\t\tisValid: true;\n\t\t\tpayload: TokenPayload;\n\t  }\n\t| {\n\t\t\tisValid: false;\n\t\t\treason: string;\n\t  } {\n\t// Decode the token\n\tconst payload = decodeToken(token, false);\n\tif (!payload) return { isValid: false, reason: 'Token is expired or tampered.' };\n\n\ttry {\n\t\t// Check if the user account still exists.\n\t\tif (!doesMemberOfIDExist(payload.user_id))\n\t\t\treturn { isValid: false, reason: 'User account does not exist.' };\n\t} catch (error: unknown) {\n\t\t// This block will catch any unexpected errors from database calls\n\t\tconst message = error instanceof Error ? error.message : 'An unexpected error occurred.';\n\t\t// Reject the token as invalid in this case\n\t\treturn { isValid: false, reason: message };\n\t}\n\n\tupdateLastSeen(payload.user_id);\n\treturn { isValid: true, payload };\n}\n\n/**\n * Checks if a refresh token is valid. Not expired, nor tampered, and it's still\n * in the database (not manually invalidated by logging out, or deleting the account).\n * @param token\n * @param IP - Has a chance to not be defined on HTTP requests.\n * @returns\n */\nfunction isRefreshTokenValid(\n\ttoken: string,\n\tIP?: string,\n):\n\t| {\n\t\t\tisValid: true;\n\t\t\tpayload: TokenPayload;\n\t\t\ttokenRecord: RefreshTokenRecord;\n\t  }\n\t| {\n\t\t\tisValid: false;\n\t\t\treason: string;\n\t  } {\n\t// Decode the token\n\tconst payload = decodeToken(token, true);\n\tif (!payload) return { isValid: false, reason: 'Token is expired or tampered.' };\n\n\tlet tokenRecord: RefreshTokenRecord | undefined;\n\ttry {\n\t\t// Check against the database\n\t\ttokenRecord = resolveRefreshTokenRecord(token, IP);\n\t\tif (!tokenRecord)\n\t\t\treturn {\n\t\t\t\tisValid: false,\n\t\t\t\treason: 'Refresh token unable to be resolved in the database.',\n\t\t\t};\n\t} catch (error) {\n\t\t// This block will catch any unexpected errors from database calls\n\t\tconst errMsg = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(`Error resolving refresh token in the database: ${errMsg}`, 'errLog.txt');\n\t\treturn { isValid: false, reason: 'An internal error occurred during validation.' };\n\t}\n\n\tupdateLastSeen(payload.user_id);\n\treturn { isValid: true, payload, tokenRecord };\n}\n\n/**\n * Checks if a specific refresh token is present in the database, and has not expired,\n * deleting it if it has expired, and updating its last used IP address if it has changed.\n * If not present, it means it has either expired, been manually invalidated by the user logging out, or deleting their account.\n *\n * Returns the token record if found and valid, otherwise undefined.\n */\nfunction resolveRefreshTokenRecord(token: string, IP?: string): RefreshTokenRecord | undefined {\n\t// Find the token in the database.\n\tconst tokenRecord = findRefreshToken(token);\n\n\tif (!tokenRecord) return; // Token must have been manually invalidated by the user logging out, or deleting their account.\n\n\tconst now = Date.now();\n\n\t// Check if it is naturally expired.\n\tif (tokenRecord.expires_at < now) {\n\t\t// The token is expired, remove it from the database for cleanup.\n\t\tdeleteRefreshToken(token);\n\t\treturn;\n\t}\n\n\t// Check if it was consumed (replaced) and the grace period has ended.\n\tif (\n\t\ttokenRecord.consumed_at !== null &&\n\t\tnow - tokenRecord.consumed_at > refreshTokenGracePeriodMillis\n\t) {\n\t\t// The token is \"dead\" (grace period over). Remove it from the database.\n\t\tdeleteRefreshToken(token);\n\t\treturn;\n\t}\n\n\t// Update the IP address if it has changed.\n\tconst IP_New: string | null = IP || null;\n\tif (IP_New !== tokenRecord.ip_address) {\n\t\tupdateRefreshTokenIP(token, IP_New);\n\t}\n\n\treturn tokenRecord;\n}\n\n/** Extracts and decodes the payload from an access or refresh token. */\nfunction decodeToken(token: string, isRefreshToken: boolean): TokenPayload | undefined {\n\tconst secret = isRefreshToken ? REFRESH_TOKEN_SECRET : ACCESS_TOKEN_SECRET;\n\ttry {\n\t\t// Decode the JWT and return the payload\n\t\tconst jwtPayload = jwt.verify(token, secret) as jwt.JwtPayload; // Can cast here because we know we originally signed it as an object, not a string.\n\t\treturn {\n\t\t\tuser_id: jwtPayload['user_id'],\n\t\t\tusername: jwtPayload['username'],\n\t\t\troles: jwtPayload['roles'],\n\t\t};\n\t} catch (err) {\n\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t// Log the error event when verification fails\n\t\tlogEventsAndPrint(\n\t\t\t`Failed to decode token (isRefreshToken: ${isRefreshToken}): ${errMsg}. Token: \"${token}\"`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\t// Return undefined if verification fails (e.g., token is invalid or expired)\n\t\treturn undefined;\n\t}\n}\n\nexport { isAccessTokenValid, isRefreshTokenValid };\n"
  },
  {
    "path": "src/server/controllers/awsWebhook.ts",
    "content": "// src/server/controllers/awsWebhook.ts\n\n/**\n * Controller to handle AWS SNS webhooks for SES bounce and complaint notifications.\n */\n\nimport type { Request, Response } from 'express';\n\nimport MessageValidator from 'sns-validator';\n\nimport { addToBlacklist } from '../database/blacklistManager.js';\nimport { logEvents, logEventsAndPrint } from '../middleware/logEvents.js';\n\nconst validator = new MessageValidator();\n\n/**\n * Handles incoming webhooks from AWS SNS.\n * VERIFIES SIGNATURE to ensure request is actually from AWS.\n */\nexport async function handleSesWebhook(req: Request, res: Response): Promise<void> {\n\tconst body = req.body;\n\n\t// Basic sanity check\n\tif (!body || !req.headers['x-amz-sns-message-type']) {\n\t\tconsole.error('[AWS WEBHOOK] Invalid request: missing body or headers');\n\t\tres.status(400).send('Invalid request');\n\t\treturn;\n\t}\n\n\t// Verify the AWS Signature\n\t// We wrap the callback in a Promise to use await\n\ttry {\n\t\tawait new Promise<void>((resolve, reject) => {\n\t\t\tvalidator.validate(body, (err, _message) => {\n\t\t\t\tif (err) reject(err);\n\t\t\t\telse resolve();\n\t\t\t});\n\t\t});\n\t} catch (err: unknown) {\n\t\tconst msg = err instanceof Error ? err.message : String(err);\n\t\tlogEvents(\n\t\t\t`[AWS WEBHOOK] Signature Verification Failed! Is this a hacker? Error: ${msg}`,\n\t\t\t'awsNotifications.txt',\n\t\t);\n\t\t// This likely means a hacker is trying to spoof a request\n\t\tres.status(401).send('Invalid signature');\n\t\treturn;\n\t}\n\n\t// console.log('[AWS WEBHOOK] Signature verified successfully.');\n\n\t// If we get here, the request is guaranteed to be from Amazon.\n\tconst messageType = body.Type; // Note: Validator might normalize keys, but usually Body.Type matches header\n\n\t// -------------------------------------------------------------------------\n\t// CASE 1: Subscription Confirmation\n\t// -------------------------------------------------------------------------\n\tif (messageType === 'SubscriptionConfirmation') {\n\t\tconst subscribeUrl = body.SubscribeURL;\n\t\tconsole.log('[AWS WEBHOOK] Verifying subscription...');\n\t\tif (subscribeUrl) {\n\t\t\ttry {\n\t\t\t\t// We must perform a GET request to this URL to confirm we own the server\n\t\t\t\tawait fetch(subscribeUrl);\n\t\t\t\tconsole.log('[AWS WEBHOOK] Subscription Confirmed!');\n\t\t\t\tres.status(200).send('Confirmed');\n\t\t\t\treturn;\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error('[AWS WEBHOOK] Confirmation failed:', err);\n\t\t\t\tres.status(500).send('Failed');\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t}\n\n\t// -------------------------------------------------------------------------\n\t// CASE 2: Notification\n\t// -------------------------------------------------------------------------\n\telse if (messageType === 'Notification') {\n\t\t// console.log('[AWS WEBHOOK] Processing notification...');\n\t\t// Log entire message so we can learn unexpected structures\n\t\tlogEvents(`[AWS WEBHOOK] Received Notification: ${body.Message}`, 'awsNotifications.txt');\n\n\t\tlet sesMessage;\n\t\ttry {\n\t\t\t// AWS SNS wraps the actual SES JSON inside a string called \"Message\"\n\t\t\t// We must parse that inner string.\n\t\t\tsesMessage = JSON.parse(body.Message);\n\t\t} catch (err: unknown) {\n\t\t\tconst msg = err instanceof Error ? err.message : String(err);\n\t\t\tlogEventsAndPrint(`[AWS WEBHOOK] JSON Parse Error: ${msg}`, 'errLog.txt');\n\t\t\tres.status(400).send('Bad JSON');\n\t\t\treturn;\n\t\t}\n\n\t\tconst type = sesMessage.notificationType;\n\n\t\t// Handle Bounces\n\t\tif (type === 'Bounce') {\n\t\t\tconst bounce = sesMessage.bounce;\n\t\t\t// We strictly ban Permanent bounces (User Unknown, etc)\n\t\t\t// Transient bounces (Mailbox Full) are usually safe to retry later, but banning them is safer.\n\t\t\tif (bounce.bounceType === 'Permanent') {\n\t\t\t\t// 'Permanent' or 'Transient'\n\t\t\t\tconst recipients = bounce.bouncedRecipients;\n\t\t\t\tif (Array.isArray(recipients)) {\n\t\t\t\t\trecipients.forEach((recipient: any) => {\n\t\t\t\t\t\tconst email = recipient.emailAddress;\n\t\t\t\t\t\tlogEvents(`[AWS WEBHOOK] Hard Bounce: ${email}`, 'awsNotifications.txt');\n\n\t\t\t\t\t\t// Add to our blacklist table (our db is synchronious, using better-sqlite3)\n\t\t\t\t\t\taddToBlacklist(email, 'bounce');\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlogEvents(\n\t\t\t\t\t`[AWS WEBHOOK] Bounce Type is not Permanent. No action taken: ${bounce.bounceType}`,\n\t\t\t\t\t'awsNotifications.txt',\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// Handle Complaints (Spam Reports)\n\t\telse if (type === 'Complaint') {\n\t\t\tconst recipients = sesMessage.complaint.complainedRecipients;\n\t\t\tif (Array.isArray(recipients)) {\n\t\t\t\trecipients.forEach((recipient: any) => {\n\t\t\t\t\tconst email = recipient.emailAddress;\n\t\t\t\t\tlogEvents(`[AWS WEBHOOK] Complaint: ${email}`, 'awsNotifications.txt');\n\t\t\t\t\taddToBlacklist(email, 'spam_report');\n\t\t\t\t});\n\t\t\t}\n\t\t} else {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`[AWS WEBHOOK] Unknown notification type: ${type}`,\n\t\t\t\t'awsNotifications.txt',\n\t\t\t);\n\t\t}\n\t} else {\n\t\tlogEventsAndPrint(\n\t\t\t`[AWS WEBHOOK] Unknown message type: ${messageType}`,\n\t\t\t'awsNotifications.txt',\n\t\t);\n\t}\n\n\t// Always return 200 OK.\n\t// If we return 500, AWS will keep retrying to send us the same bounce event.\n\tres.status(200).send('OK');\n}\n"
  },
  {
    "path": "src/server/controllers/browserIDManager.ts",
    "content": "// src/server/controllers/browserIDManager.ts\n\nimport type { Request, Response, NextFunction } from 'express';\n\nimport uuid from '../../shared/util/uuid.js';\n\nimport { isBrowserIDBanned } from '../middleware/banned.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\n\nconst expireOfBrowserIDCookieMillis = 1000 * 60 * 60 * 24 * 7; // 7 days\n\n/**\n * Assigns/renews the browser-id cookie to all requests for an html file.\n * If they have an existing browser id, it renews it for 7 more days.\n * If they don't, it gives them a new browser id for 7 day.\n * @param req - The Express request object.\n * @param res - The Express response object.\n * @param next - The Express next middleware function.\n */\nfunction assignOrRenewBrowserID(req: Request, res: Response, next: NextFunction): void {\n\t// We don't have to worry about the request being for a resource because those have already been served.\n\t// The only scenario this request could be for now is an HTML or fetch API request\n\t// The 'is-fetch-request' header is a custom header we add on all fetch requests to let us know is is a fetch request.\n\tif (req.headers['is-fetch-request'] === 'true' || !req.accepts('html')) return next(); // Not an HTML request (but a fetch), don't set the cookie\n\n\tconst cookies = req.cookies;\n\tif (!cookies['browser-id']) giveBrowserID(req, res);\n\telse refreshBrowserID(req, res);\n\n\tnext();\n}\n\nfunction giveBrowserID(req: Request, res: Response): void {\n\tconst cookieName = 'browser-id';\n\tconst id = uuid.generateID_Base62(6);\n\n\t// console.log(`Assigning new browser-id: \"${id}\" for url: ` + req.url + ' --------');\n\n\t// Readable by server with web socket connections, NOT by javascript: MAX AGE IN MILLIS NOT SECS\n\tres.cookie(cookieName, id, {\n\t\thttpOnly: true,\n\t\tsameSite: 'none',\n\t\tsecure: true,\n\t\tmaxAge: expireOfBrowserIDCookieMillis /* 1 day */,\n\t});\n}\n\nfunction refreshBrowserID(req: Request, res: Response): void {\n\tconst cookieName = 'browser-id';\n\tconst cookies = req.cookies;\n\tconst id = cookies[cookieName]!;\n\n\tif (isBrowserIDBanned(id)) return makeBrowserIDPermanent(req, res, id);\n\n\t// console.log(`Renewing browser-id: \"${id}\" for url: ` + req.url);\n\n\t// Readable by server with web socket connections, NOT by javascript\n\tres.cookie(cookieName, id, {\n\t\thttpOnly: true,\n\t\tsameSite: 'none',\n\t\tsecure: true,\n\t\tmaxAge: expireOfBrowserIDCookieMillis,\n\t});\n}\n\nfunction makeBrowserIDPermanent(req: Request, res: Response, browserID: string): void {\n\t// Readable by server with web socket connections, NOT by javascript: MAX AGE IN MILLIS NOT SECS\n\tres.cookie('browser-id', browserID, {\n\t\thttpOnly: true,\n\t\tsameSite: 'none',\n\t\tsecure: true,\n\t\tmaxAge: Number.MAX_SAFE_INTEGER /* FOREVER!! */,\n\t});\n\n\tconst logThis = `Making banned browser-id PERMANENT: ${browserID} !!! ${req.headers.origin}   ${req.method}   ${req.url}   ${req.headers['user-agent']}`;\n\tlogEventsAndPrint(logThis, 'bannedIPLog.txt');\n}\n\nexport { assignOrRenewBrowserID };\n"
  },
  {
    "path": "src/server/controllers/createAccountController.ts",
    "content": "// src/server/controllers/createAccountController.ts\n\n/*\n * This module handles create account form data,\n * verifying the data, creating the account,\n * and sending them a verification email.\n *\n * It also answers requests for whether\n * a specific username or email is available.\n */\n\nimport crypto from 'crypto';\nimport bcrypt from 'bcrypt';\n// @ts-ignore this package has no types\nimport emailValidator from 'node-email-verifier';\nimport { Request, Response } from 'express';\nimport { RegExpMatcher, englishDataset, englishRecommendedTransformers } from 'obscenity';\n\nimport validators from '../../shared/util/validators.js';\n\nimport { handleLogin } from './loginController.js';\nimport { isBlacklisted } from '../database/blacklistManager.js';\nimport { getTranslationForReq } from '../utility/translate.js';\nimport { sendEmailConfirmation } from './emailController.js';\nimport { logEvents, logEventsAndPrint } from '../middleware/logEvents.js';\nimport {\n\taddUser,\n\tisEmailTaken,\n\tisUsernameTaken,\n\tSQLITE_CONSTRAINT_ERROR,\n} from '../database/memberManager.js';\n\n// Variables -------------------------------------------------------------------------\n\n/**\n * The number of times to SALT passwords before storing in the database.\n *\n * Consider moving SALT_ROUNDS to a config file or environment variable\n */\nconst PASSWORD_SALT_ROUNDS: number = 10;\n\n/**\n * Initialize the obscenity profanity matcher.\n * Uses the English dataset with recommended transformers.\n */\nconst profanityMatcher = new RegExpMatcher({\n\t...englishDataset.build(),\n\t...englishRecommendedTransformers,\n});\n\n// Functions -------------------------------------------------------------------------\n\n/**\n * This route is called whenever the user clicks \"Create Account\"\n */\nasync function createNewMember(req: Request, res: Response): Promise<void> {\n\tif (!req.body) {\n\t\tconsole.log(`User sent a bad create account request missing the whole body!`);\n\t\tres.status(400).send('Bad request'); // 400 Bad request\n\t\treturn;\n\t}\n\n\t// Honeypot Bot Catcher: `recovery` — if present, return generic success.\n\tconst recoveryEmail: string =\n\t\ttypeof req.body.recovery === 'string' ? req.body.recovery.trim() : '';\n\tif (recoveryEmail.length > 0) {\n\t\tconst username = typeof req.body.username === 'string' ? req.body.username : '[empty]';\n\t\tlogEventsAndPrint(\n\t\t\t`Bot signup detected! IP: ${req.ip}, Username: ${username}, User-Agent: ${req.get('User-Agent')}`,\n\t\t\t'newMemberLog.txt',\n\t\t);\n\t\t// Return a normal-looking success so bot doesn't adapt\n\t\tres.status(200).json({ success: true, created: true });\n\t\treturn;\n\t}\n\n\t// First make sure we have all 3 variables.\n\t// eslint-disable-next-line prefer-const\n\tlet { username, email, password }: { username: string; email: string; password: string } =\n\t\treq.body;\n\tif (typeof username !== 'string' || typeof email !== 'string' || typeof password !== 'string') {\n\t\tconsole.error(\n\t\t\t'We received request to create new member without all supplied username, email, and password!',\n\t\t);\n\t\tres.status(400).redirect('/400'); // Bad request\n\t\treturn;\n\t}\n\n\t// Make the email lowercase, so we don't run into problems with seeing if capitalized emails are taken!\n\temail = email.toLowerCase();\n\n\t// First we make checks on the username...\n\t// These 'return's are so that we don't send duplicate responses, AND so we don't create the member anyway.\n\tif (!doUsernameValidation(username, req, res)) return;\n\tif (!(await doEmailValidation(email, req, res))) return;\n\tif (!doPasswordFormatChecks(password, req, res)) return;\n\n\ttry {\n\t\tawait generateAccount({ username, email, password });\n\t} catch (error: unknown) {\n\t\tlet message = error instanceof Error ? error.message : 'An unexpected error occurred.';\n\t\t// Detect the specific constraint error message that can be thrown\n\t\tif (message === SQLITE_CONSTRAINT_ERROR)\n\t\t\tmessage = 'The username or email has just been taken.';\n\t\tres.status(500).json({ error: 'Could not generate account. ' + message });\n\t\treturn;\n\t}\n\n\t// Create new login session! They just created an account, so log them in!\n\t// This will handle our response/redirect too for us!\n\thandleLogin(req, res);\n}\n\n/**\n * Generate an account only from the provided username, email, and password.\n * Regex tests are skipped.\n * @returns If it was a success, the row ID of where the member was inserted (same as their user_id).\n *\n * @throws If account creation fails for any reason.\n */\nasync function generateAccount({\n\tusername,\n\temail,\n\tpassword,\n\tautoVerify = false,\n}: {\n\tusername: string;\n\temail: string;\n\tpassword: string;\n\tautoVerify?: boolean;\n}): Promise<number> {\n\t// Use bcrypt to hash & salt password\n\tconst hashedPassword = await bcrypt.hash(password, PASSWORD_SALT_ROUNDS); // Passes 10 salt rounds. (standard)\n\n\tconst { is_verified, verification_code, is_verification_notified } = autoVerify\n\t\t? {\n\t\t\t\tis_verified: 1 as 0 | 1,\n\t\t\t\tverification_code: null,\n\t\t\t\tis_verification_notified: 1 as 0 | 1,\n\t\t\t}\n\t\t: {\n\t\t\t\t// Don't auto verify them\n\t\t\t\tis_verified: 0 as 0 | 1,\n\t\t\t\tverification_code: crypto.randomBytes(24).toString('base64url'),\n\t\t\t\tis_verification_notified: 0 as 0 | 1,\n\t\t\t};\n\n\tconst user_id = addUser(\n\t\tusername,\n\t\temail,\n\t\thashedPassword,\n\t\tis_verified,\n\t\tverification_code,\n\t\tis_verification_notified,\n\t);\n\n\tlogEvents(`Created new member: ${username}`, 'newMemberLog.txt');\n\n\t// SEND EMAIL CONFIRMATION\n\tif (!autoVerify) sendEmailConfirmation(user_id);\n\n\treturn user_id;\n}\n\n/**\n * Route that's called whenever the client unfocuses the email input field.\n * This tells them whether the email is valid or not.\n */\nasync function checkEmailValidity(req: Request, res: Response): Promise<void> {\n\tconst lowercaseEmail = req.params['email']!.toLowerCase();\n\n\tif (isEmailTaken(lowercaseEmail)) {\n\t\tres.json({\n\t\t\tvalid: false,\n\t\t\treason: getTranslationForReq('server.javascript.ws-email_in_use', req),\n\t\t});\n\t\treturn;\n\t}\n\tif (isBlacklisted(lowercaseEmail)) {\n\t\tres.json({\n\t\t\tvalid: false,\n\t\t\treason: getTranslationForReq('server.javascript.ws-email_blacklisted', req),\n\t\t});\n\t\treturn;\n\t}\n\tif (!(await isEmailDNSValid(lowercaseEmail))) {\n\t\tres.json({\n\t\t\tvalid: false,\n\t\t\treason: getTranslationForReq('server.javascript.ws-email_domain_invalid', req),\n\t\t});\n\t\treturn;\n\t}\n\n\t// Both checks pass\n\tres.json({ valid: true });\n}\n\n/**\n * Route handler to check if a username is available to use (not taken, reserved, or baaaad word).\n * The request parameters MUST contain the username to test! (different from the body)\n *\n * We send the client the object: `{ allowed: true, reason: '' } | { allowed: false, reason: string }`\n */\nfunction checkUsernameAvailable(req: Request, res: Response): void {\n\tconst username = req.params['username']!;\n\tconst usernameLowercase = username.toLowerCase();\n\n\tlet allowed = true;\n\tlet reason = '';\n\n\tif (isUsernameTaken(username)) {\n\t\tallowed = false;\n\t\treason = getTranslationForReq('server.javascript.ws-username_taken', req);\n\t}\n\tif (checkProfanity(usernameLowercase)) {\n\t\tallowed = false;\n\t\treason = getTranslationForReq('server.javascript.ws-username_bad_word', req);\n\t}\n\t// we only check if it's reserved and ignore any other possible reasons it might not be a valid username\n\tif (\n\t\tvalidators.validateUsername(username) ===\n\t\tvalidators.UsernameValidationResult.UsernameIsReserved\n\t) {\n\t\tallowed = false;\n\t\treason = getTranslationForReq('create-account.javascript.js-username_reserved', req);\n\t}\n\n\tres.json({\n\t\tallowed,\n\t\treason,\n\t});\n\treturn;\n}\n\n/** Returns true if the username passes all the checks required before account generation. */\nfunction doUsernameValidation(username: string, req: Request, res: Response): boolean {\n\tconst result = validators.validateUsername(username);\n\tif (result !== validators.UsernameValidationResult.Ok) {\n\t\tswitch (result) {\n\t\t\tcase validators.UsernameValidationResult.UsernameTooShort:\n\t\t\tcase validators.UsernameValidationResult.UsernameTooLong:\n\t\t\t\tres.status(400).json({\n\t\t\t\t\tmessage: getTranslationForReq(\n\t\t\t\t\t\t'create-account.javascript.js-username_length',\n\t\t\t\t\t\treq,\n\t\t\t\t\t),\n\t\t\t\t});\n\t\t\t\treturn false;\n\t\t\tcase validators.UsernameValidationResult.OnlyLettersAndNumbers:\n\t\t\t\tres.status(400).json({\n\t\t\t\t\tmessage: getTranslationForReq('server.javascript.ws-username_letters', req),\n\t\t\t\t});\n\t\t\t\treturn false;\n\t\t\tcase validators.UsernameValidationResult.UsernameIsReserved:\n\t\t\t\tres.status(409).json({\n\t\t\t\t\tconflict: getTranslationForReq('server.javascript.ws-username_taken', req),\n\t\t\t\t}); // Code for reserved (but the users don't know that!)\n\t\t\t\treturn false;\n\t\t\tdefault:\n\t\t\t\tres.status(400).json({\n\t\t\t\t\tmessage: 'Username is not valid, but the server could not determine why.',\n\t\t\t\t});\n\t\t\t\treturn false;\n\t\t}\n\t}\n\t// Then check if the name's taken\n\tconst usernameLowercase = username.toLowerCase();\n\n\t// Make sure the username isn't taken!!\n\n\tif (isUsernameTaken(username)) {\n\t\tres.status(409).json({\n\t\t\tconflict: getTranslationForReq('server.javascript.ws-username_taken', req),\n\t\t});\n\t\treturn false;\n\t}\n\t// Lastly check for profain words\n\tif (checkProfanity(usernameLowercase)) {\n\t\tres.status(409).json({\n\t\t\tconflict: getTranslationForReq('server.javascript.ws-username_bad_word', req),\n\t\t});\n\t\treturn false;\n\t}\n\n\treturn true; // Everything's good, no conflicts!\n}\n\n/**\n * Returns true if profanity/offensive language is found in the string.\n * Uses the obscenity package with English dataset and recommended transformers.\n */\nfunction checkProfanity(string: string): boolean {\n\treturn profanityMatcher.hasMatch(string);\n}\n\n/** Returns true if the email passes all the checks required for account generation. */\nasync function doEmailValidation(string: string, req: Request, res: Response): Promise<boolean> {\n\tconst result = validators.validateEmail(string);\n\tif (result !== validators.EmailValidationResult.Ok) {\n\t\tswitch (result) {\n\t\t\tcase validators.EmailValidationResult.InvalidFormat:\n\t\t\t\tres.status(400).json({\n\t\t\t\t\tmessage: getTranslationForReq('server.javascript.ws-email_invalid', req),\n\t\t\t\t});\n\t\t\t\treturn false;\n\t\t\tcase validators.EmailValidationResult.EmailTooLong:\n\t\t\t\tres.status(400).json({\n\t\t\t\t\tmessage: getTranslationForReq('server.javascript.ws-email_too_long', req),\n\t\t\t\t});\n\t\t\t\treturn false;\n\t\t\tdefault:\n\t\t\t\tres.status(400).json({\n\t\t\t\t\tmessage: 'Email is not valid, but the server could not determine why.',\n\t\t\t\t});\n\t\t\t\treturn false;\n\t\t}\n\t}\n\tif (isEmailTaken(string)) {\n\t\tres.status(409).json({\n\t\t\tconflict: getTranslationForReq('server.javascript.ws-email_in_use', req),\n\t\t});\n\t\treturn false;\n\t}\n\tif (isBlacklisted(string)) {\n\t\tconst errMessage = `Blacklisted email ${string} tried to create an account!`;\n\t\tlogEventsAndPrint(errMessage, 'blacklistLog.txt');\n\t\tres.status(409).json({\n\t\t\tconflict: getTranslationForReq('server.javascript.ws-email_blacklisted', req),\n\t\t});\n\t\treturn false;\n\t}\n\tif (!(await isEmailDNSValid(string))) {\n\t\tres.status(400).json({\n\t\t\tmessage: getTranslationForReq('server.javascript.ws-email_domain_invalid', req),\n\t\t});\n\t\treturn false;\n\t}\n\treturn true;\n}\n\n/**\n * Checks an email address's MX records to see if it is valid\n */\nasync function isEmailDNSValid(email: string): Promise<boolean> {\n\ttry {\n\t\treturn await emailValidator(email, { checkMx: true });\n\t} catch (error) {\n\t\tconst err = error as Error; // Type assertion\n\t\tlogEventsAndPrint(\n\t\t\t`Error when validating domain for email \"${email}\": ${err.stack}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn true; // Default to true to avoid blocking users.\n\t}\n}\n\nfunction doPasswordFormatChecks(password: string, req: Request, res: Response): boolean {\n\tconst result = validators.validatePassword(password);\n\tif (result !== validators.PasswordValidationResult.Ok) {\n\t\tswitch (result) {\n\t\t\tcase validators.PasswordValidationResult.PasswordTooShort:\n\t\t\tcase validators.PasswordValidationResult.PasswordTooLong:\n\t\t\t\tres.status(400).json({\n\t\t\t\t\tmessage: getTranslationForReq('server.javascript.ws-password_length', req),\n\t\t\t\t});\n\t\t\t\treturn false;\n\t\t\tcase validators.PasswordValidationResult.PasswordIsPassword:\n\t\t\t\tres.status(400).json({\n\t\t\t\t\tmessage: getTranslationForReq('server.javascript.ws-password_password', req),\n\t\t\t\t});\n\t\t\t\treturn false;\n\t\t\tdefault:\n\t\t\t\tres.status(400).json({\n\t\t\t\t\tmessage: 'Password is not valid, but the server could not determine why.',\n\t\t\t\t});\n\t\t\t\treturn false;\n\t\t}\n\t}\n\treturn true;\n}\n\nexport {\n\tcreateNewMember,\n\tcheckEmailValidity,\n\tcheckUsernameAvailable,\n\tgenerateAccount,\n\tdoPasswordFormatChecks,\n\tPASSWORD_SALT_ROUNDS,\n\tprofanityMatcher,\n};\n"
  },
  {
    "path": "src/server/controllers/createAccountController.unit.test.ts",
    "content": "// src/server/controllers/createAccountController.unit.test.ts\n\n/**\n * Tests for the profanity filter used in account creation.\n *\n * This test suite verifies that the obscenity package correctly identifies\n * profane content in usernames during account creation.\n */\n\nimport { describe, it, expect } from 'vitest';\n\nimport { profanityMatcher } from './createAccountController'; // Import the identical one used in the controller\n\n/**\n * Helper function to check profanity (same logic as in createAccountController)\n */\nfunction checkProfanity(string: string): boolean {\n\treturn profanityMatcher.hasMatch(string);\n}\n\ndescribe('Profanity Filter', () => {\n\tdescribe('Basic profanity detection', () => {\n\t\tit('should detect common profane words', () => {\n\t\t\texpect(checkProfanity('fuck')).toBe(true);\n\t\t\texpect(checkProfanity('shit')).toBe(true);\n\t\t\texpect(checkProfanity('bitch')).toBe(true);\n\t\t\texpect(checkProfanity('ass')).toBe(true);\n\t\t});\n\n\t\tit('should detect profanity regardless of case', () => {\n\t\t\texpect(checkProfanity('FUCK')).toBe(true);\n\t\t\texpect(checkProfanity('FuCk')).toBe(true);\n\t\t\texpect(checkProfanity('sHiT')).toBe(true);\n\t\t});\n\n\t\tit('should detect profanity within usernames', () => {\n\t\t\texpect(checkProfanity('userfuck123')).toBe(true);\n\t\t\texpect(checkProfanity('shit4brains')).toBe(true);\n\t\t\texpect(checkProfanity('mybitch')).toBe(true);\n\t\t});\n\t});\n\n\tdescribe('Variant detection', () => {\n\t\tit('should detect common profanity variants', () => {\n\t\t\t// Obscenity package handles these with its transformers\n\t\t\t// Note: symbols are currently not allowed in usernames.\n\t\t\texpect(checkProfanity('f*ck')).toBe(true);\n\t\t\texpect(checkProfanity('sh!t')).toBe(true);\n\t\t\texpect(checkProfanity('b1tch')).toBe(true);\n\t\t});\n\n\t\tit('should detect leetspeak variants', () => {\n\t\t\texpect(checkProfanity('fuk')).toBe(true);\n\t\t\texpect(checkProfanity('fvck')).toBe(true);\n\t\t});\n\t});\n\n\tdescribe('Clean usernames', () => {\n\t\tit('should allow clean usernames', () => {\n\t\t\texpect(checkProfanity('john123')).toBe(false);\n\t\t\texpect(checkProfanity('player1')).toBe(false);\n\t\t\texpect(checkProfanity('cooluser')).toBe(false);\n\t\t\texpect(checkProfanity('chessmaster')).toBe(false);\n\t\t});\n\n\t\tit('should allow usernames with words that contain profanity substrings but are not profane', () => {\n\t\t\t// The obscenity package is smart enough to handle these cases\n\t\t\texpect(checkProfanity('password')).toBe(false);\n\t\t\texpect(checkProfanity('classic')).toBe(false);\n\t\t\texpect(checkProfanity('assassin')).toBe(false);\n\t\t});\n\n\t\tit('should allow numbers and alphanumeric combinations', () => {\n\t\t\texpect(checkProfanity('user123')).toBe(false);\n\t\t\texpect(checkProfanity('abc123xyz')).toBe(false);\n\t\t\texpect(checkProfanity('player9000')).toBe(false);\n\t\t});\n\n\t\tit('should allow usernames with profaine substrings in non-profane words', () => {\n\t\t\texpect(checkProfanity('passage')).toBe(false);\n\t\t\texpect(checkProfanity('classical')).toBe(false);\n\t\t\texpect(checkProfanity('assistant')).toBe(false);\n\t\t});\n\t});\n\n\tdescribe('Edge cases', () => {\n\t\tit('should handle empty strings', () => {\n\t\t\texpect(checkProfanity('')).toBe(false);\n\t\t});\n\n\t\tit('should handle single characters', () => {\n\t\t\texpect(checkProfanity('a')).toBe(false);\n\t\t\texpect(checkProfanity('1')).toBe(false);\n\t\t});\n\n\t\tit('should handle special characters only', () => {\n\t\t\texpect(checkProfanity('!@#$%')).toBe(false);\n\t\t});\n\n\t\tit('should handle long usernames with profanity', () => {\n\t\t\texpect(checkProfanity('verylongusernamewithfuckprofanity')).toBe(true);\n\t\t});\n\t});\n\n\tdescribe('Performance', () => {\n\t\tit('should handle multiple checks efficiently', () => {\n\t\t\tconst testUsernames = [\n\t\t\t\t'user1',\n\t\t\t\t'user2',\n\t\t\t\t'user3',\n\t\t\t\t'cleanuser',\n\t\t\t\t'chessplayer',\n\t\t\t\t'john123',\n\t\t\t\t'jane456',\n\t\t\t\t'player789',\n\t\t\t\t'gamer1000',\n\t\t\t\t'testuser',\n\t\t\t];\n\n\t\t\tconst startTime = Date.now();\n\t\t\ttestUsernames.forEach((username) => {\n\t\t\t\tcheckProfanity(username);\n\t\t\t});\n\t\t\tconst endTime = Date.now();\n\n\t\t\t// Should complete quickly\n\t\t\texpect(endTime - startTime).toBeLessThan(10);\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "src/server/controllers/deleteAccountController.ts",
    "content": "// src/server/controllers/deleteAccountController.ts\n\n/**\n * This module handles account deletion.\n */\n\nimport type { Request, Response } from 'express';\n\nimport { revokeSession } from './authenticationTokens/sessionManager.js';\nimport { getTranslationForReq } from '../utility/translate.js';\nimport { testPasswordForRequest } from './authController.js';\nimport { closeAllSocketsOfMember } from '../socket/socketManager.js';\nimport { isMemberInSomeActiveGame } from '../game/gamemanager/gamemanager.js';\nimport { logEvents, logEventsAndPrint } from '../middleware/logEvents.js';\nimport { deleteUser, getMemberDataByCriteria } from '../database/memberManager.js';\n\n// Constants -------------------------------------------------------------------------\n\n/**\n * A list of all valid reasons to delete an account.\n * These reasons are stored in the deleted_members table in the database.\n */\nconst validDeleteReasons = [\n\t'unverified', // They failed to verify after 3 days\n\t'user request', // They deleted their own account, or requested it to be deleted.\n\t'security', // A choice by server admins, for security purpose.\n\t'rating abuse', // Unfairly boosted their own elo with a throwaway account\n] as const;\n\n/** A valid account deletion reason. */\nexport type DeleteReason = (typeof validDeleteReasons)[number];\n\n// Functions -------------------------------------------------------------------------\n\n/**\n * Route that removes a user account if they request to delete it.\n * Checks if there password was correct first.\n * @param req - The request object.\n * @param res - The response object.\n */\nasync function removeAccount(req: Request, res: Response): Promise<void> {\n\tconst claimedUsername = req.params['member']; // case-insensitive username\n\tif (!claimedUsername) {\n\t\tres.status(400).send('Username required');\n\t\treturn;\n\t}\n\n\t// The delete account request doesn't come with the username already in the body, so we set that here.\n\treq.body.username = claimedUsername;\n\tif (!(await testPasswordForRequest(req, res))) {\n\t\t// It will have already sent a response\n\t\tlogEvents(\n\t\t\t`Incorrect password for user \"${claimedUsername}\" attempting to remove account!`,\n\t\t\t'loginAttempts.txt',\n\t\t);\n\t\treturn;\n\t}\n\n\t// Get user_id and case-sensitive username from database\n\tconst record = getMemberDataByCriteria(['user_id', 'username'], 'username', claimedUsername);\n\tif (record === undefined) {\n\t\tlogEventsAndPrint(\n\t\t\t`Unable to find member of claimed username \"${claimedUsername}\" after a correct password to delete their account!`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn;\n\t}\n\n\t// Do not allow account deletion if user is currently playing a game\n\t// THIS DOES NOT PREVENT AN ADMIN MANUALLY DELETING THEIR ACCOUNT\n\t// If that is done while they are in the middle of a rated game,\n\t// errors will happen when the game is deleted.\n\tif (isMemberInSomeActiveGame(record.username)) {\n\t\tlogEventsAndPrint(\n\t\t\t`User ${record.username} requested account deletion while being listed in some active game.`,\n\t\t\t'deletedAccounts.txt',\n\t\t);\n\t\tres.status(403).json({\n\t\t\tmessage: getTranslationForReq('server.javascript.ws-deleting_account_in_game', req),\n\t\t});\n\t\treturn;\n\t}\n\n\t// DELETE ACCOUNT..\n\n\t// Close their sockets, delete their invites, delete their session cookies...\n\trevokeSession(res);\n\n\tconst reason_deleted = 'user request';\n\n\ttry {\n\t\tdeleteAccount(record.user_id, reason_deleted);\n\t\tlogEvents(\n\t\t\t`Deleted account of user_id (${record.user_id}) for reason (${reason_deleted}).`,\n\t\t\t'deletedAccounts.txt',\n\t\t);\n\t\tres.send('OK'); // 200 is default code\n\t\treturn;\n\t} catch (error) {\n\t\tconst errorMessage = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Can't delete account of user_id (${record.user_id}) after a correct password entered: ${errorMessage}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tres.status(404).json({\n\t\t\tmessage: getTranslationForReq('server.javascript.ws-deleting_account_not_found', req),\n\t\t});\n\t\treturn;\n\t}\n}\n\n/**\n * Deletes a user's account by user_id,\n * terminates all their login session,\n * and closes all their open websockets.\n *\n * @throws If the delete reason is invalid, or if a database error occurs during the deletion process.\n */\nfunction deleteAccount(user_id: number, reason_deleted: string): void {\n\tif (!isValidDeleteReason(reason_deleted)) {\n\t\tthrow Error(`Delete reason (${reason_deleted}) is invalid.`);\n\t}\n\n\tdeleteUser(user_id, reason_deleted);\n\n\t// Close their sockets, delete their invites...\n\tcloseAllSocketsOfMember(user_id, 1008, 'Logged out');\n\n\t// Account deleting automatically invalidates all their sessions,\n\t// because their refresh tokens are deleted.\n\t// However, they will have to refresh the page for their page and navigation links to update.\n}\n\n/** Type Guard: Checks if a string is a valid DeleteReason. */\nfunction isValidDeleteReason(reason: string): reason is DeleteReason {\n\treturn validDeleteReasons.some((r) => r === reason);\n}\n\nexport { removeAccount, deleteAccount };\n"
  },
  {
    "path": "src/server/controllers/deployController.ts",
    "content": "// src/server/controllers/deployController.ts\n\n/**\n * Handles server lifecycle endpoints called by the GitHub Actions deploy workflow.\n *\n * All endpoints in this file are authenticated via the X-Restart-Secret header,\n * which must match the RESTART_SECRET environment variable.\n */\n\nimport type { Request, Response } from 'express';\n\nimport { performBackup } from '../database/backupManager.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\n\n/**\n * POST /api/prepare-restart\n *\n * Called by the GitHub Actions deploy workflow before `pm2 reload`.\n * The runner must wait for HTTP 200 before proceeding so all pre-deploy work\n * (currently: DB backup) completes before the process is reloaded.\n */\nasync function handlePrepareRestart(req: Request, res: Response): Promise<void> {\n\tconst secret = process.env['RESTART_SECRET'];\n\tif (!secret) {\n\t\tlogEventsAndPrint(\n\t\t\t'POST /api/prepare-restart called but RESTART_SECRET is not set.',\n\t\t\t'errLog.txt',\n\t\t);\n\t\tres.status(500).send('Endpoint is not configured.');\n\t\treturn;\n\t}\n\n\tif (req.headers['x-restart-secret'] !== secret) {\n\t\tres.status(403).send('Forbidden.');\n\t\treturn;\n\t}\n\n\ttry {\n\t\tawait performBackup();\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(`Pre-deploy DB backup failed: ${message}`, 'errLog.txt');\n\t\tres.status(500).send('Pre-deploy backup failed.');\n\t\treturn;\n\t}\n\n\tres.status(200).send('Ready for restart.');\n}\n\nexport { handlePrepareRestart };\n"
  },
  {
    "path": "src/server/controllers/emailController.ts",
    "content": "// src/server/controllers/emailController.ts\n\n/*\n * This module constructs and dispatches application emails:\n * password resets, account verification, and rating abuse alerts.\n *\n * It also handles the API endpoint for resending verification emails.\n */\n\nimport type { Request, Response } from 'express';\n\nimport mailer from '../utility/mailer.js';\nimport { getAppBaseUrl } from '../utility/urlUtils.js';\nimport { isBlacklisted } from '../database/blacklistManager.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\nimport { getMemberDataByCriteria } from '../database/memberManager.js';\n\n// --- Helper Functions ---\n\nfunction createEmailHtmlWrapper(title: string, contentHtml: string): string {\n\treturn `\n\t\t<div style=\"font-family: Arial, sans-serif; padding: 20px; max-width: 600px; margin: 0 auto; border: 1px solid #999; border-radius: 5px;\">\n\t\t\t<h2 style=\"color: #333;\">${title}</h2>\n\t\t\t${contentHtml}\n\t\t</div>\n\t`;\n}\n\n// --- Email Sending Functions ---\n\nasync function sendPasswordResetEmail(recipientEmail: string, resetUrl: string): Promise<void> {\n\tconst content = `\n\t\t<p style=\"font-size: 16px; color: #555;\">We received a request to reset the password for your account.</p>\n\t\t<p style=\"font-size: 16px; color: #555;\">Please click the button below to set a new password. This link will expire in 1 hour.</p>\n\t\t<a href=\"${resetUrl}\" style=\"font-size: 16px; background-color: #fff; color: black; padding: 10px 20px; text-decoration: none; border: 1px solid black; border-radius: 6px; display: inline-block; margin: 20px 0;\">Reset Password</a>\n\t\t<p style=\"font-size: 14px; color: #666;\">If you did not request a password reset, you can safely ignore this email.</p>\n\t`;\n\n\ttry {\n\t\tconst sent = await mailer.send({\n\t\t\tto: recipientEmail,\n\t\t\tsubject: 'Your Password Reset Request',\n\t\t\thtml: createEmailHtmlWrapper('Password Reset Request', content),\n\t\t});\n\t\tif (sent) {\n\t\t\t// console.log(`Password reset email sent to ${recipientEmail}`);\n\t\t} else {\n\t\t\tconsole.log(`Password Reset Link: ${resetUrl}`);\n\t\t}\n\t} catch (err) {\n\t\tconst errorMessage = err instanceof Error ? err.stack : String(err);\n\t\tlogEventsAndPrint(`Error sending password reset email: ${errorMessage}`, 'errLog.txt');\n\t\tthrow new Error('Unexpected transporter error sending password reset email.');\n\t}\n}\n\n/**\n * Sends an account verification email to the specified member,\n * IF they are not blacklisted.\n * @param user_id - The ID of the user to send the verification email to.\n */\nasync function sendEmailConfirmation(user_id: number): Promise<void> {\n\tconst record = getMemberDataByCriteria(\n\t\t['username', 'email', 'is_verified', 'verification_code'],\n\t\t'user_id',\n\t\tuser_id,\n\t);\n\n\tif (record === undefined) {\n\t\tlogEventsAndPrint(\n\t\t\t`Unable to send email confirmation for non-existent member of id (${user_id})!`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn;\n\t}\n\n\tif (isBlacklisted(record.email)) {\n\t\tlogEventsAndPrint(\n\t\t\t`[BLOCKED] Skipping email confirmation to ${record.email} (Blacklisted)`,\n\t\t\t'blacklistLog.txt',\n\t\t);\n\t\treturn;\n\t}\n\n\t// Check the new 'is_verified' column directly.\n\tif (record.is_verified === 1) {\n\t\t// console.log(\n\t\t// \t`User ${record.username} (ID: ${user_id}) is already verified. Skipping email confirmation.`,\n\t\t// );\n\t\treturn;\n\t}\n\n\t// An unverified user MUST have a verification code.\n\tif (!record.verification_code) {\n\t\tlogEventsAndPrint(\n\t\t\t`User ${record.username} (ID: ${user_id}) is unverified but has no verification code. Cannot send email.`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn;\n\t}\n\n\ttry {\n\t\t// Construct verification URL using the new 'verification_code' column\n\t\tconst baseUrl = getAppBaseUrl();\n\t\tconst verificationUrl = new URL(\n\t\t\t`${baseUrl}/verify/${record.username.toLowerCase()}/${record.verification_code}`,\n\t\t).toString();\n\n\t\tconst content = `\n\t\t\t<p style=\"font-size: 16px; color: #555;\">Thank you, <strong>${record.username}</strong>, for creating an account. Please click the button below to verify your account.</p>\n\t\t\t<p style=\"font-size: 16px; color: #555;\">If this takes you to the login page, then as soon as you log in, your account will be verified.</p>\n\t\t\t<a href=\"${verificationUrl}\" style=\"font-size: 16px; background-color: #fff; color: black; padding: 10px 20px; text-decoration: none; border: 1px solid black; border-radius: 6px; display: inline-block; margin: 20px 0;\">Verify Account</a>\n\t\t\t<p style=\"font-size: 14px; color: #666;\">If this wasn't you, please ignore this email.</p>\n\t\t`;\n\n\t\tconst sent = await mailer.send({\n\t\t\tto: record.email,\n\t\t\tsubject: 'Verify Your Account',\n\t\t\thtml: createEmailHtmlWrapper('Welcome to InfiniteChess.org!', content),\n\t\t});\n\n\t\tif (sent) {\n\t\t\t// console.log(`Verification email sent to member ${record.username} of ID ${user_id}!`);\n\t\t} else {\n\t\t\tconsole.log(`Verification Link: ${verificationUrl}`);\n\t\t}\n\t} catch (e) {\n\t\tconst errorMessage = e instanceof Error ? e.stack : String(e);\n\t\tlogEventsAndPrint(\n\t\t\t`Error during sendEmailConfirmation for user_id (${user_id}): ${errorMessage}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t}\n}\n\n/** API to resend the verification email. */\nfunction requestConfirmEmail(req: Request, res: Response): void {\n\tif (!req.memberInfo?.signedIn) {\n\t\tres.status(401).json({ message: 'You must be signed in to perform this action.' });\n\t\treturn;\n\t}\n\n\t// We know the member url param is defined because this route is only used when it is present.\n\tconst usernameParam = req.params['member']!;\n\tconst { user_id, username } = req.memberInfo;\n\n\tif (username.toLowerCase() !== usernameParam.toLowerCase()) {\n\t\tconst errText = `Member \"${username}\" (ID: ${user_id}) attempted to send verification email for user (${usernameParam})!`;\n\t\tlogEventsAndPrint(errText, 'hackLog.txt');\n\t\tres.status(403).json({ sent: false, message: 'Forbidden' });\n\t\treturn;\n\t}\n\n\t// Send the email (fire-and-forget, no need to await here as we respond to the user immediately)\n\tsendEmailConfirmation(user_id);\n\n\tres.json({ sent: true });\n}\n\n/**\n * API to send an email warning about rating abuse to our own infinite chess email address\n * @param messageSubject - email subject text\n * @param messageText - email body text\n */\nasync function sendRatingAbuseEmail(messageSubject: string, messageText: string): Promise<void> {\n\ttry {\n\t\tconst sent = await mailer.send({\n\t\t\tto: mailer.FROM ?? '',\n\t\t\tsubject: messageSubject,\n\t\t\ttext: messageText,\n\t\t});\n\t\tif (sent) {\n\t\t\t// console.log(`Rating abuse warning email sent successfully to ${mailer.FROM}.`);\n\t\t} else {\n\t\t\tconsole.log(\"Didn't send rating abuse email.\");\n\t\t}\n\t} catch (e) {\n\t\tconst errorMessage = e instanceof Error ? e.stack : String(e);\n\t\tvoid logEventsAndPrint(\n\t\t\t`Error during the sending of rating abuse email with subject \"${messageSubject}\": ${errorMessage}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t}\n}\n\n// --- Exports ---\nexport { sendPasswordResetEmail, sendEmailConfirmation, requestConfirmEmail, sendRatingAbuseEmail };\n"
  },
  {
    "path": "src/server/controllers/loginController.int.test.ts",
    "content": "// src/server/controllers/loginController.int.test.ts\n\nimport { describe, it, expect, beforeEach, beforeAll } from 'vitest';\n\nimport { testRequest } from '../../tests/testRequest.js';\n\nimport { generateAccount } from './createAccountController.js';\nimport { generateTables, clearAllTables } from '../database/databaseTables.js';\n\ndescribe('Login Controller Integration', () => {\n\t// Runs once at the very start of this file\n\tbeforeAll(() => {\n\t\tgenerateTables();\n\t});\n\n\t// Runs before EVERY single 'it' block\n\tbeforeEach(() => {\n\t\tclearAllTables();\n\t});\n\n\tit('should reject login with no body', async () => {\n\t\tconst response = await testRequest().post('/auth').send(); // No body\n\n\t\texpect(response.status).toBe(400);\n\t});\n\n\tit('should reject login with missing username', async () => {\n\t\tconst response = await testRequest().post('/auth').send({ username: 'OnlyUserNoPass' }); // Missing password\n\n\t\texpect(response.status).toBe(400);\n\t});\n\n\tit('should reject login with missing password', async () => {\n\t\tconst response = await testRequest().post('/auth').send({ password: 'OnlyPassNoUser' }); // Missing username\n\n\t\texpect(response.status).toBe(400);\n\t});\n\n\tit('should reject login with non-string username', async () => {\n\t\tconst response = await testRequest()\n\t\t\t.post('/auth')\n\t\t\t.send({ username: 12345, password: 'SomePassword' }); // Non-string username\n\n\t\texpect(response.status).toBe(400);\n\t});\n\n\tit('should reject login with non-string password', async () => {\n\t\tconst response = await testRequest()\n\t\t\t.post('/auth')\n\t\t\t.send({ username: 'SomeUser', password: 67890 }); // Non-string password\n\n\t\texpect(response.status).toBe(400);\n\t});\n\n\tit('should reject login for non-existent user', async () => {\n\t\tconst response = await testRequest()\n\t\t\t.post('/auth')\n\t\t\t.send({ username: 'GhostUser', password: 'password123' });\n\n\t\texpect(response.status).toBe(401);\n\t});\n\n\tit('should reject login with incorrect password', async () => {\n\t\t// 1. Setup\n\t\tawait generateAccount({\n\t\t\tusername: 'RealUser',\n\t\t\temail: 'test@example.com',\n\t\t\tpassword: 'CorrectPassword!',\n\t\t\tautoVerify: true,\n\t\t});\n\n\t\t// 2. Test\n\t\tconst response = await testRequest()\n\t\t\t.post('/auth')\n\t\t\t.send({ username: 'RealUser', password: 'WRONG_PASSWORD' });\n\n\t\texpect(response.status).toBe(401);\n\t});\n\n\tit('should login successfully with correct credentials', async () => {\n\t\t// 1. Setup\n\t\tawait generateAccount({\n\t\t\tusername: 'RealUser',\n\t\t\temail: 'test@example.com',\n\t\t\tpassword: 'CorrectPassword!',\n\t\t\tautoVerify: true,\n\t\t});\n\n\t\t// 2. Test\n\t\tconst response = await testRequest()\n\t\t\t.post('/auth')\n\t\t\t.send({ username: 'RealUser', password: 'CorrectPassword!' });\n\n\t\texpect(response.status).toBe(200);\n\n\t\t// Ensure that the session cookies are set\n\t\tconst cookies = response.headers['set-cookie'] as unknown as string[]; // set-cookie is actually an array\n\n\t\texpect(cookies).toBeDefined();\n\t\texpect(cookies.some((c) => c.startsWith('jwt='))).toBe(true);\n\t\texpect(cookies.some((c) => c.startsWith('memberInfo='))).toBe(true);\n\t});\n});\n"
  },
  {
    "path": "src/server/controllers/loginController.ts",
    "content": "// src/server/controllers/loginController.ts\n\n/**\n * This controller is used when a client logs in.\n *\n * This rate limits a members login attempts,\n * and when they successfully login:\n *\n * Creates a new login session,\n * and updates last_seen and login_count in their profile.\n */\n\nimport type { Request, Response } from 'express';\n\nimport { createNewSession } from './authenticationTokens/sessionManager.js';\nimport { testPasswordForRequest } from './authController.js';\nimport { logEvents, logEventsAndPrint } from '../middleware/logEvents.js';\nimport { getMemberDataByCriteria, updateLoginCountAndLastSeen } from '../database/memberManager.js';\n\n/**\n * Called when the login page submits login form data.\n * Tests their username and password. If correct, it logs\n * them in, generates tokens for them, and updates their member variables.\n * THIS SHOULD ALWAYS send a json response, because the errors we send are displayed on the page.\n */\nasync function handleLogin(req: Request, res: Response): Promise<void> {\n\t// Initial check - if this fails, it sends a response and returns.\n\tif (!(await testPasswordForRequest(req, res))) return;\n\t// Correct password...\n\n\ttry {\n\t\tconst usernameCaseInsensitive = req.body.username; // We already know this property is present on the request\n\n\t\tconst record = getMemberDataByCriteria(\n\t\t\t['user_id', 'username', 'roles'],\n\t\t\t'username',\n\t\t\tusernameCaseInsensitive,\n\t\t);\n\n\t\tif (record === undefined) {\n\t\t\t// This is a critical internal inconsistency.\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`User \"${usernameCaseInsensitive}\" not found by username after a successful password check! This indicates a data integrity issue.`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t\t// Send a generic error to the client, as this is a server-side problem.\n\t\t\tres.status(500).json({\n\t\t\t\tmessage: 'Login failed due to an internal server error. Please try again later.',\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\t// The roles fetched from the database is a stringified json string array, parse it here!\n\t\tconst parsedRoles = record.roles !== null ? JSON.parse(record.roles) : null;\n\n\t\tcreateNewSession(req, res, record.user_id, record.username, parsedRoles);\n\n\t\tres.status(200).json({ message: 'Logged in successfully.' });\n\n\t\t// These operations are \"fire and forget\" in terms of the client response\n\t\tupdateLoginCountAndLastSeen(record.user_id);\n\t\tlogEvents(`Logged in member \"${record.username}\".`, 'loginAttempts.txt');\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t// Log the detailed error for server-side debugging.\n\t\tlogEventsAndPrint(\n\t\t\t`Error during handleLogin for user \"${req.body.username}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\n\t\t// Send a generic error response to the client.\n\t\t// Avoid sending detailed error messages to the client for security reasons.\n\t\t// Check if a response has already been sent to avoid \"Error [ERR_HTTP_HEADERS_SENT]\"\n\t\tif (!res.headersSent) {\n\t\t\tres.status(500).json({\n\t\t\t\tmessage: 'Login failed due to an unexpected error. Please try again.',\n\t\t\t});\n\t\t}\n\t}\n}\n\nexport { handleLogin };\n"
  },
  {
    "path": "src/server/controllers/logoutController.ts",
    "content": "// src/server/controllers/logoutController.ts\n\nimport type { Request, Response } from 'express';\n\nimport { revokeSession } from '../controllers/authenticationTokens/sessionManager.js';\nimport { deleteRefreshToken } from '../database/refreshTokenManager.js';\nimport { closeAllSocketsOfSession } from '../socket/socketManager.js';\nimport { logEvents, logEventsAndPrint } from '../middleware/logEvents.js';\n\n/** Handles member logout by revoking the session and deleting the refresh token. */\nasync function handleLogout(req: Request, res: Response): Promise<void> {\n\t// Delete the refresh token cookie...\n\t// On client, also delete the accessToken\n\n\tconst cookies = req.cookies;\n\tconst refreshToken = cookies['jwt'];\n\tif (typeof refreshToken !== 'string') return res.redirect('/'); // Cookie already deleted. (Already logged out)\n\n\t// Delete their existing session cookies WHETHER OR NOT they\n\t// are signed in, because they may THINK they are...\n\trevokeSession(res);\n\n\tif (!req.memberInfo?.signedIn) {\n\t\t// Existing refresh token cookie was invalid (tampered, expired, manually invalidated, or account deleted)\n\t\tres.redirect('/');\n\t\treturn;\n\t}\n\n\ttry {\n\t\t// Now invalidate the refresh token from the database by deleting it.\n\t\tdeleteRefreshToken(refreshToken);\n\t} catch (e) {\n\t\tconst message = e instanceof Error ? e.message : String(e);\n\t\tlogEventsAndPrint(\n\t\t\t`Critical error when logging out member \"${req.memberInfo.username}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tres.status(500).json({ message: 'Server Error' });\n\t\treturn;\n\t}\n\n\tcloseAllSocketsOfSession(refreshToken, 1008, 'Logged out');\n\n\tres.redirect('/');\n\n\tlogEvents(`Logged out member \"${req.memberInfo.username}\".`, 'loginAttempts.txt');\n}\n\nexport { handleLogout };\n"
  },
  {
    "path": "src/server/controllers/passwordResetController.ts",
    "content": "// src/server/controllers/passwordResetController.ts\n\nimport crypto from 'crypto';\nimport bcrypt from 'bcrypt';\nimport { Request, Response } from 'express';\n\nimport db from '../database/database.js';\nimport { getAppBaseUrl } from '../utility/urlUtils.js';\nimport { isBlacklisted } from '../database/blacklistManager.js';\nimport { getTranslationForReq } from '../utility/translate.js';\nimport { sendPasswordResetEmail } from './emailController.js';\nimport { logEvents, logEventsAndPrint } from '../middleware/logEvents.js';\nimport { deleteAllRefreshTokensForUser } from '../database/refreshTokenManager.js';\nimport { doPasswordFormatChecks, PASSWORD_SALT_ROUNDS } from './createAccountController.js';\n\nconst PASSWORD_RESET_TOKEN_EXPIRY_MILLIS: number = 1000 * 60 * 60; // 1 Hour\n\n/** Route for when a user REQUESTS a password reset email. */\nasync function handleForgotPasswordRequest(req: Request, res: Response): Promise<void> {\n\tconst { email } = req.body;\n\n\tif (!email || typeof email !== 'string') {\n\t\tres.status(400).json({ message: 'Email is required and must be a string.' });\n\t\treturn;\n\t}\n\n\ttry {\n\t\t// 1. Find user by email (case-insensitive)\n\t\tconst member = db.get<{ user_id: number }>(\n\t\t\t'SELECT user_id FROM members WHERE email = ? COLLATE NOCASE',\n\t\t\t[email],\n\t\t);\n\n\t\tif (member) {\n\t\t\t// User exists, proceed with password reset flow\n\t\t\tconst userId: number = member.user_id;\n\n\t\t\t// 2. Invalidate old tokens\n\t\t\tdb.run('DELETE FROM password_reset_tokens WHERE user_id = ?', [userId]);\n\n\t\t\t// 3. Make sure they aren't blacklisted\n\t\t\tif (isBlacklisted(email)) {\n\t\t\t\tlogEventsAndPrint(\n\t\t\t\t\t`User has a blacklisted email ${email} when attempting to request a password reset!`,\n\t\t\t\t\t'blacklistLog.txt',\n\t\t\t\t);\n\t\t\t\tres.status(409).json({\n\t\t\t\t\tmessage: getTranslationForReq('server.javascript.ws-email_blacklisted', req),\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// 4. Generate plain token\n\t\t\tconst plainToken: string = crypto.randomBytes(32).toString('base64url');\n\n\t\t\t// 5. Hash the plain token\n\t\t\tconst hashedTokenForDb: string = await bcrypt.hash(plainToken, PASSWORD_SALT_ROUNDS);\n\n\t\t\t// 6. Set expiration (e.g., ~1 hour from now in milliseconds)\n\t\t\tconst expiresAt: number = Date.now() + PASSWORD_RESET_TOKEN_EXPIRY_MILLIS;\n\n\t\t\t// 7. Store new token in the database\n\t\t\tdb.run(\n\t\t\t\t'INSERT INTO password_reset_tokens (user_id, hashed_token, expires_at) VALUES (?, ?, ?)',\n\t\t\t\t[userId, hashedTokenForDb, expiresAt],\n\t\t\t);\n\n\t\t\t// 8. Construct reset URL using the utility\n\t\t\tconst baseUrl = getAppBaseUrl();\n\t\t\tconst resetUrl = new URL(`${baseUrl}/reset-password/${plainToken}`).toString();\n\n\t\t\t// 9. Log the email send attempt\n\t\t\tlogEvents(\n\t\t\t\t`Sending password reset email to user_id (${userId})...`,\n\t\t\t\t'loginAttempts.txt',\n\t\t\t);\n\n\t\t\t// 10. Send email (must have its own error handling since we're not await'ing an async method!!)\n\t\t\tsendPasswordResetEmail(email, resetUrl).catch((err) => {\n\t\t\t\tconst errorMessage = err instanceof Error ? err.stack : String(err);\n\t\t\t\tlogEventsAndPrint(\n\t\t\t\t\t`Background password reset email send failed for user_id (${userId}), email (${email}): ${errorMessage}`,\n\t\t\t\t\t'errLog.txt',\n\t\t\t\t);\n\t\t\t});\n\t\t} else {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`No member exists with the email (${email}). Not sending password reset email.`,\n\t\t\t\t'loginAttempts.txt',\n\t\t\t);\n\t\t}\n\n\t\t// ALWAYS return a generic success message to prevent email enumeration.\n\t\tres.status(200).json({\n\t\t\tmessage: getTranslationForReq('server.javascript.ws-password-reset-link-sent', req),\n\t\t});\n\t} catch (error) {\n\t\tconst errorMessage: string =\n\t\t\t'Forgot password database error: ' +\n\t\t\t(error instanceof Error ? error.message : String(error));\n\t\tlogEventsAndPrint(errorMessage, 'errLog.txt');\n\t\tres.status(500).json({\n\t\t\tmessage: 'An error occurred while processing your request. Please try again later.',\n\t\t});\n\t\treturn;\n\t}\n}\n\ntype TokenRecord = { user_id: number; hashed_token: string };\n\n/**\n * Route for when a user SENDS the password change API.\n * Changes their password in the database.\n */\nasync function handleResetPassword(req: Request, res: Response): Promise<void> {\n\tconst { token, password } = req.body;\n\n\t// 1. Basic Input Validation\n\tif (!token || !password) {\n\t\tres.status(400).json({ message: 'Token and new password are required.' });\n\t\treturn;\n\t}\n\tif (typeof token !== 'string') {\n\t\tres.status(400).json({ message: 'Token must be a string.' });\n\t\treturn;\n\t}\n\tif (typeof password !== 'string') {\n\t\tres.status(400).json({ message: 'Password must be a string.' });\n\t\treturn;\n\t}\n\t// Password strength rules (e.g., length)\n\tif (!doPasswordFormatChecks(password, req, res)) return;\n\n\ttry {\n\t\t// 2. Find a matching, unexpired token.\n\t\t// Since we stored a HASH, we cannot query by the plain token directly.\n\t\t// We must fetch potential tokens and compare them one by one.\n\t\tconst now = Date.now();\n\t\tconst potentialTokens = db.all<TokenRecord>(\n\t\t\t'SELECT user_id, hashed_token FROM password_reset_tokens WHERE expires_at > ?',\n\t\t\t[now],\n\t\t);\n\n\t\tlet validTokenRecord: TokenRecord | null = null;\n\t\tfor (const record of potentialTokens) {\n\t\t\tconst isMatch = await bcrypt.compare(token, record.hashed_token);\n\t\t\tif (isMatch) {\n\t\t\t\tvalidTokenRecord = record;\n\t\t\t\tbreak; // Found our match, exit the loop\n\t\t\t}\n\t\t}\n\n\t\t// 3. Handle Invalid or Expired Token\n\t\tif (!validTokenRecord) {\n\t\t\tlogEvents(`Invalid or expired password reset token used.`, 'loginAttempts.txt');\n\t\t\tres.status(400).json({\n\t\t\t\tmessage: getTranslationForReq(\n\t\t\t\t\t'server.javascript.ws-password-reset-token-invalid',\n\t\t\t\t\treq,\n\t\t\t\t),\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\t// 4. Hash the New Password\n\t\tconst hashedNewPassword = await bcrypt.hash(password, PASSWORD_SALT_ROUNDS);\n\t\tconst userId = validTokenRecord.user_id;\n\t\tconst usedHashedToken = validTokenRecord.hashed_token; // Store for use in transaction\n\n\t\t// 5. Update the User's Password in the database.\n\t\t// At the same time, invalidate the used token.\n\t\tconst resetTransaction = db.transaction(() => {\n\t\t\t// Step 1: Update the User's Password\n\t\t\tconst updateResult = db.run(\n\t\t\t\t'UPDATE members SET hashed_password = ? WHERE user_id = ?',\n\t\t\t\t[hashedNewPassword, userId],\n\t\t\t);\n\n\t\t\tif (updateResult.changes === 0) {\n\t\t\t\t// If the user doesn't exist, we must throw an error\n\t\t\t\t// to force the transaction to roll back.\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Failed to update password for user_id (${userId}), user may not exist.`,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\t// Step 2: Invalidate/Delete the used token\n\t\t\tdb.run('DELETE FROM password_reset_tokens WHERE hashed_token = ?', [usedHashedToken]);\n\t\t});\n\n\t\t// Execute the transaction. If any part of it throws an error,\n\t\t// the entire transaction is rolled back automatically.\n\t\tresetTransaction();\n\n\t\t// 6. Terminate all of the user's active sessions.\n\t\t// Recommended for security.\n\t\tdeleteAllRefreshTokensForUser(userId);\n\n\t\t// Optional but recommended: Send a confirmation email that the password was changed.\n\n\t\t// 7. Send Success Response\n\t\tres.status(200).json({\n\t\t\tmessage: getTranslationForReq('server.javascript.ws-password-change-success', req),\n\t\t});\n\n\t\t// 8. Log the successful password reset\n\t\tlogEvents(`Password reset successful for user_id (${userId})`, 'loginAttempts.txt');\n\t} catch (error) {\n\t\tconst errorMessage: string =\n\t\t\t'Reset password error: ' + (error instanceof Error ? error.message : String(error));\n\t\tlogEventsAndPrint(errorMessage, 'errLog.txt');\n\t\tres.status(500).json({ message: 'An internal error occurred. Please try again later.' });\n\t}\n}\n\nexport { handleForgotPasswordRequest, handleResetPassword };\n"
  },
  {
    "path": "src/server/controllers/roles.ts",
    "content": "// src/server/controllers/roles.ts\n\n/**\n * This module handles the addition\n * and removal of roles from members.\n */\n\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\nimport { getMemberDataByCriteria, updateMemberColumns } from '../database/memberManager.js';\n\n/**\n * All possible roles, IN ORDER FROM LEAST TO MOST IMPORTANCE!\n * The ordering determines admin's capabilities in the admin console.\n */\nconst validRoles = ['patron', 'admin', 'owner'] as const;\n\n/** A valid role of a user. */\nexport type Role = (typeof validRoles)[number];\n\n/**\n * Adds a specified role to a member's roles list.\n * @param userId - The user ID of the member.\n * @param role - The role to add (e.g., 'owner', 'patron').\n */\nfunction giveRole(userId: number, role: Role): void {\n\t// Fetch the member's current roles from the database\n\tconst memberData = getMemberDataByCriteria(['roles'], 'user_id', userId);\n\tif (!memberData) {\n\t\tlogEventsAndPrint(\n\t\t\t`Cannot give role \"${role}\" to user of ID \"${userId}\" when they don't exist!`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn;\n\t}\n\tconst roles: Role[] = memberData.roles === null ? [] : JSON.parse(memberData.roles); // ['role1','role2', ...]\n\n\t// If the role already exists, return early\n\tif (roles.includes(role)) {\n\t\tlogEventsAndPrint(\n\t\t\t`Role \"${role}\" already exists for member with user ID \"${userId}\".`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn;\n\t}\n\n\t// Add the new role to the roles array\n\troles.push(role);\n\n\ttry {\n\t\t// Save the updated roles back to the database\n\t\tconst result = updateMemberColumns(userId, { roles: JSON.stringify(roles) });\n\n\t\tif (result.changeMade) {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`Added role \"${role}\" to member with ID \"${userId}\".`,\n\t\t\t\t'loginAttempts.txt',\n\t\t\t);\n\t\t} else {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`Failed to add role \"${role}\" to member with ID \"${userId}\".`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t}\n\t} catch (error) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error adding role \"${role}\" to member of ID \"${userId}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t}\n}\n\n/**\n * Returns true if roles1 contains at least one role that is higher in priority than the highest role in roles2.\n *\n * If so, the user with roles1 would be able to perform destructive commands on user with roles2.\n * @param roles1 - List of roles for the first user.\n * @param roles2 - List of roles for the second user.\n */\nfunction areRolesHigherInPriority(roles1: Role[] | null, roles2: Role[] | null): boolean {\n\t// Make sure they are not null\n\tconst r1: Role[] = roles1 || [];\n\tconst r2: Role[] = roles2 || [];\n\n\tlet roles1HighestPriority = -1; // -1 is the same as someone with zero roles\n\tr1.forEach((role) => {\n\t\tconst priorityOfRole = validRoles.indexOf(role);\n\t\tif (priorityOfRole > roles1HighestPriority) roles1HighestPriority = priorityOfRole;\n\t});\n\n\tlet roles2HighestPriority = -1; // -1 is the same as someone with zero roles\n\tr2.forEach((role) => {\n\t\tconst priorityOfRole = validRoles.indexOf(role);\n\t\tif (priorityOfRole > roles2HighestPriority) roles2HighestPriority = priorityOfRole;\n\t});\n\n\treturn roles1HighestPriority > roles2HighestPriority;\n}\n\nexport { giveRole, areRolesHigherInPriority };\n"
  },
  {
    "path": "src/server/controllers/verifyAccountController.ts",
    "content": "// src/server/controllers/verifyAccountController.ts\n\n/**\n * This controller handles verifying accounts, either manually or via an email link.\n */\n\nimport type { Request, Response } from 'express';\n\nimport { getTranslationForReq } from '../utility/translate.js';\nimport { logEvents, logEventsAndPrint } from '../middleware/logEvents.js';\nimport { AddVerificationToAllSocketsOfMember } from '../socket/socketManager.js';\nimport {\n\tgetMemberDataByCriteria,\n\tMemberRecord,\n\tupdateMemberColumns,\n} from '../database/memberManager.js';\n\n// Functions -------------------------------------------------------------------------\n\n/**\n * Route that verifies an account when the user clicks the link in the email.\n * If they are not signed in, this forwards them to the login page.\n */\nexport async function verifyAccount(req: Request, res: Response): Promise<void> {\n\tif (!req.memberInfo) {\n\t\tlogEventsAndPrint('req.memberInfo must be defined for verify account route!', 'errLog.txt');\n\t\tres.status(500).redirect('/500');\n\t\treturn;\n\t}\n\n\tconst claimedUsername = req.params['member']!;\n\tconst claimedCode = req.params['code']!;\n\n\tconst record = getMemberDataByCriteria(\n\t\t['user_id', 'username', 'is_verified', 'verification_code'],\n\t\t'username',\n\t\tclaimedUsername,\n\t);\n\n\tif (record === undefined) {\n\t\t// User not found. Can happen if they click the link after their account was auto-deleted for never verifying.\n\t\tlogEvents(\n\t\t\t`Invalid account verification link! User \"${claimedUsername}\" doesn't exist.`,\n\t\t\t'loginAttempts.txt',\n\t\t);\n\t\tres.status(400).redirect(`/400`); // Bad request\n\t\treturn;\n\t}\n\n\tif (!req.memberInfo.signedIn) {\n\t\t// Not logged in\n\t\tlogEvents(\n\t\t\t`Forwarding user '${record.username}' to login before they can verify!`,\n\t\t\t'loginAttempts.txt',\n\t\t);\n\t\t// Redirect them to the login page, BUT add a query parameter with the original verification url they were visiting!\n\t\tconst redirectTo = encodeURIComponent(req.originalUrl);\n\t\tres.redirect(`/login?redirectTo=${redirectTo}`);\n\t\treturn;\n\t}\n\n\tif (req.memberInfo.username !== record.username) {\n\t\t// Forbid them if they are logged in and NOT who they're wanting to verify!\n\t\tlogEventsAndPrint(\n\t\t\t`Member \"${req.memberInfo.username}\" of ID \"${req.memberInfo.user_id}\" attempted to verify member \"${record.username}\"!`,\n\t\t\t'loginAttempts.txt',\n\t\t);\n\t\tres.status(403).send(\n\t\t\tgetTranslationForReq('server.javascript.ws-forbidden_wrong_account', req),\n\t\t);\n\t\treturn;\n\t}\n\n\t// Ignore if already verified.\n\tif (record.is_verified === 1) {\n\t\tlogEvents(\n\t\t\t`Member \"${record.username}\" of ID ${record.user_id} is already verified!`,\n\t\t\t'loginAttempts.txt',\n\t\t);\n\t\tres.redirect(`/member/${record.username.toLowerCase()}`);\n\t\treturn;\n\t}\n\n\t// Check if the verification code matches!\n\tif (claimedCode !== record.verification_code) {\n\t\tlogEventsAndPrint(\n\t\t\t`Invalid account verification link! User \"${record.username}\" had an incorrect code`,\n\t\t\t'loginAttempts.txt',\n\t\t);\n\t\tres.status(400).redirect(`/400`);\n\t\treturn;\n\t}\n\n\t// VERIFY THEM..\n\tconst result = _executeVerificationUpdate(record.user_id, record.username);\n\n\tif (result.success) {\n\t\tlogEvents(\n\t\t\t`Verified member ${record.username}'s account! ID ${record.user_id}`,\n\t\t\t'loginAttempts.txt',\n\t\t);\n\t\tres.redirect(`/member/${record.username.toLowerCase()}`);\n\t} else {\n\t\tlogEventsAndPrint(\n\t\t\t`Verification failed for \"${claimedUsername}\" due to: ${result.reason}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tres.status(500).redirect(`/member/${record.username.toLowerCase()}`);\n\t}\n}\n\n/**\n * Manually verifies a user by their email. DOES NOT CHECK PERMISSIONS.\n * @param email The email of the account to verify.\n * @returns A success or failure object.\n */\nexport function manuallyVerifyUser(\n\temail: string,\n): { success: true; username: string } | { success: false; reason: string } {\n\tconst record = getMemberDataByCriteria(['user_id', 'username', 'is_verified'], 'email', email);\n\n\tif (record === undefined) {\n\t\treturn { success: false, reason: `User with email \"${email}\" doesn't exist.` };\n\t}\n\n\tif (record.is_verified === 1) {\n\t\treturn { success: false, reason: `User with email \"${email}\" is already verified.` };\n\t}\n\n\t// VERIFY THEM..\n\tconst result = _executeVerificationUpdate(record.user_id, record.username);\n\n\tif (result.success) {\n\t\tlogEvents(\n\t\t\t`Manually verified account of user with email \"${email}\"! ID ${record.user_id}`,\n\t\t\t'loginAttempts.txt',\n\t\t);\n\t\treturn { success: true, username: record.username };\n\t} else {\n\t\treturn { success: false, reason: result.reason };\n\t}\n}\n\n/**\n * Core logic to update the database to mark a user as verified.\n * @param user_id The ID of the user to verify.\n * @param username The username of the user to verify (for logging).\n * @returns A success or failure object.\n */\nfunction _executeVerificationUpdate(\n\tuser_id: number,\n\tusername: string,\n): { success: true } | { success: false; reason: string } {\n\tAddVerificationToAllSocketsOfMember(user_id);\n\n\tconst changes: Partial<MemberRecord> = {\n\t\tis_verified: 1,\n\t\tverification_code: null,\n\t\t// Set to 0 so they will see the \"Thank you\" message next time they visit their profile\n\t\tis_verification_notified: 0,\n\t};\n\n\ttry {\n\t\tconst result = updateMemberColumns(user_id, changes);\n\n\t\tif (!result.changeMade) {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`Failed to verify member \"${username}\" of ID \"${user_id}\": No change made. Do they exist?`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t\treturn { success: false, reason: 'Failed to update user verification.' };\n\t\t}\n\n\t\treturn { success: true };\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error verifying member \"${username}\" of ID \"${user_id}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn { success: false, reason: 'Server error during user verification.' };\n\t}\n}\n"
  },
  {
    "path": "src/server/database/backupManager.ts",
    "content": "// src/server/database/backupManager.ts\n\n/**\n * This module handles automated SQLite database backups.\n *\n * It uses SQLite's Online Backup API (via better-sqlite3's db.backup())\n * to produce a single consistent .db snapshot while the database is live.\n */\n\nimport fs from 'fs';\nimport path from 'path';\nimport { format } from 'date-fns';\nimport { fileURLToPath } from 'url';\n\nimport db from './database.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\nconst BACKUPS_DIR = path.join(__dirname, '../../../backups');\nconst MAX_BACKUP_AGE_MS = 1000 * 60 * 60 * 24 * 30; // 30 days\nconst BACKUP_INTERVAL_MS = 1000 * 60 * 60 * 24; // 24 hours\n\n/** The in-flight backup promise, or null if no backup is currently running. */\nlet activeBackup: Promise<void> | null = null;\n\n// Functions -------------------------------------------------------------------------\n\n/** Schedules a database backup to run once every 24 hours. */\nfunction startDailyBackups(): void {\n\tsetInterval(async () => {\n\t\ttry {\n\t\t\tawait performBackup();\n\t\t} catch (error: unknown) {\n\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\tlogEventsAndPrint(`Daily database backup failed: ${message}`, 'errLog.txt');\n\t\t}\n\t}, BACKUP_INTERVAL_MS);\n}\n\n/**\n * Creates a timestamped backup of the database in the `backups/` directory,\n * then purges any backups older than 30 days.\n * If a backup is already in progress, returns the same promise so callers join it.\n * @throws If the SQLite backup or directory creation fails.\n */\nfunction performBackup(): Promise<void> {\n\tif (activeBackup !== null) return activeBackup;\n\tactiveBackup = doBackup().finally(() => {\n\t\tactiveBackup = null;\n\t});\n\treturn activeBackup;\n}\n\n/**\n * The actual backup implementation.\n * @throws If the SQLite backup or directory creation fails.\n */\nasync function doBackup(): Promise<void> {\n\tif (process.env['NODE_ENV'] === 'test') return; // In-memory DB — nothing to back up.\n\n\tfs.mkdirSync(BACKUPS_DIR, { recursive: true });\n\n\tconst dateFormatted = format(new Date(), 'yyyy-MM-dd_HH-mm-ss');\n\tconst destPath = path.join(BACKUPS_DIR, `database-${dateFormatted}.db`);\n\n\tconst start = Date.now();\n\tawait db.backup(destPath);\n\tconst elapsed = Date.now() - start;\n\n\tconsole.log(`Database backup created: ${path.basename(destPath)} (${elapsed}ms)`);\n\n\tpurgeOldBackups();\n}\n\n/** Deletes backup files in `backups/` that are older than 30 days. */\nfunction purgeOldBackups(): void {\n\ttry {\n\t\tconst now = Date.now();\n\t\tfor (const file of fs.readdirSync(BACKUPS_DIR)) {\n\t\t\tif (!file.endsWith('.db')) continue;\n\t\t\tconst filePath = path.join(BACKUPS_DIR, file);\n\t\t\tconst stat = fs.statSync(filePath);\n\t\t\tif (now - stat.mtimeMs > MAX_BACKUP_AGE_MS) {\n\t\t\t\tfs.unlinkSync(filePath);\n\t\t\t}\n\t\t}\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tvoid logEventsAndPrint(`Error purging old db backups: ${message}`, 'errLog.txt');\n\t}\n}\n\nexport { startDailyBackups, performBackup };\n"
  },
  {
    "path": "src/server/database/blacklistManager.ts",
    "content": "// src/server/database/blacklistManager.ts\n\nimport db from './database.js';\nimport { logEvents, logEventsAndPrint } from '../middleware/logEvents.js';\n\n/** Adds an email to the blacklist, if it isn't already. */\nexport function addToBlacklist(email: string, reason: string): void {\n\ttry {\n\t\t// Uses INSERT OR IGNORE so it doesn't crash if the email is already blacklisted.\n\t\tdb.run(`INSERT OR IGNORE INTO email_blacklist (email, reason) VALUES (?, ?)`, [\n\t\t\temail,\n\t\t\treason,\n\t\t]);\n\t\tlogEvents(`Added ${email} to blacklist for reason: ${reason}`, 'blacklistLog.txt');\n\t} catch (err) {\n\t\tconst msg = err instanceof Error ? err.message : String(err);\n\t\tlogEventsAndPrint(`Database error when blacklisting email ${email}: ${msg}`, 'errLog.txt');\n\t}\n}\n\n/** Removes an email from the blacklist, if it exists. */\nexport function removeFromBlacklist(email: string): void {\n\ttry {\n\t\t// Won't error if the email doesn't exist.\n\t\tdb.run(`DELETE FROM email_blacklist WHERE email = ?`, [email]);\n\t\tlogEvents(`Removed ${email} from blacklist`, 'blacklistLog.txt');\n\t} catch (err) {\n\t\tconst msg = err instanceof Error ? err.message : String(err);\n\t\tlogEventsAndPrint(\n\t\t\t`Database error when removing email ${email} from blacklist: ${msg}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t}\n}\n\n/**\n * Checks if an email is in the blacklist.\n * Returns true if blacklisted, false otherwise.\n */\nexport function isBlacklisted(email: string): boolean {\n\ttry {\n\t\t// We select '1' just to see if a row exists.\n\t\t// db.get returns the row object (truthy) or undefined (falsy).\n\t\tconst result = db.get<{ '1': number }>(`SELECT 1 FROM email_blacklist WHERE email = ?`, [\n\t\t\temail,\n\t\t]);\n\t\treturn !!result;\n\t} catch (err: unknown) {\n\t\tconst msg = err instanceof Error ? err.message : String(err);\n\t\tlogEventsAndPrint(\n\t\t\t`Database error when checking blacklist for email ${email}: ${msg}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\t// Fail safe: If DB errors, assume NOT blacklisted so we don't block legitimate users\n\t\t// (or return true if we want to be ultra-safe/paranoid)\n\t\treturn false;\n\t}\n}\n"
  },
  {
    "path": "src/server/database/cleanupTasks.ts",
    "content": "// src/server/database/cleanupTasks.ts\n\n/**\n * This script contains methods for periodically\n * cleaning up each table in the database of stale data.\n */\n\nimport timeutil from '../../shared/util/timeutil.js';\n\nimport db from './database.js';\nimport { deleteAccount } from '../controllers/deleteAccountController.js';\nimport { logEvents, logEventsAndPrint } from '../middleware/logEvents.js';\nimport { refreshTokenGracePeriodMillis } from '../controllers/authenticationTokens/tokenSigner.js';\n\n/** The maximum time an account is allowed to remain unverified before the server will delete it from Database. */\nconst maxExistenceTimeForUnverifiedAccountMillis = 1000 * 60 * 60 * 24 * 3; // 3 days\n// const maxExistenceTimeForUnverifiedAccountMillis = 1000 * 40; // 30 seconds\n\nconst CLEANUP_INTERVAL_MS = 1000 * 60 * 60 * 24; // 24 hours\n// const CLEANUP_INTERVAL_MS = 1000 * 20; // 20 seconds for dev testing\n\nfunction startPeriodicDatabaseCleanupTasks(): void {\n\tperformCleanupTasks(); // Run immediately to clean up now.\n\tsetInterval(() => performCleanupTasks(), CLEANUP_INTERVAL_MS);\n}\n\nfunction performCleanupTasks(): void {\n\tcheckDatabaseIntegrity();\n\tdeleteExpiredPasswordResetTokens();\n\tcleanUpExpiredRefreshTokens();\n\tremoveOldUnverifiedMembers();\n}\n\n// ========================================================\n\n/** Checks the integrity of the SQLite database and logs it to the error log if the check fails. */\nfunction checkDatabaseIntegrity(): void {\n\ttry {\n\t\tconst result = db.get<{ integrity_check: string }>('PRAGMA integrity_check;');\n\n\t\tif (result?.integrity_check !== 'ok')\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`Database integrity check failed: ${result?.integrity_check} !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t// else console.log('Database integrity check passed.');\n\t} catch (error: unknown) {\n\t\tconst errorMessage = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error performing database integrity check: ${errorMessage} !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`,\n\t\t\t'errLog.txt',\n\t\t);\n\t}\n}\n\n/** Periodically deletes expired password reset tokens from the database. */\nfunction deleteExpiredPasswordResetTokens(): void {\n\t// console.log('Running cleanup of expired password reset tokens.');\n\ttry {\n\t\tconst now = Date.now();\n\n\t\tconst result = db.run('DELETE FROM password_reset_tokens WHERE expires_at < ?', [now]);\n\n\t\tif (result.changes > 0) {\n\t\t\tconsole.log(`Cleanup: Deleted ${result.changes} expired password reset tokens.`);\n\t\t}\n\t} catch (error) {\n\t\tconst errorMessage =\n\t\t\t'Failed to delete expired password reset tokens: ' +\n\t\t\t(error instanceof Error ? error.message : String(error));\n\t\tlogEventsAndPrint(errorMessage, 'errLog.txt');\n\t}\n}\n\n/**\n * Deletes invalid refresh tokens:\n * 1. Tokens that have naturally expired.\n * 2. Tokens that were consumed (replaced) more than a short grace period ago.\n */\nfunction cleanUpExpiredRefreshTokens(): void {\n\ttry {\n\t\tconst now = Date.now();\n\t\tconst consumptionThreshold = now - refreshTokenGracePeriodMillis;\n\n\t\tconst query = `\n            DELETE FROM refresh_tokens \n            WHERE expires_at < ?\n\t\t\t   OR (consumed_at IS NOT NULL AND consumed_at < ?)\n        `;\n\n\t\tconst result = db.run(query, [now, consumptionThreshold]);\n\n\t\tif (result.changes > 0) {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`Cleanup: Deleted ${result.changes} expired/consumed refresh tokens.`,\n\t\t\t\t'tokenCleanupLog.txt',\n\t\t\t);\n\t\t}\n\t} catch (error) {\n\t\tconst errorMessage =\n\t\t\t'Failed to delete expired refresh tokens: ' +\n\t\t\t(error instanceof Error ? error.message : String(error));\n\t\tlogEventsAndPrint(errorMessage, 'errLog.txt');\n\t}\n}\n\n/**\n * Removes unverified members who have not verified their account for more than 3 days.\n *\n * FUTURE: If the user has zero game records in the database, we could skip adding\n * their user_id to the deleted_members table, allowing us to reuse that id.\n */\nfunction removeOldUnverifiedMembers(): void {\n\t// console.log(\"Checking for old unverified accounts to remove.\");\n\ttry {\n\t\t// Calculate the cutoff time.\n\t\tconst cutoffTimestamp = Date.now() - maxExistenceTimeForUnverifiedAccountMillis;\n\t\tconst cutoffDateString = timeutil.timestampToSqlite(cutoffTimestamp);\n\n\t\tconst membersToDelete = db.all<{ user_id: number }>(\n\t\t\t`\n\t\t\tSELECT user_id FROM members \n\t\t\tWHERE is_verified = 0 \n\t\t\t  AND joined < ?\n\t\t`,\n\t\t\t[cutoffDateString],\n\t\t);\n\n\t\tif (membersToDelete.length === 0) return; // Nothing to do.\n\n\t\tconst reason_deleted = 'unverified';\n\n\t\t// Iterate through the IDs and delete each account.\n\t\tfor (const member of membersToDelete) {\n\t\t\ttry {\n\t\t\t\tdeleteAccount(member.user_id, reason_deleted);\n\t\t\t\tlogEvents(\n\t\t\t\t\t`Removed old unverified account with ID: ${member.user_id}`,\n\t\t\t\t\t'deletedAccounts.txt',\n\t\t\t\t);\n\t\t\t} catch (error: unknown) {\n\t\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\t\tlogEventsAndPrint(\n\t\t\t\t\t`FAILED to remove old unverified account with ID (${member.user_id}): ${message}`,\n\t\t\t\t\t'errLog.txt',\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tconsole.log(`Cleanup: Removed ${membersToDelete.length} unverified account(s).`);\n\t} catch (error: unknown) {\n\t\tconst errorMessage = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(`Error removing old unverified accounts: ${errorMessage}`, 'errLog.txt');\n\t}\n}\n\n// =========================================================\n\nexport { startPeriodicDatabaseCleanupTasks };\n"
  },
  {
    "path": "src/server/database/database.ts",
    "content": "// src/server/database/database.ts\n\n/*\n * This module provides utility functions for managing SQLite database operations\n * using the `better-sqlite3` library.\n *\n * It supports executing SQL queries, retrieving  results (single or multiple rows),\n * caching prepared statements for performance,  and handling database transactions.\n */\n\nimport path from 'path';\nimport Database from 'better-sqlite3';\nimport { fileURLToPath } from 'url';\n\n// Get the current file path and derive the directory (ESM doesn't support __dirname)\nconst __filename: string = fileURLToPath(import.meta.url);\nconst __dirname: string = path.dirname(__filename);\n\n// Create or connect to the SQLite database file\nconst dbLocation: string =\n\tprocess.env['NODE_ENV'] === 'test'\n\t\t? ':memory:' // For integration tests, use in-memory database\n\t\t: path.join(__dirname, '../../../', 'database.db'); // Normal database file\nconst db = new Database(dbLocation);\n// const db = new Database(dbLocation, { verbose: console.log }); // Optional for logging queries\n\n// Enable WAL (Write-Ahead Logging) mode for better concurrency and crash safety.\n// Writers no longer block readers, and the main database file is never modified mid-write.\ndb.pragma('journal_mode = WAL');\n// With WAL, NORMAL synchronous is safe and faster than the default FULL.\n// WAL provides its own durability guarantees that make FULL redundant.\ndb.pragma('synchronous = NORMAL');\n\n// Variables ----------------------------------------------------------------------------------------------\n\n// Prepared statements cache\nconst stmtCache: Record<string, Database.Statement> = {};\n\n// Query Calls --------------------------------------------------------------------------------------------\n\n// Utility function to retrieve or prepare statements\nfunction prepareStatement(query: string): Database.Statement {\n\tif (!stmtCache[query]) {\n\t\t// console.log(`Added statement to stmtCache: \"${query}\"`);\n\t\tstmtCache[query] = db.prepare(query);\n\t}\n\treturn stmtCache[query];\n}\n\ntype SupportedColumnTypes = string | number | boolean | null;\n\n/**\n * Executes a given SQL query with optional parameters and returns the result.\n * @param {string} query - The SQL query to be executed.\n * @param {Array} [params=[]] - An array of parameters to bind to the query.\n * @returns {object} - The result of the query execution.\n */\nfunction run(query: string, params: SupportedColumnTypes[] = []): Database.RunResult {\n\tconst stmt = prepareStatement(query);\n\treturn stmt.run(...params);\n}\n\n/**\n * Retrieves a single row from the database for a given SQL query.\n * @param query - The SQL query to be executed.\n * @param [params=[]] - An array of parameters to bind to the query.\n * @returns - The row object if found, otherwise undefined.\n */\nfunction get<T>(query: string, params: SupportedColumnTypes[] = []): T | undefined {\n\tconst stmt = prepareStatement(query);\n\treturn stmt.get(...params) as T | undefined;\n}\n\n/**\n * Retrieves all rows from the database for a given SQL query.\n * @param query - The SQL query to be executed.\n * @param [params=[]] - An array of parameters to bind to the query.\n * @returns - An array of row objects.\n */\nfunction all<T>(query: string, params: SupportedColumnTypes[] = []): T[] {\n\tconst stmt = prepareStatement(query);\n\treturn stmt.all(...params) as T[];\n}\n\n/** Closes the database connection. */\nfunction close(): void {\n\tdb.close();\n\t// console.log('Closed database.');\n}\n\n/**\n * Creates a consistent point-in-time backup of the database to the given file path\n * using SQLite's Online Backup API. Safe to call while the database is open and being written to.\n * @param destPath - Absolute path for the destination backup file.\n */\nasync function backup(destPath: string): Promise<void> {\n\tawait db.backup(destPath);\n}\n\n/** Checks if a column exists in a table. */\nfunction columnExists(tableName: string, columnName: string): boolean {\n\ttry {\n\t\t// PRAGMA queries are special and should not use the statement cache.\n\t\t// We access the raw db instance's prepare method directly.\n\t\tconst result = db\n\t\t\t.prepare(`SELECT 1 FROM pragma_table_info(?) WHERE name = ?`)\n\t\t\t.get(tableName, columnName);\n\t\treturn !!result;\n\t} catch (error) {\n\t\tconsole.error(`Error checking if column ${columnName} exists in ${tableName}:`, error);\n\t\treturn false;\n\t}\n}\n\n/**\n * Creates a transaction function that wraps the given callback in a database transaction.\n * The callback will be executed atomically - either all operations succeed or all are rolled back.\n *\n * @template Args - The argument types for the transaction function\n * @template Return - The return type of the transaction function\n * @param callback - The function to execute within the transaction context\n * @returns A transaction function that executes the callback atomically\n *\n * @example\n * ```typescript\n * const transferFunds = transaction((fromId: number, toId: number, amount: number) => {\n *   run('UPDATE accounts SET balance = balance - ? WHERE id = ?', [amount, fromId]);\n *   run('UPDATE accounts SET balance = balance + ? WHERE id = ?', [amount, toId]);\n * });\n *\n * // Execute the transaction\n * transferFunds(1, 2, 100);\n * ```\n */\nfunction transaction<Args extends unknown[], Return>(\n\tcallback: (..._args: Args) => Return,\n): (..._args: Args) => Return {\n\treturn db.transaction(callback);\n}\n\nexport default {\n\trun,\n\tget,\n\tall,\n\tclose,\n\tbackup,\n\tcolumnExists,\n\ttransaction,\n};\n"
  },
  {
    "path": "src/server/database/databaseTables.ts",
    "content": "// src/server/database/databaseTables.ts\n\n/**\n * This script creates our database tables if they aren't already present.\n */\n\nimport db from './database.js';\nimport { startDailyBackups } from './backupManager.js';\nimport { startPeriodicDatabaseCleanupTasks } from './cleanupTasks.js';\nimport { startPeriodicLeaderboardRatingDeviationUpdate } from './leaderboardsManager.js';\n\n// Variables -----------------------------------------------------------------------------------\n\nconst user_id_upper_cap: number = 14_776_336; // 62**4: Limit of unique user id with 4-digit base-62 user ids!\nconst game_id_upper_cap: number = 14_776_336; // 62**4: Limit of unique game id with 4-digit base-62 game ids!\n\n/** All unique columns of the members table. Each of these would be valid to search for to find a single member. */\nconst uniqueMemberKeys: string[] = ['user_id', 'username', 'email'];\n\n/** All columns of the members table. Each of these would be valid to retrieve from any member. */\nconst allMemberColumns: string[] = [\n\t'user_id',\n\t'username',\n\t'username_history',\n\t'email',\n\t'hashed_password',\n\t'roles',\n\t'joined',\n\t'last_seen',\n\t'preferences',\n\t'login_count',\n\t'checkmates_beaten',\n\t'is_verified',\n\t'verification_code',\n\t'is_verification_notified',\n\t'last_read_news_date',\n];\n\n/** All columns of the player_stats table. Each of these would be valid to retrieve from any member. */\nconst _allPlayerStatsColumns: string[] = [\n\t'user_id',\n\t'moves_played',\n\t'game_count',\n\t'game_count_rated',\n\t'game_count_casual',\n\t'game_count_public',\n\t'game_count_private',\n\t'game_count_wins',\n\t'game_count_losses',\n\t'game_count_draws',\n\t'game_count_aborted',\n\t'game_count_wins_rated',\n\t'game_count_losses_rated',\n\t'game_count_draws_rated',\n\t'game_count_wins_casual',\n\t'game_count_losses_casual',\n\t'game_count_draws_casual',\n];\n\n/** All columns of the player_stats table. Each of these would be valid to retrieve from any member. */\nconst allPlayerGamesColumns: string[] = [\n\t'user_id',\n\t'game_id',\n\t'player_number',\n\t'score',\n\t'clock_at_end_millis',\n\t'elo_at_game',\n\t'elo_change_from_game',\n];\n\n/** All columns of the games table. Each of these would be valid to retrieve from any game. */\nconst allGamesColumns: string[] = [\n\t'game_id',\n\t'date',\n\t'base_time_seconds',\n\t'increment_seconds',\n\t'variant',\n\t'rated',\n\t'leaderboard_id',\n\t'private',\n\t'result',\n\t'termination',\n\t'move_count',\n\t'time_duration_millis',\n\t'icn',\n];\n\n/** All columns of the rating_abuse table. Each of these would be valid to retrieve from any member and/or leaderboard. */\nconst allRatingAbuseColumns: string[] = [\n\t'user_id',\n\t'leaderboard_id',\n\t'game_count_since_last_check',\n\t'last_alerted_at',\n];\n\n// Functions -----------------------------------------------------------------------------------\n\n/** Creates the tables in our database if they do not exist. */\nfunction generateTables(): void {\n\t// Members table\n\tdb.run(`\n\t\tCREATE TABLE IF NOT EXISTS members (\n\t\t\tuser_id INTEGER PRIMARY KEY,\n\t\t\tusername TEXT UNIQUE NOT NULL COLLATE NOCASE,\n\t\t\temail TEXT UNIQUE NOT NULL,\n\t\t\thashed_password TEXT NOT NULL,\n\t\t\troles TEXT,\n\t\t\tjoined TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\t\t\tlast_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\t\t\tlogin_count INTEGER NOT NULL DEFAULT 0,\n\t\t\tis_verified INTEGER NOT NULL DEFAULT 0,\n\t\t\tverification_code TEXT,\n\t\t\tis_verification_notified INTEGER NOT NULL DEFAULT 0,\n\t\t\tpreferences TEXT,\n\t\t\tusername_history TEXT,\n\t\t\tcheckmates_beaten TEXT NOT NULL DEFAULT '',\n\t\t\tlast_read_news_date TEXT\n\t\t);\n\t`);\n\n\t// Deleted Members table\n\tdb.run(`\n\t\tCREATE TABLE IF NOT EXISTS deleted_members (\n\t\t\tuser_id INTEGER PRIMARY KEY,             \n\t\t\treason_deleted TEXT NOT NULL -- \"unverified\" / \"user request\" / \"security\" / \"rating abuse\"\n\t\t);\n\t`);\n\n\t// Leaderboards table\n\tdb.run(`\n\t\tCREATE TABLE IF NOT EXISTS leaderboards (\n        \tuser_id INTEGER NOT NULL REFERENCES members(user_id) ON DELETE CASCADE,\n   \t\t\tleaderboard_id INTEGER NOT NULL, -- Each leaderboard's id and variants are declared in the code\n\t\t\telo REAL NOT NULL,\n\t\t\trating_deviation REAL NOT NULL,\n\t\t\t-- Add other Glicko fields if needed (volatility)\n\t\t\trd_last_update_date TIMESTAMP,\n\t\t\tPRIMARY KEY (user_id, leaderboard_id) -- Composite key essential\n\t\t);\n\t`);\n\n\t// Indexes for leaderboards table\n\n\t// To quickly get all leaderboards for a specific user\n\tdb.run(`CREATE INDEX IF NOT EXISTS idx_leaderboards_user ON leaderboards (user_id);`);\n\t// To quickly get rankings for a specific leaderboard (ESSENTIAL)\n\tdb.run(\n\t\t`CREATE INDEX IF NOT EXISTS idx_leaderboards_leaderboard_elo ON leaderboards (leaderboard_id, elo DESC);`,\n\t);\n\n\t// Games table\n\tdb.run(`\n\t\tCREATE TABLE IF NOT EXISTS games (\n\t\t\tgame_id INTEGER PRIMARY KEY,\n\t\t\tdate TIMESTAMP NOT NULL,\n\t\t\tbase_time_seconds INTEGER, -- null if untimed\n\t\t\tincrement_seconds INTEGER, -- null if untimed\n\t\t\tvariant TEXT NOT NULL,\n\t\t\trated BOOLEAN NOT NULL CHECK (rated IN (0, 1)), -- Ensures only 0 or 1\n\t\t\tleaderboard_id INTEGER, -- Specified only if the variant belongs to a leaderboard, ignoring whether the game was rated\n\t\t\tprivate BOOLEAN NOT NULL CHECK (private IN (0, 1)), -- Ensures only 0 or 1\n\t\t\tresult TEXT NOT NULL,\n\t\t\ttermination TEXT NOT NULL,\n\t\t\tmove_count INTEGER NOT NULL,\n\t\t\ttime_duration_millis INTEGER, -- Number of milliseconds that the game lasted in total on the server. Null if info is missing.\n\t\t\ticn TEXT NOT NULL -- Also includes clock timestamps after each move\n\n\t\t\t-- Add a CHECK constraint to ensure consistency:\n\t\t\t-- EITHER both are NULL (untimed) OR both are NOT NULL and >= 0 (timed)\n\t\t\tCHECK (\n\t\t\t\t(base_time_seconds IS NULL AND increment_seconds IS NULL)\n\t\t\t\tOR\n\t\t\t\t(base_time_seconds > 0 AND increment_seconds >= 0)\n\t\t\t)\n\t\t);\n\t`);\n\n\t// Create an index on the date column of the games table for faster queries\n\tdb.run(`CREATE INDEX IF NOT EXISTS idx_games_date ON games (date DESC);`);\n\n\t// Player Games Table\n\tdb.run(`\n\t\tCREATE TABLE IF NOT EXISTS player_games (\n\t\t\tuser_id INTEGER NOT NULL, -- Account deletion does not delete rows in this table\n\t\t\tgame_id INTEGER NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,\n\t\t\tplayer_number INTEGER NOT NULL, -- 1 => White  2 => Black\n\t\t\tscore REAL, -- 1 => Win   0.5 => Draw   0 => Loss   NULL => Aborted\n\t\t\tclock_at_end_millis INTEGER, -- Number of milliseconds that player still has left on his clock when the game ended. Null if game has no clock or info is missing.\n\t\t\telo_at_game REAL, -- Specified if they have a rating for the leaderboard, ignoring whether the game was rated\n\t\t\telo_change_from_game REAL, -- Specified only if the game was rated\n\t\t\tPRIMARY KEY (user_id, game_id) -- Ensures unique link\n\t\t);\n\t`);\n\n\t// Create an index for efficiently finding players in a specific game\n\tdb.run(`CREATE INDEX IF NOT EXISTS idx_player_games_game ON player_games (game_id);`);\n\n\t// Player Stats table\n\tdb.run(`\n\t\tCREATE TABLE IF NOT EXISTS player_stats (\n\t\t\tuser_id INTEGER PRIMARY KEY REFERENCES members(user_id) ON DELETE CASCADE,\n\t\t\tmoves_played INTEGER NOT NULL DEFAULT 0,\n\t\t\tgame_count INTEGER NOT NULL DEFAULT 0,\n\t\t\tgame_count_rated INTEGER NOT NULL DEFAULT 0,\n\t\t\tgame_count_casual INTEGER NOT NULL DEFAULT 0,\n\t\t\tgame_count_public INTEGER NOT NULL DEFAULT 0,\n\t\t\tgame_count_private INTEGER NOT NULL DEFAULT 0,\n\t\t\tgame_count_wins INTEGER NOT NULL DEFAULT 0,\n\t\t\tgame_count_losses INTEGER NOT NULL DEFAULT 0,\n\t\t\tgame_count_draws INTEGER NOT NULL DEFAULT 0,\n\t\t\tgame_count_aborted INTEGER NOT NULL DEFAULT 0,\n\t\t\tgame_count_wins_rated INTEGER NOT NULL DEFAULT 0,\n\t\t\tgame_count_losses_rated INTEGER NOT NULL DEFAULT 0,\n\t\t\tgame_count_draws_rated INTEGER NOT NULL DEFAULT 0,\n\t\t\tgame_count_wins_casual INTEGER NOT NULL DEFAULT 0,\n\t\t\tgame_count_losses_casual INTEGER NOT NULL DEFAULT 0,\n\t\t\tgame_count_draws_casual INTEGER NOT NULL DEFAULT 0\n\t\t);\n\t`);\n\n\t// Rating Abuse table\n\tdb.run(`\n\t\tCREATE TABLE IF NOT EXISTS rating_abuse (\n\t\t\tuser_id INTEGER NOT NULL,\n\t\t\tleaderboard_id INTEGER NOT NULL,\n\t\t\tgame_count_since_last_check INTEGER,\n\t\t\tlast_alerted_at TIMESTAMP,\n\n\t\t\tPRIMARY KEY (user_id, leaderboard_id),\n\t\t\tFOREIGN KEY (user_id, leaderboard_id)\n\t\t\t\tREFERENCES leaderboards(user_id, leaderboard_id) ON DELETE CASCADE\n\t\t);\n\t`);\n\n\t// To quickly get all rating_abuse entries for a specific user\n\tdb.run(`CREATE INDEX IF NOT EXISTS idx_rating_abuse_user ON rating_abuse (user_id);`);\n\n\t// Password Reset Tokens table\n\tdb.run(`\n\t\tCREATE TABLE IF NOT EXISTS password_reset_tokens (\n\t\t\thashed_token TEXT PRIMARY KEY NOT NULL,\n\t\t\tuser_id INTEGER NOT NULL REFERENCES members(user_id) ON DELETE CASCADE,\n\t\t\texpires_at INTEGER NOT NULL, -- Unix timestamp (milliseconds)\n\t\t\tcreated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', 'now') AS INTEGER) * 1000) -- Unix timestamp (milliseconds)\n\t\t);\n\t`);\n\t// Indexes for password_reset_tokens table\n\tdb.run(`CREATE INDEX IF NOT EXISTS idx_prt_user_id ON password_reset_tokens (user_id);`);\n\tdb.run(`CREATE INDEX IF NOT EXISTS idx_prt_expires_at ON password_reset_tokens (expires_at);`);\n\n\t// Refresh Tokens table\n\tdb.run(`\n\t\tCREATE TABLE IF NOT EXISTS refresh_tokens (\n\t\t\ttoken TEXT PRIMARY KEY NOT NULL,\n\t\t\tuser_id INTEGER NOT NULL REFERENCES members(user_id) ON DELETE CASCADE,\n\t\t\tcreated_at INTEGER NOT NULL,   -- Unix timestamp (milliseconds)\n\t\t\texpires_at INTEGER NOT NULL,   -- Unix timestamp (milliseconds)\n\t\t\tconsumed_at INTEGER,           -- Allows a grace period for using old tokens when renewing sessions\n\t\t\tip_address TEXT\n\t\t);\n\t`);\n\t// Indexes for refresh_tokens table\n\tdb.run(`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens (user_id);`);\n\tdb.run(\n\t\t`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens (expires_at);`,\n\t);\n\n\t// Editor Saves table\n\tdb.run(`\n\t\tCREATE TABLE IF NOT EXISTS editor_saves (\n\t\t\tuser_id INTEGER NOT NULL REFERENCES members(user_id) ON DELETE CASCADE,\n\t\t\tname TEXT NOT NULL,\n\t\t\tpiece_count INTEGER NOT NULL,\n\t\t\ttimestamp INTEGER NOT NULL,\n\t\t\ticn TEXT NOT NULL,\n\t\t\tcompression TEXT NOT NULL DEFAULT 'none',\n\t\t\tpawn_double_push INTEGER NOT NULL CHECK (pawn_double_push IN (-1, 0, 1)),\n\t\t\tcastling INTEGER NOT NULL CHECK (castling IN (-1, 0, 1)),\n\n\t\t\tPRIMARY KEY (user_id, name)\n\t\t);\n\t`);\n\n\t// Blacklisted Emails table\n\tdb.run(`\n\t\tCREATE TABLE IF NOT EXISTS email_blacklist (\n\t\t\temail TEXT PRIMARY KEY NOT NULL,\n\t\t\treason TEXT NOT NULL, -- e.g. 'bounce', 'spam_report', 'banned'\n\t\t\tcreated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n\t\t);\n\t`);\n\n\t// Live Games table — persists active games across server restarts\n\tdb.run(`\n\t\tCREATE TABLE IF NOT EXISTS live_games (\n\t\t\tgame_id               INTEGER PRIMARY KEY,\n\t\t\ttime_created          INTEGER NOT NULL,\n\t\t\tvariant               TEXT NOT NULL,\n\t\t\tclock                 TEXT NOT NULL,\n\t\t\trated                 BOOLEAN NOT NULL CHECK (rated IN (0, 1)),\n\t\t\tprivate               BOOLEAN NOT NULL CHECK (private IN (0, 1)),\n\t\t\tmoves                 TEXT NOT NULL DEFAULT '',\n\t\t\tcolor_ticking         INTEGER,\n\t\t\tclock_snapshot_time   INTEGER,\n\t\t\tdraw_offer_state      INTEGER,\n\t\t\tconclusion_condition  TEXT,\n\t\t\tconclusion_victor     INTEGER,\n\t\t\ttime_ended            INTEGER,\n\t\t\tafk_resign_time       INTEGER,\n\t\t\tdelete_time           INTEGER,\n\t\t\tposition_pasted       BOOLEAN NOT NULL DEFAULT 0 CHECK (position_pasted IN (0, 1)),\n\t\t\tvalidate_moves        BOOLEAN NOT NULL DEFAULT 1 CHECK (validate_moves IN (0, 1))\n\t\t);\n\t`);\n\n\t// Live Player Games table — per-player state for active games\n\tdb.run(`\n\t\tCREATE TABLE IF NOT EXISTS live_player_games (\n\t\t\tgame_id                         INTEGER NOT NULL REFERENCES live_games(game_id) ON DELETE CASCADE,\n\t\t\tplayer_number                   INTEGER NOT NULL,\n\t\t\tuser_id                         INTEGER,\n\t\t\tbrowser_id                      TEXT NOT NULL,\n\t\t\telo                             TEXT,\n\t\t\tlast_draw_offer_ply             INTEGER,\n\t\t\ttime_remaining_ms               INTEGER,\n\t\t\tdisconnect_cushion_end_time     INTEGER,\n\t\t\tdisconnect_resign_time          INTEGER,\n\t\t\tdisconnect_by_choice            INTEGER CHECK (disconnect_by_choice IN (0, 1)),\n\t\t\tPRIMARY KEY (game_id, player_number)\n\t\t);\n\t`);\n\tdb.run(`CREATE INDEX IF NOT EXISTS idx_live_player_games_game ON live_player_games (game_id);`);\n}\n\n// /**\n//  * Deletes a table from the database by its name.\n//  * @param tableName - The name of the table to delete.\n//  */\n// function deleteTable(tableName: string): void {\n// \ttry {\n// \t\t// Prepare the SQL query to drop the table\n// \t\tconst deleteTableSQL = `DROP TABLE IF EXISTS ${tableName};`;\n\n// \t\t// Run the query\n// \t\tdb.run(deleteTableSQL);\n// \t\tconsole.log(`Table ${tableName} deleted successfully.`);\n// \t} catch (error) {\n// \t\tconsole.error(`Error deleting table ${tableName}:`, error);\n// \t}\n// }\n// deleteTable('test');\n\nfunction initDatabase(): void {\n\tgenerateTables();\n\tstartPeriodicDatabaseCleanupTasks();\n\tstartPeriodicLeaderboardRatingDeviationUpdate();\n\tstartDailyBackups();\n}\n\n/** Wipes all data from all tables. ONLY call in a test environment! */\nfunction clearAllTables(): void {\n\tif (process.env['NODE_ENV'] !== 'test') {\n\t\treturn console.error('CANNOT CLEAR DATABASE TABLES OUTSIDE OF TEST ENVIRONMENT!');\n\t}\n\n\t// Get all table names dynamically\n\tconst tables = db.all<{ name: string }>(\n\t\t\"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'\",\n\t);\n\n\t// Disable foreign keys temporarily to avoid constraint errors (e.g. deleting Parent before Child)\n\tdb.run('PRAGMA foreign_keys = OFF');\n\n\t// Wrap deletions in a transaction for speed\n\tconst wipeTransaction = db.transaction(() => {\n\t\tfor (const table of tables) {\n\t\t\tdb.run(`DELETE FROM ${table.name}`);\n\t\t}\n\t});\n\twipeTransaction();\n\n\t// Re-enable foreign keys\n\tdb.run('PRAGMA foreign_keys = ON');\n}\n\nexport {\n\tuser_id_upper_cap,\n\tgame_id_upper_cap,\n\tuniqueMemberKeys,\n\tallMemberColumns,\n\tallPlayerGamesColumns,\n\tallGamesColumns,\n\tallRatingAbuseColumns,\n\tinitDatabase,\n\tgenerateTables,\n\tclearAllTables,\n};\n"
  },
  {
    "path": "src/server/database/editorSavesManager.ts",
    "content": "// src/server/database/editorSavesManager.ts\n\n/**\n * This module manages saved positions in the editor_saves table.\n */\n\nimport type { RunResult } from 'better-sqlite3';\n\nimport db from './database.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\n\n// Types -------------------------------------------------------------------------------\n\n/** Represents a saved position list record (name, piece_count, timestamp). */\ntype EditorSavesListRecord = {\n\tname: string;\n\tpiece_count: number;\n\ttimestamp: number;\n};\n\n/** Represents a saved position ICN record (icn, pawn_double_push, castling, compression). */\ntype EditorSavesIcnRecord = {\n\ttimestamp: number;\n\tcompression: string;\n\ticn: string;\n\t/** -1 = Indeterminate tristate */\n\tpawn_double_push: -1 | 0 | 1;\n\t/** -1 = Indeterminate tristate */\n\tcastling: -1 | 0 | 1;\n};\n\n// Constants ---------------------------------------------------------------------------------\n\n/** Maximum number of saved positions allowed per user */\nconst MAX_SAVED_POSITIONS = 50;\n\n/** Error message for when the user's save quota is exceeded. */\nconst QUOTA_EXCEEDED_ERROR = 'QUOTA_EXCEEDED';\n\n// Methods -----------------------------------------------------------------------------\n\n/**\n * Retrieves all saved positions for a given user_id.\n * Returns only name, piece_count, and timestamp columns.\n * @param user_id - The user ID\n * @returns An array of saved positions.\n * @throws A database error occurred while managing editor saves.\n */\nfunction getAllSavedPositionsForUser(user_id: number): EditorSavesListRecord[] {\n\ttry {\n\t\tconst query = `SELECT name, piece_count, timestamp FROM editor_saves WHERE user_id = ?`;\n\t\treturn db.all<EditorSavesListRecord>(query, [user_id]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error retrieving saved positions for user_id ${user_id}: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('A database error occurred while managing editor saves.');\n\t}\n}\n\n/**\n * Adds a new saved position to the editor_saves table,\n * enforcing the maximum saved positions quota per user.\n * If a position with the same name already exists, it will be overwritten.\n * @param user_id - The user ID who owns the position\n * @param name - The name of the saved position\n * @param piece_count - The client-provided piece count of the position\n * @param timestamp - The timestamp when the position was saved\n * @param icn - The ICN notation of the position\n * @param compression - The compression mode used for the ICN\n * @param pawn_double_push - Whether the pawn double push gamerule is enabled, or undefined if indeterminate\n * @param castling - Whether the castling gamerule is enabled, or undefined if indeterminate\n * @returns The RunResult.\n * @throws QUOTA_EXCEEDED if the user has reached the maximum saved positions, or a generic database error.\n */\nfunction addSavedPosition(\n\tuser_id: number,\n\tname: string,\n\tpiece_count: number,\n\ttimestamp: number,\n\ticn: string,\n\tcompression: string,\n\tpawn_double_push?: boolean,\n\tcastling?: boolean,\n): RunResult {\n\ttry {\n\t\tconst transaction = db.transaction(() => {\n\t\t\t// Check if a position with the same name already exists\n\t\t\tconst existingPosition = db.get<{ name: string }>(\n\t\t\t\t`SELECT name FROM editor_saves WHERE user_id = ? AND name = ?`,\n\t\t\t\t[user_id, name],\n\t\t\t);\n\n\t\t\t// Get count within the transaction, only if it's a new position\n\t\t\tif (!existingPosition) {\n\t\t\t\tconst countResult = db.get<{ count: number }>(\n\t\t\t\t\t`SELECT COUNT(*) as count FROM editor_saves WHERE user_id = ?`,\n\t\t\t\t\t[user_id],\n\t\t\t\t);\n\t\t\t\tconst currentCount = countResult?.count ?? 0;\n\n\t\t\t\t// Check quota\n\t\t\t\tif (currentCount >= MAX_SAVED_POSITIONS) {\n\t\t\t\t\t// Throw an error to roll back the transaction\n\t\t\t\t\tthrow new Error(QUOTA_EXCEEDED_ERROR);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Insert the record (overwrites any existing one)\n\t\t\tconst insertQuery = `\n            INSERT OR REPLACE INTO editor_saves (user_id, name, piece_count, timestamp, icn, compression, pawn_double_push, castling)\n            VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n        `;\n\t\t\treturn db.run(insertQuery, [\n\t\t\t\tuser_id,\n\t\t\t\tname,\n\t\t\t\tpiece_count,\n\t\t\t\ttimestamp,\n\t\t\t\ticn,\n\t\t\t\tcompression,\n\t\t\t\t// Encode tristate\n\t\t\t\tpawn_double_push === undefined ? -1 : pawn_double_push ? 1 : 0,\n\t\t\t\tcastling === undefined ? -1 : castling ? 1 : 0,\n\t\t\t]);\n\t\t});\n\n\t\treturn transaction();\n\t} catch (error: unknown) {\n\t\tconst errMsg = error instanceof Error ? error.message : String(error);\n\n\t\t// Re-throw quota exceeded errors as-is (expected business logic failure)\n\t\tif (errMsg === QUOTA_EXCEEDED_ERROR) {\n\t\t\tthrow error;\n\t\t}\n\t\t// Log and throw generic error for all other database errors\n\t\tlogEventsAndPrint(\n\t\t\t`Error adding saved position for user_id ${user_id} with name \"${name}\": ${errMsg}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('A database error occurred while managing editor saves.');\n\t}\n}\n\n/**\n * Retrieves the ICN notation, pawn_double_push, and castling for a specific saved position by name and user_id.\n * @param name - The position name\n * @param user_id - The user ID who owns the position\n * @returns The ICN record if found and owned by the user, otherwise undefined.\n * @throws A database error occurred while managing editor saves.\n */\nfunction getSavedPositionICN(name: string, user_id: number): EditorSavesIcnRecord | undefined {\n\ttry {\n\t\tconst query = `SELECT timestamp, icn, compression, pawn_double_push, castling FROM editor_saves WHERE name = ? AND user_id = ?`;\n\t\treturn db.get<EditorSavesIcnRecord>(query, [name, user_id]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error retrieving ICN for name \"${name}\" and user_id ${user_id}: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('A database error occurred while managing editor saves.');\n\t}\n}\n\n/**\n * Deletes a saved position by name and user_id.\n * Will fail to delete if the user_id doesn't match the position owner.\n * @param name - The position name\n * @param user_id - The user ID who owns the position\n * @returns The RunResult containing the number of changes.\n * @throws A database error occurred while managing editor saves.\n */\nfunction deleteSavedPosition(name: string, user_id: number): RunResult {\n\ttry {\n\t\tconst query = `DELETE FROM editor_saves WHERE name = ? AND user_id = ?`;\n\t\treturn db.run(query, [name, user_id]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error deleting position \"${name}\" for user_id ${user_id}: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('A database error occurred while managing editor saves.');\n\t}\n}\n\nexport default {\n\t// Constants\n\tMAX_SAVED_POSITIONS,\n\tQUOTA_EXCEEDED_ERROR,\n\t// Methods\n\tgetAllSavedPositionsForUser,\n\taddSavedPosition,\n\tgetSavedPositionICN,\n\tdeleteSavedPosition,\n};\n"
  },
  {
    "path": "src/server/database/gamesManager.ts",
    "content": "// src/server/database/gamesManager.ts\n\n/**\n * This script handles queries to the games table.\n */\n\nimport jsutil from '../../shared/util/jsutil.js';\n\nimport db from './database.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js'; // Adjust path if needed\nimport { allGamesColumns, game_id_upper_cap } from './databaseTables.js';\n\n// Types ----------------------------------------------------------------------------------------------\n\n/** Structure of a complete games record. */\nexport interface GamesRecord {\n\tgame_id: number;\n\tdate: string;\n\tbase_time_seconds: number | null;\n\tincrement_seconds: number | null;\n\tvariant: string;\n\t/** 0 => false  1 => true */\n\trated: 0 | 1;\n\tleaderboard_id: number | null;\n\t/** 0 => false  1 => true */\n\tprivate: 0 | 1;\n\tresult: string;\n\ttermination: string;\n\tmove_count: number;\n\ttime_duration_millis: number | null;\n\ticn: string;\n}\n\ntype GamesColumn = keyof GamesRecord;\n\n// Methods --------------------------------------------------------------------------------------------\n\n/**\n * Generates a game_id **UNIQUE** to all other game ids in the games table.\n * @returns - A unique game_id.\n */\nfunction genUniqueGameID(): number {\n\tlet id: number;\n\tdo {\n\t\tid = generateRandomGameId();\n\t} while (isGameIdTaken(id));\n\treturn id;\n}\n\n/**\n * Generates a random game_id. DOES NOT test if it's taken already.\n * @returns - A random game_id.\n */\nfunction generateRandomGameId(): number {\n\t// Generate a random number between 0 and game_id_upper_cap\n\treturn Math.floor(Math.random() * game_id_upper_cap);\n}\n\n/**\n * Checks if a given game_id exists in the games table.\n * @param game_id - The game_id to check.\n * @returns - Returns true if the game_id exists, false otherwise.\n */\nfunction isGameIdTaken(game_id: number): boolean {\n\ttry {\n\t\tconst query = 'SELECT 1 FROM games WHERE game_id = ?';\n\n\t\t// Execute query to check if the game_id exists in the games table\n\t\tconst row = db.get<{ '1': number }>(query, [game_id]);\n\n\t\t// If a row is found, the game_id exists\n\t\treturn row !== undefined;\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t// Log the error if the query fails\n\t\tlogEventsAndPrint(\n\t\t\t`Error checking if game_id \"${game_id}\" is taken: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn false; // Return false if an error occurs\n\t}\n}\n\n/**\n * Fetches specified columns of a single game from the games table based on game_id\n * @param game_id - The game_id of the game\n * @param columns - The columns to retrieve (e.g., ['game_id', 'date', 'rated']).\n * @returns An object containing the requested columns, or undefined if no match is found.\n */\nfunction getGameData<K extends GamesColumn>(\n\tgame_id: number,\n\tcolumns: K[],\n): Pick<GamesRecord, K> | undefined {\n\t// Guard clauses... Validating the arguments...\n\n\tif (!Array.isArray(columns)) {\n\t\tlogEventsAndPrint(\n\t\t\t`When getting game data, columns must be an array of strings! Received: ${jsutil.ensureJSONString(columns)}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn undefined;\n\t}\n\tif (\n\t\t!columns.every((column) => typeof column === 'string' && allGamesColumns.includes(column))\n\t) {\n\t\tlogEventsAndPrint(\n\t\t\t`Invalid columns requested from games table: ${jsutil.ensureJSONString(columns)}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn undefined;\n\t}\n\n\t// Arguments are valid, move onto the SQL query...\n\n\t// Construct SQL query\n\tconst query = `SELECT ${columns.join(', ')} FROM games WHERE game_id = ?`;\n\n\ttry {\n\t\t// Execute the query and fetch result\n\t\tconst row = db.get<Pick<GamesRecord, K>>(query, [game_id]);\n\n\t\t// If no row is found, return undefined\n\t\tif (!row) {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`No matches found in games table for game_id = ${game_id}.`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t\treturn undefined;\n\t\t}\n\n\t\t// Return the fetched row (single object)\n\t\treturn row;\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t// Log the error and return undefined\n\t\tlogEventsAndPrint(\n\t\t\t`Error executing query when getting game data of game_id ${game_id}: ${message}. The query: \"${query}\"`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn undefined;\n\t}\n}\n\n/**\n * Fetches specified columns of multiple games from the games table based on list of game_ids\n * @param game_id_list - A list of game_ids\n * @param columns - The columns to retrieve (e.g., ['game_id', 'date', 'rated']).\n * @returns An array of objects with the requested columns, or undefined if no matches found.\n */\nfunction getMultipleGameData<K extends GamesColumn>(\n\tgame_id_list: number[],\n\tcolumns: K[],\n): Pick<GamesRecord, K>[] | undefined {\n\t// Guard clauses... Validating the arguments...\n\n\tif (!Array.isArray(columns)) {\n\t\tlogEventsAndPrint(\n\t\t\t`When getting game data, columns must be an array of strings! Received: ${jsutil.ensureJSONString(columns)}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn undefined;\n\t}\n\tif (\n\t\t!columns.every((column) => typeof column === 'string' && allGamesColumns.includes(column))\n\t) {\n\t\tlogEventsAndPrint(\n\t\t\t`Invalid columns requested from games table: ${jsutil.ensureJSONString(columns)}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn undefined;\n\t}\n\n\t// Arguments are valid, move onto the SQL query...\n\n\t// Construct SQL query\n\tconst placeholders = game_id_list.map(() => '?').join(', ');\n\tconst query = `SELECT ${columns.join(', ')} FROM games WHERE game_id IN (${placeholders})`;\n\n\ttry {\n\t\t// Execute the query and fetch result\n\t\tconst rows = db.all<Pick<GamesRecord, K>>(query, game_id_list);\n\n\t\t// If no rows found, return undefined\n\t\tif (!rows || rows.length === 0) {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`No matches found in games table for game_ids: ${jsutil.ensureJSONString(game_id_list)}.`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t\treturn undefined;\n\t\t}\n\n\t\t// Return the fetched rows (single object)\n\t\treturn rows;\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t// Log the error and return undefined\n\t\tlogEventsAndPrint(\n\t\t\t`Error executing query for game_ids ${jsutil.ensureJSONString(game_id_list)}: ${message}. Query: \"${query}\"`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn undefined;\n\t}\n}\n\n// Exports --------------------------------------------------------------------------------------------\n\nexport { genUniqueGameID, getGameData, getMultipleGameData };\n"
  },
  {
    "path": "src/server/database/leaderboardsManager.ts",
    "content": "// src/server/database/leaderboardsManager.ts\n\n/**\n * This script handles queries to the leaderboards table.\n */\n\nimport type { RunResult } from 'better-sqlite3'; // Import necessary types\nimport type { Rating } from '../../shared/types.js';\nimport type { Leaderboard } from '../../shared/chess/variants/validleaderboard.js';\n\nimport db from './database.js';\nimport { getTrueRD } from '../game/gamemanager/ratingcalculation.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js'; // Adjust path if needed\nimport {\n\tDEFAULT_LEADERBOARD_ELO,\n\tUNCERTAIN_LEADERBOARD_RD,\n\tRD_UPDATE_FREQUENCY,\n} from '../game/gamemanager/ratingcalculation.js';\n\n// Types ----------------------------------------------------------------------------------------------\n\n/** Structure of a complete leaderboard entry record. */\ninterface LeaderboardEntry {\n\tuser_id: number;\n\tleaderboard_id: number;\n\telo: number;\n\trating_deviation: number;\n\trd_last_update_date: string | null; // Can be null if no games played yet\n\t// Consider adding volatility if you use it in Glicko-2\n}\n\n// Methods --------------------------------------------------------------------------------------------\n\n/**\n * The core logic for adding a user to a leaderboard.\n * This function is \"unsafe\" as it throws errors on failure, making it\n * suitable for use inside a database transaction.\n * @throws {SqliteError} If the database query fails. The error's `code` property\n *                       can be checked for specific constraints like 'SQLITE_CONSTRAINT_PRIMARYKEY'.\n */\nfunction addUserToLeaderboard(\n\tuser_id: number,\n\tleaderboard_id: Leaderboard,\n\telo: number,\n\trd: number,\n): RunResult {\n\tconst query = `\n\tINSERT INTO leaderboards (\n\t\tuser_id,\n\t\tleaderboard_id,\n\t\telo,\n\t\trating_deviation\n\t\t-- rd_last_update_date will be NULL by default\n\t) VALUES (?, ?, ?, ?)\n\t`;\n\t// This will throw on failure, which is what we want for a transaction.\n\treturn db.run(query, [user_id, leaderboard_id, elo, rd]);\n}\n\n/**\n * Updates the rating values for a player on a specific leaderboard.\n * This function throws errors on failure, making it suitable for use\n * inside a database transaction which can catch the error and roll back.\n * Callers outside of transactions should implement their own error handling.\n * @throws {Error} If the user is not found or if the database query fails.\n */\nfunction updatePlayerLeaderboardRating(\n\tuser_id: number,\n\tleaderboard_id: Leaderboard,\n\telo: number,\n\trd: number,\n): RunResult {\n\tconst query = `\n\tUPDATE leaderboards\n\tSET elo = ?,\n\t    rating_deviation = ?,\n\t\trd_last_update_date = CURRENT_TIMESTAMP -- Automatically update timestamp on rating change\n\tWHERE user_id = ? AND leaderboard_id = ?\n\t`;\n\tconst result = db.run(query, [elo, rd, user_id, leaderboard_id]);\n\n\t// If the UPDATE affected no rows, it's a critical failure for a transaction.\n\t// We must throw an error to trigger a rollback.\n\tif (result.changes === 0) {\n\t\tthrow new Error(\n\t\t\t`User with ID \"${user_id}\" not found on leaderboard \"${leaderboard_id}\" for update.`,\n\t\t);\n\t}\n\treturn result;\n}\n\n/**\n * Checks if a player exists on a specific leaderboard.\n * Relies on the composite primary key (user_id, leaderboard_id).\n * @param user_id - The ID of the user to check.\n * @param leaderboard_id - The ID of the leaderboard to check within.\n * @returns True if the player exists on the specified leaderboard, false otherwise (including on error).\n */\nfunction isPlayerInLeaderboard(user_id: number, leaderboard_id: Leaderboard): boolean {\n\t// Query to select a constant '1' if a matching row exists.\n\t// LIMIT 1 ensures the database can stop searching after finding the first match.\n\t// This is efficient, especially with the primary key index.\n\tconst query = `\n        SELECT 1\n        FROM leaderboards\n        WHERE user_id = ? AND leaderboard_id = ?\n        LIMIT 1;\n    `;\n\n\ttry {\n\t\tconst result = db.get<{ '1': 1 }>(query, [user_id, leaderboard_id]);\n\n\t\t// If db.get returns anything (even an object like { '1': 1 }), it means a row was found.\n\t\t// If no row is found, db.get returns undefined.\n\t\t// The double negation (!!) converts a truthy value (the result object) to true,\n\t\t// and a falsy value (undefined) to false.\n\t\treturn !!result;\n\t} catch (error: unknown) {\n\t\t// Log any potential database errors during the check\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error checking existence of user \"${user_id}\" on leaderboard \"${leaderboard_id}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\n\t\t// On error, we cannot confirm existence, so return false.\n\t\treturn false;\n\t}\n}\n\n/** The return type of {@link getPlayerLeaderboardRating} */\ntype PlayerLeaderboardRating = {\n\telo: number;\n\trating_deviation: number;\n\trd_last_update_date: string | null; // Can be null if no games played yet\n};\n\n/**\n * The core logic for getting a player's rating. Throws on failure.\n * @throws {SqliteError} If the database query fails.\n */\nfunction getPlayerLeaderboardRating_core(\n\tuser_id: number,\n\tleaderboard_id: Leaderboard,\n): PlayerLeaderboardRating | undefined {\n\tconst query = `\n\t\tSELECT elo, rating_deviation, rd_last_update_date\n\t\tFROM leaderboards\n\t\tWHERE user_id = ? AND leaderboard_id = ?\n\t`;\n\t// This will throw an error if the query fails.\n\treturn db.get<PlayerLeaderboardRating>(query, [user_id, leaderboard_id]);\n}\n\n/**\n * Safely gets the rating values for a player on a specific leaderboard.\n * This wraps the core logic in a try/catch block to prevent crashes.\n * @returns The player's leaderboard entry object or undefined if not found or on error.\n */\nfunction getPlayerLeaderboardRating(\n\tuser_id: number,\n\tleaderboard_id: Leaderboard,\n): PlayerLeaderboardRating | undefined {\n\ttry {\n\t\treturn getPlayerLeaderboardRating_core(user_id, leaderboard_id);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t// Log the error for debugging purposes\n\t\tlogEventsAndPrint(\n\t\t\t`Error getting leaderboard rating data for member \"${user_id}\" on leaderboard \"${leaderboard_id}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn undefined;\n\t}\n}\n\n/**\n * Gets all leaderboard entries for a specific user.\n * @param user_id - The id for the user\n * @returns An array of the user's leaderboard entries across all leaderboards, potentially empty.\n */\nfunction _getAllUserLeaderboardEntries(user_id: number): LeaderboardEntry[] {\n\t// New function leveraging the idx_leaderboards_user index\n\tconst query = `\n        SELECT leaderboard_id, elo, rating_deviation, rd_last_update_date\n        FROM leaderboards\n        WHERE user_id = ?\n        ORDER BY leaderboard_id ASC -- Optional: order for consistency\n    `;\n\n\ttry {\n\t\tconst entries = db.all(query, [user_id]) as LeaderboardEntry[];\n\t\treturn entries;\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error getting all leaderboard entries for user \"${user_id}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn []; // Return an empty array on error\n\t}\n}\n\n/**\n * Gets the top N players for a specific leaderboard by elo, starting from a given rank.\n * @param leaderboard_id - The id for the specific leaderboard.\n * @param start_rank - The 1-based rank to start from (e.g. 1 = top player, 2 = second-best, etc.)\n * @param n_players - The maximum number of players to retrieve, starting from start_rank\n * @returns An array of top player leaderboard entries, potentially empty.\n */\nfunction getTopPlayersForLeaderboard(\n\tleaderboard_id: Leaderboard,\n\tstart_rank: number,\n\tn_players: number,\n): LeaderboardEntry[] {\n\t// Changed table name, column names, ORDER BY column, added WHERE clause for leaderboard_id\n\tconst offset = Math.max(0, start_rank - 1); // SQL OFFSET is 0-based\n\n\tconst query = `\n\t\tSELECT user_id, elo, rating_deviation, rd_last_update_date\n\t\tFROM leaderboards\n\t\tWHERE leaderboard_id = ?\n\t\tAND rating_deviation <= ? -- Disregard any members with a too high RD\n\t\tORDER BY elo DESC\n\t\tLIMIT ? OFFSET ?\n\t`;\n\n\ttry {\n\t\t// Execute the query with leaderboard_id, n_players and offset parameters\n\t\t// Added leaderboard_id to parameters\n\t\tconst top_players = db.all(query, [\n\t\t\tleaderboard_id,\n\t\t\tUNCERTAIN_LEADERBOARD_RD,\n\t\t\tn_players,\n\t\t\toffset,\n\t\t]) as LeaderboardEntry[];\n\t\treturn top_players; // Returns an array (potentially empty)\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t// Updated log message\n\t\tlogEventsAndPrint(\n\t\t\t`Error getting top \"${n_players}\" players starting at rank \"${start_rank}\" for leaderboard \"${leaderboard_id}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn []; // Return an empty array on error\n\t}\n}\n\n/**\n * Gets the rank (position) of a specific user within a specific leaderboard based on Elo.\n * Rank 1 is the highest Elo. Uses RANK() to handle ties (tied players share the same rank,\n * but the next rank number is skipped, creating potential gaps, e.g., 1, 1, 3).\n * @param user_id - The ID of the user whose rank is needed.\n * @param leaderboard_id - The ID of the leaderboard to check.\n * @returns The user's rank (1-based) as a number, or undefined if the user is not found\n *          on that leaderboard or if an error occurs.\n */\nfunction getPlayerRankInLeaderboard(\n\tuser_id: number,\n\tleaderboard_id: Leaderboard,\n): number | undefined {\n\t// This query uses a Common Table Expression (CTE) and the RANK window function.\n\t// 1. Filter `leaderboards` to only include rows for the specific `leaderboard_id`.\n\t// 2. Calculate `RANK() OVER (ORDER BY elo DESC)`.\n\t//    RANK assigns the same rank to ties, but skips subsequent ranks\n\t//    (e.g., if 2 players tie for 1st, the next rank is 3).\n\t// 3. Select the calculated `rank` for the specific `user_id`.\n\tconst query = `\n\t\tWITH RankedPlayers AS (\n\t\t\tSELECT\n\t\t\t\tuser_id,\n\t\t\t\tRANK() OVER (ORDER BY elo DESC) as rank\n\t\t\tFROM leaderboards\n\t\t\tWHERE leaderboard_id = ? -- Filter for the specific leaderboard FIRST\n\t\t\tAND (rating_deviation <= ? OR user_id = ?) -- Disregard any other users with a too high RD\n\t\t)\n\t\tSELECT rank\n\t\tFROM RankedPlayers\n\t\tWHERE user_id = ?; -- Then find the rank for the specific user\n\t`;\n\n\ttry {\n\t\t// Execute the query, expecting at most one row containing the rank\n\t\tconst result = db.get<{ rank: number }>(query, [\n\t\t\tleaderboard_id,\n\t\t\tUNCERTAIN_LEADERBOARD_RD,\n\t\t\tuser_id,\n\t\t\tuser_id,\n\t\t]);\n\n\t\t// If a result is found, return the rank, otherwise return undefined\n\t\treturn result?.rank;\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t// Log message remains appropriate\n\t\tlogEventsAndPrint(\n\t\t\t`Error getting rank for user \"${user_id}\" on leaderboard \"${leaderboard_id}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn undefined; // Return undefined on error\n\t}\n}\n\n// Helper Functions ----------------------------------------------------------------------------------\n\n/**\n * Returns the elo of a player on a specific leaderboard, or their elo if they were\n * to join it now, and whether we are confident about it.\n * @param user_id - The id for the user\n * @param leaderboard_id - The id for the specific leaderboard.\n * @returns The player's leaderboard elo and whether we are confident about it.\n */\nfunction getEloOfPlayerInLeaderboard(user_id: number, leaderboard_id: Leaderboard): Rating {\n\tconst rating_values = getPlayerLeaderboardRating(user_id, leaderboard_id); // { user_id, elo, rating_deviation, rd_last_update_date } | undefined\n\tif (!rating_values) return { value: DEFAULT_LEADERBOARD_ELO, confident: false }; // No rating, return un-confident default elo\n\n\tconst confident = rating_values.rating_deviation <= UNCERTAIN_LEADERBOARD_RD;\n\treturn { value: rating_values.elo, confident };\n}\n\n// Regular Table Utility Functions -------------------------------------------------------------------\n\n/** Calls updateAllRatingDeviationsofLeaderboardTable() every {@link RD_UPDATE_FREQUENCY} milliseconds */\nfunction startPeriodicLeaderboardRatingDeviationUpdate(): void {\n\tsetInterval(updateAllRatingDeviationsofLeaderboardTable, RD_UPDATE_FREQUENCY);\n}\n\n/** Retrieves all entries of the leaderboards table and updates their RD */\nfunction updateAllRatingDeviationsofLeaderboardTable(): void {\n\tconst query = `SELECT * FROM leaderboards`;\n\n\ttry {\n\t\tconst entries = db.all<LeaderboardEntry>(query);\n\t\tfor (const entry of entries) {\n\t\t\tconst updatedRD = getTrueRD(entry.rating_deviation, entry.rd_last_update_date);\n\t\t\tupdatePlayerLeaderboardRating(\n\t\t\t\tentry.user_id,\n\t\t\t\tentry.leaderboard_id as Leaderboard,\n\t\t\t\tentry.elo,\n\t\t\t\tupdatedRD,\n\t\t\t);\n\t\t}\n\t\tlogEventsAndPrint(\n\t\t\t`Updated all rating deviations in leaderboard table.`,\n\t\t\t'leaderboardLog.txt',\n\t\t);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error updating all rating deviations in leaderboard table: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t}\n}\n\n// Exports --------------------------------------------------------------------------------------------\n\nexport {\n\taddUserToLeaderboard,\n\tupdatePlayerLeaderboardRating,\n\tisPlayerInLeaderboard,\n\tgetPlayerLeaderboardRating,\n\tgetPlayerLeaderboardRating_core,\n\tgetTopPlayersForLeaderboard,\n\tgetPlayerRankInLeaderboard,\n\tgetEloOfPlayerInLeaderboard,\n\tstartPeriodicLeaderboardRatingDeviationUpdate,\n};\n"
  },
  {
    "path": "src/server/database/liveGamesManager.ts",
    "content": "// src/server/database/liveGamesManager.ts\n\n/**\n * This script manages the live_games table, which persists active game state\n * across server restarts. One row per active game.\n */\n\nimport db from './database.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\n\n// Types ----------------------------------------------------------------------------------------------\n\n/** Structure of a complete live_games record. */\nexport interface LiveGamesRecord extends LiveGameData {\n\tgame_id: number;\n}\n\n/** Live game data columns, excluding the primary key. */\nexport interface LiveGameData {\n\ttime_created: number;\n\tvariant: string;\n\tclock: string;\n\t/** 0 = casual, 1 = rated */\n\trated: 0 | 1;\n\t/** 0 = public, 1 = private */\n\tprivate: 0 | 1;\n\tmoves: string;\n\tcolor_ticking: number | null;\n\tclock_snapshot_time: number | null;\n\tdraw_offer_state: number | null;\n\tconclusion_condition: string | null;\n\tconclusion_victor: number | null;\n\ttime_ended: number | null;\n\tafk_resign_time: number | null;\n\tdelete_time: number | null;\n\t/** 0 = false, 1 = true */\n\tposition_pasted: 0 | 1;\n\t/** 0 = false, 1 = true */\n\tvalidate_moves: 0 | 1;\n}\n\n// SQL Queries ---------------------------------------------------------------------------------------\n\nconst INSERT_QUERY = `\n\tINSERT INTO live_games (\n\t\tgame_id, time_created, variant, clock, rated, private,\n\t\tmoves, color_ticking, clock_snapshot_time,\n\t\tdraw_offer_state,\n\t\tconclusion_condition, conclusion_victor, time_ended,\n\t\tafk_resign_time, delete_time,\n\t\tposition_pasted, validate_moves\n\t) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n`;\n\nconst DELETE_QUERY = `DELETE FROM live_games WHERE game_id = ?`;\n\nconst SELECT_ALL_QUERY = `SELECT * FROM live_games`;\n\n// Methods --------------------------------------------------------------------------------------------\n\n/**\n * Inserts a new live game row into the database.\n * @param record - The complete live_games record to insert.\n */\nfunction insertLiveGame(record: LiveGamesRecord): void {\n\ttry {\n\t\tdb.run(INSERT_QUERY, [\n\t\t\trecord.game_id,\n\t\t\trecord.time_created,\n\t\t\trecord.variant,\n\t\t\trecord.clock,\n\t\t\trecord.rated,\n\t\t\trecord.private,\n\t\t\trecord.moves,\n\t\t\trecord.color_ticking,\n\t\t\trecord.clock_snapshot_time,\n\t\t\trecord.draw_offer_state,\n\t\t\trecord.conclusion_condition,\n\t\t\trecord.conclusion_victor,\n\t\t\trecord.time_ended,\n\t\t\trecord.afk_resign_time,\n\t\t\trecord.delete_time,\n\t\t\trecord.position_pasted,\n\t\t\trecord.validate_moves,\n\t\t]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(`Error inserting live game ${record.game_id}: ${message}`, 'errLog.txt');\n\t}\n}\n\n/**\n * Updates specific columns of a live game.\n * @param game_id - The game to update.\n * @param updates - An object containing only the columns to update and their new values.\n */\nfunction updateLiveGame(game_id: number, updates: Partial<LiveGameData>): void {\n\tconst entries = Object.entries(updates);\n\tif (entries.length === 0) return;\n\n\tconst setClauses = entries.map(([col]) => `${col} = ?`).join(', ');\n\tconst values = entries.map(([, val]) => val);\n\tconst query = `UPDATE live_games SET ${setClauses} WHERE game_id = ?`;\n\n\ttry {\n\t\tdb.run(query, [...values, game_id]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(`Error updating live game ${game_id}: ${message}`, 'errLog.txt');\n\t}\n}\n\n/**\n * Deletes a live game row (cascades to live_player_games).\n * @param game_id - The game to delete.\n */\nfunction deleteLiveGame(game_id: number): void {\n\ttry {\n\t\tdb.run(DELETE_QUERY, [game_id]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(`Error deleting live game ${game_id}: ${message}`, 'errLog.txt');\n\t}\n}\n\n/**\n * Retrieves all live game rows. Used on server startup to restore games.\n * @returns An array of all live_games records.\n */\nfunction getAllLiveGames(): LiveGamesRecord[] {\n\ttry {\n\t\treturn db.all<LiveGamesRecord>(SELECT_ALL_QUERY);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(`Error retrieving all live games: ${message}`, 'errLog.txt');\n\t\treturn [];\n\t}\n}\n\n// Exports --------------------------------------------------------------------------------------------\n\nexport { insertLiveGame, updateLiveGame, deleteLiveGame, getAllLiveGames };\n"
  },
  {
    "path": "src/server/database/livePlayerGamesManager.ts",
    "content": "// src/server/database/livePlayerGamesManager.ts\n\n/**\n * This script manages the live_player_games table, which persists per-player\n * state for active games across server restarts. One row per player per game.\n */\n\nimport db from './database.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\n\n// Types ----------------------------------------------------------------------------------------------\n\n/** Structure of a complete live_player_games record. */\nexport interface LivePlayerGamesRecord extends LivePlayerData {\n\tgame_id: number;\n\tplayer_number: number;\n}\n\n/** Per-player live game data columns, excluding the composite key fields. */\nexport interface LivePlayerData extends LivePlayerDisconnectData {\n\tuser_id: number | null;\n\tbrowser_id: string;\n\telo: string | null;\n\tlast_draw_offer_ply: number | null;\n\ttime_remaining_ms: number | null;\n}\n\n/** Disconnect-state columns shared by live_player_games rows. */\nexport interface LivePlayerDisconnectData {\n\tdisconnect_cushion_end_time: number | null;\n\tdisconnect_resign_time: number | null;\n\t/** 0 = network interruption (60s), 1 = intentional (20s). NULL if connected. */\n\tdisconnect_by_choice: 0 | 1 | null;\n}\n\n// SQL Queries ---------------------------------------------------------------------------------------\n\nconst INSERT_QUERY = `\n\tINSERT INTO live_player_games (\n\t\tgame_id, player_number, user_id, browser_id, elo,\n\t\tlast_draw_offer_ply, time_remaining_ms,\n\t\tdisconnect_cushion_end_time, disconnect_resign_time, disconnect_by_choice\n\t) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n`;\n\nconst SELECT_BY_GAME_QUERY = `SELECT * FROM live_player_games WHERE game_id = ? ORDER BY player_number`;\n\n// Methods --------------------------------------------------------------------------------------------\n\n/**\n * Inserts a new live player game row into the database.\n * @param record - The complete live_player_games record to insert.\n */\nfunction insertLivePlayerGame(record: LivePlayerGamesRecord): void {\n\ttry {\n\t\tdb.run(INSERT_QUERY, [\n\t\t\trecord.game_id,\n\t\t\trecord.player_number,\n\t\t\trecord.user_id,\n\t\t\trecord.browser_id,\n\t\t\trecord.elo,\n\t\t\trecord.last_draw_offer_ply,\n\t\t\trecord.time_remaining_ms,\n\t\t\trecord.disconnect_cushion_end_time,\n\t\t\trecord.disconnect_resign_time,\n\t\t\trecord.disconnect_by_choice,\n\t\t]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error inserting live player game (game_id=${record.game_id}, player=${record.player_number}): ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t}\n}\n\n/**\n * Updates specific columns of a player's live game record.\n * @param game_id - The game ID.\n * @param player_number - The player number to update.\n * @param updates - An object containing only the columns to update and their new values.\n */\nfunction updateLivePlayerGame(\n\tgame_id: number,\n\tplayer_number: number,\n\tupdates: Partial<LivePlayerData>,\n): void {\n\tconst entries = Object.entries(updates);\n\tif (entries.length === 0) return;\n\n\tconst setClauses = entries.map(([col]) => `${col} = ?`).join(', ');\n\tconst values = entries.map(([, val]) => val ?? null);\n\tconst query = `UPDATE live_player_games SET ${setClauses} WHERE game_id = ? AND player_number = ?`;\n\n\ttry {\n\t\tdb.run(query, [...values, game_id, player_number]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error updating live player game (game_id=${game_id}, player=${player_number}): ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t}\n}\n\n/**\n * Retrieves all player rows for a given live game. Used on server startup.\n * @param game_id - The game ID.\n * @returns An array of live_player_games records for this game.\n */\nfunction getLivePlayerGamesForGame(game_id: number): LivePlayerGamesRecord[] {\n\ttry {\n\t\treturn db.all<LivePlayerGamesRecord>(SELECT_BY_GAME_QUERY, [game_id]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error retrieving live player games for game ${game_id}: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn [];\n\t}\n}\n\n// Exports --------------------------------------------------------------------------------------------\n\nexport { insertLivePlayerGame, updateLivePlayerGame, getLivePlayerGamesForGame };\n"
  },
  {
    "path": "src/server/database/memberManager.ts",
    "content": "// src/server/database/memberManager.ts\n\n/**\n * This script handles almost all of the queries we use to interact with the members table!\n */\n\nimport type { DeleteReason } from '../controllers/deleteAccountController.js';\n\nimport { SqliteError } from 'better-sqlite3';\n\nimport jsutil from '../../shared/util/jsutil.js';\n\nimport db from './database.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\nimport { allMemberColumns, uniqueMemberKeys, user_id_upper_cap } from './databaseTables.js';\n\n// Types ---------------------------------------------------------------------\n\n/** Structure of a complete member record. */\nexport interface MemberRecord {\n\tuser_id: number;\n\tusername: string;\n\temail: string;\n\thashed_password: string;\n\troles: string | null;\n\tjoined: string;\n\tlast_seen: string;\n\tlogin_count: number;\n\tis_verified: 0 | 1;\n\tverification_code: string | null;\n\tis_verification_notified: 0 | 1;\n\tpreferences: string | null;\n\tusername_history: string | null;\n\tcheckmates_beaten: string;\n\tlast_read_news_date: string | null;\n}\n\ntype MembersColumn = keyof MemberRecord;\n\n// Constants ----------------------------------------------------------\n\n/** SQLite constraint error code constant */\nconst SQLITE_CONSTRAINT_ERROR = 'SQLITE_CONSTRAINT';\n\n/** Custom error message for user not found during deletion */\nconst USER_NOT_FOUND_ERROR = 'USER_NOT_FOUND';\n\n// Create / Delete Member methods ---------------------------------------------------------------------------------------\n\n/**\n * Creates a new account. This is the single, authoritative function for user creation.\n * It atomically inserts records into both the `members` and `player_stats` tables\n * within a single database transaction, ensuring data integrity.\n * @param username The user's username.\n * @param email The user's email.\n * @param hashedPassword The user's hashed password.\n * @param is_verified The verification status.\n * @param verification_code The unique code for verification, if they are not yet verified.\n * @param is_verification_notified The verified notification status.\n * @returns The user_id of the newly created user.\n *\n * @throws If the insertion fails (e.g., due to constraint violation or other unexpected error).\n */\nfunction addUser(\n\tusername: string,\n\temail: string,\n\thashedPassword: string,\n\tis_verified: 0 | 1,\n\tverification_code: string | null,\n\tis_verification_notified: 0 | 1,\n): number {\n\t// prettier-ignore\n\tconst createAccountTransaction = db.transaction<[{ username: string; email: string; hashedPassword: string; is_verified: 0 | 1; verification_code: string | null; is_verification_notified: 0 | 1 }], number>((userData) => {\n\t\t// Step 1: Generate a unique user ID.\n\t\tconst userId = genUniqueUserID();\n\n\t\t// Step 2: Set initial last_read_news_date to current date so new users don't see all news as unread\n\t\tconst currentDate = new Date().toISOString().split('T')[0]!; // 'YYYY-MM-DDThh:mm:ss.sssZ' -> 'YYYY-MM-DD'\n\n\t\t// Step 3: Insert into the members table.\n\t\tconst membersQuery = `\n\t\t\tINSERT INTO members (\n\t\t\t\tuser_id, username, email, hashed_password, \n\t\t\t\tis_verified, verification_code, is_verification_notified,\n\t\t\t\tlast_read_news_date\n\t\t\t) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`;\n\t\tconst params = [\n\t\t\tuserId,\n\t\t\tuserData.username,\n\t\t\tuserData.email,\n\t\t\tuserData.hashedPassword,\n\t\t\tuserData.is_verified,\n\t\t\tuserData.verification_code,\n\t\t\tuserData.is_verification_notified,\n\t\t\tcurrentDate,\n\t\t];\n\t\tdb.run(membersQuery, params);\n\n\t\t// Step 4: Insert into the 'player_stats' table.\n\t\tconst statsQuery = `INSERT INTO player_stats (user_id) VALUES (?)`;\n\t\tdb.run(statsQuery, [userId]);\n\n\t\t// If both inserts succeed, the transaction will commit and return the new user_id.\n\t\treturn userId;\n\t});\n\n\ttry {\n\t\treturn createAccountTransaction({\n\t\t\tusername,\n\t\t\temail,\n\t\t\thashedPassword,\n\t\t\tis_verified,\n\t\t\tverification_code,\n\t\t\tis_verification_notified,\n\t\t});\n\t} catch (error: unknown) {\n\t\tconst detailedError = error instanceof SqliteError ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Account creation transaction for \"${username}\" failed and was rolled back: ${detailedError}`,\n\t\t\t'errLog.txt',\n\t\t);\n\n\t\tlet genericError: string = 'A database error occurred.'; // Generic error message to avoid leaking details\n\t\tif (error instanceof SqliteError && error.code === SQLITE_CONSTRAINT_ERROR)\n\t\t\tgenericError = SQLITE_CONSTRAINT_ERROR;\n\t\tthrow Error(genericError); // Rethrow with the generic error message, or specific constraint error\n\t}\n}\n// setTimeout(() => { console.log(addUser('na3v534', 'tes3t5em3a4il3', 'password', null)); }, 1000); // Set timeout needed so user_id_upper_cap is initialized before this function is called.\n\n/**\n * Deletes a user from the members table and adds them to the deleted_members table.\n * @param user_id - The ID of the user to delete.\n * @param reason_deleted - The reason the user is being deleted.\n * @returns A result object: { success: true } on success, or { success: false, reason: string } on failure.\n *\n * @throws If a database error occurs during the deletion process.\n */\nfunction deleteUser(user_id: number, reason_deleted: DeleteReason): void {\n\t// Create a transaction function. better-sqlite3 will wrap the execution\n\t// of this function in BEGIN/COMMIT/ROLLBACK statements.\n\tconst deleteTransaction = db.transaction<[number, string], void>((id, reason) => {\n\t\t// Step 1: Delete the user from the main 'members' table\n\t\tconst deleteQuery = 'DELETE FROM members WHERE user_id = ?';\n\t\tconst deleteResult = db.run(deleteQuery, [id]);\n\n\t\t// If no user was deleted, they didn't exist. Throw an error to\n\t\t// abort the transaction and prevent any further action.\n\t\tif (deleteResult.changes === 0) throw new Error(USER_NOT_FOUND_ERROR);\n\n\t\t// Step 2: Add their user_id to the 'deleted_members' table\n\t\t// If this fails (e.g., UNIQUE constraint), it will also throw an error\n\t\t// and cause the entire transaction (including the DELETE) to roll back.\n\t\tconst insertQuery = 'INSERT INTO deleted_members (user_id, reason_deleted) VALUES (?, ?)';\n\t\tdb.run(insertQuery, [id, reason]);\n\t});\n\n\ttry {\n\t\t// Execute the transaction\n\t\tdeleteTransaction(user_id, reason_deleted);\n\t} catch (error: unknown) {\n\t\t// The transaction was rolled back due to an error inside it.\n\t\tconst errorMessage = error instanceof Error ? error.message : String(error);\n\n\t\t// Detailed error for logging\n\t\tlet detailedError = `Delete user transaction for ID (${user_id}) for reason (${reason_deleted}) failed and was rolled back: ${errorMessage}`;\n\t\t// Handle any other unexpected database errors (like UNIQUE constraint)\n\t\tif (error instanceof SqliteError && error.code === 'SQLITE_CONSTRAINT_UNIQUE') {\n\t\t\tdetailedError = `Delete user transaction for ID (${user_id}) for reason (${reason_deleted}) failed and was rolled back because they already exist in the deleted_members tables, but the user was not deleted from the members table.`;\n\t\t}\n\t\tlogEventsAndPrint(detailedError, 'errLog.txt');\n\n\t\t// Generic error message for return value\n\t\tlet genericError = 'A database error occurred.';\n\t\t// Handle our custom \"user not found\" error\n\t\tif (error instanceof Error && error.message === USER_NOT_FOUND_ERROR)\n\t\t\tgenericError = USER_NOT_FOUND_ERROR;\n\t\tthrow Error(genericError); // Rethrow with the generic error message\n\t}\n}\n// console.log(deleteUser(3887110, 'security'));\n\n// General SELECT/UPDATE methods ---------------------------------------------------------------------------------------\n\n/**\n * Helper for validating the common arguments used for querying member data.\n * @param columns - The list of columns to retrieve (e.g., ['checkmates_beaten']).\n * @param searchKey - The database column to search by (e.g., 'username').\n * @param searchValues - An array of values to search for (e.g., ['user1', 'user2']).\n * @throws Error if any validation fails.\n */\nfunction validateMemberQueryArgs(\n\tcolumns: string[],\n\tsearchKey: string,\n\tsearchValues: (string | number)[],\n): void {\n\t// 1. Validate Columns\n\tif (\n\t\t!Array.isArray(columns) ||\n\t\tcolumns.length === 0 ||\n\t\t!columns.every((column) => typeof column === 'string' && allMemberColumns.includes(column))\n\t) {\n\t\tlogEventsAndPrint(\n\t\t\t`Invalid columns requested from members table: ${jsutil.ensureJSONString(columns)}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('Invalid columns parameter.');\n\t}\n\n\t// 2. Validate Search Key\n\tif (typeof searchKey !== 'string' || !uniqueMemberKeys.includes(searchKey)) {\n\t\tlogEventsAndPrint(\n\t\t\t`Invalid search key for members table \"${searchKey}\". Must be one of: ${uniqueMemberKeys.join(', ')}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('Invalid search key.');\n\t}\n\n\t// 3. Validate Search Values\n\tif (\n\t\t!Array.isArray(searchValues) ||\n\t\tsearchValues.length === 0 ||\n\t\t!searchValues.every((value) => typeof value === 'string' || typeof value === 'number')\n\t) {\n\t\tlogEventsAndPrint(\n\t\t\t`Invalid search values for members table: ${jsutil.ensureJSONString(searchValues)}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('Invalid search values.');\n\t}\n}\n\n/**\n * Fetches specified columns of a single member from the database based on user_id, username, or email.\n * @param columns - The columns to retrieve (e.g., ['checkmates_beaten']).\n * @param searchKey - The search key to use. (e.g. 'username')\n * @param searchValue - The value to search for (e.g. 'user123').\n * @returns An object containing the requested columns, or undefined if no match is found.\n * @throws If invalid parameters are provided, or if a database error occurs during the query.\n */\nfunction getMemberDataByCriteria<K extends MembersColumn>(\n\tcolumns: K[],\n\tsearchKey: MembersColumn,\n\tsearchValue: string | number,\n): Pick<MemberRecord, K> | undefined {\n\t// Runtime validation\n\tvalidateMemberQueryArgs(columns, searchKey, [searchValue]);\n\n\tconst query = `SELECT ${columns.join(', ')} FROM members WHERE ${searchKey} = ?`;\n\n\ttry {\n\t\t// Execute the query and fetch result\n\t\treturn db.get<Pick<MemberRecord, K>>(query, [searchValue]);\n\t} catch (error: unknown) {\n\t\t// Log the error and rethrow a generic error\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(`Error getting member data by criteria: ${message}`, 'errLog.txt');\n\t\tthrow new Error('A database error occured.');\n\t}\n}\n\n/**\n * Fetches specified columns of multiple members from the database based on a list of user_ids, usernames, or emails.\n * @param columns - The columns to retrieve (e.g., ['user_id', 'username', 'roles']).\n * @param searchKey - The search key to use (e.g., 'checkmates_beaten').\n * @param searchValueList - The value to search for, can be a list of user IDs, usernames, or emails.\n * @returns An array of member records.\n * @throws If invalid parameters are provided, or if a database error occurs during the query.\n */\nfunction getMultipleMemberDataByCriteria<K extends MembersColumn>(\n\tcolumns: K[],\n\tsearchKey: MembersColumn,\n\tsearchValueList: string[] | number[],\n): Pick<MemberRecord, K>[] {\n\t// Runtime validation\n\tvalidateMemberQueryArgs(columns, searchKey, searchValueList);\n\n\t// Construct SQL query\n\tconst placeholders = searchValueList.map(() => '?').join(', ');\n\tconst query = `\n\t\tSELECT ${columns.join(', ')}\n\t\tFROM members\n\t\tWHERE ${searchKey} IN (${placeholders})\n\t`;\n\n\ttry {\n\t\t// Execute the query and fetch result\n\t\treturn db.all<Pick<MemberRecord, K>>(query, searchValueList);\n\t} catch (error: unknown) {\n\t\t// Log the error and rethrow a generic error\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error getting MULTIPLE member data by criteria: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('A database error occured.');\n\t}\n}\n\n/**\n * Updates specified columns for a member based on their user ID.\n * @param user_id - The user ID of the member to update.\n * @param columnsAndValues - An object mapping column names to their new values.\n * @returns A result object indicating if a change was made, which if not, may indicate the user_id does not exist.\n * @throws If invalid parameters are provided, or if a database error occurs.\n */\nfunction updateMemberColumns(\n\tuser_id: number,\n\tcolumnsAndValues: Partial<MemberRecord>,\n): { changeMade: boolean } {\n\t// Validate that we have columns to update\n\tif (typeof columnsAndValues !== 'object' || columnsAndValues === null) {\n\t\tlogEventsAndPrint(\n\t\t\t`Invalid columnsAndValues provided when updating member of ID \"${user_id}\": ${jsutil.ensureJSONString(columnsAndValues)}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('Invalid update parameters.');\n\t}\n\n\tconst columns = Object.keys(columnsAndValues);\n\tconst values = Object.values(columnsAndValues);\n\n\t// Validate they are all valid database columns\n\tif (\n\t\tcolumns.length === 0 ||\n\t\t!columns.every((col) => allMemberColumns.includes(col)) ||\n\t\t!values.every((val) => typeof val === 'string' || typeof val === 'number' || val === null)\n\t) {\n\t\tlogEventsAndPrint(\n\t\t\t`Invalid columns or values provided when updating member of ID \"${user_id}\": ${jsutil.ensureJSONString(columnsAndValues)}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('Invalid update parameters.');\n\t}\n\n\t// Dynamically build the SET part of the query\n\tconst setStatements = columns.map((column) => `${column} = ?`).join(', ');\n\tconst query = `UPDATE members SET ${setStatements} WHERE user_id = ?`;\n\n\ttry {\n\t\t// Execute the update query, appending user_id as the last parameter\n\t\tconst result = db.run(query, [...values, user_id]);\n\t\treturn { changeMade: result.changes > 0 };\n\t} catch (error: unknown) {\n\t\t// Log the error and rethrow a generic error\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error updating columns ${jsutil.ensureJSONString(columnsAndValues)} for user ID \"${user_id}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('A database error occurred.');\n\t}\n}\n\n// Login Count & Last Seen ---------------------------------------------------------------------------------------\n\n/**\n * Increments the login count and updates the last_seen column for a member based on their user ID.\n * @param userId - The user ID of the member.\n */\nfunction updateLoginCountAndLastSeen(userId: number): void {\n\t// SQL query to update the login_count and last_seen fields\n\tconst query = `\n\t\tUPDATE members\n\t\tSET login_count = login_count + 1, last_seen = CURRENT_TIMESTAMP\n\t\tWHERE user_id = ?\n\t`;\n\n\ttry {\n\t\t// Execute the query with the provided userId\n\t\tconst result = db.run(query, [userId]);\n\n\t\t// Log if no changes were made\n\t\tif (result.changes === 0)\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`No changes made when updating login_count and last_seen for member of id \"${userId}\"!`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t} catch (error: unknown) {\n\t\t// Log the error for debugging purposes\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error updating login_count and last_seen for member of id \"${userId}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t}\n}\n\n/**\n * Updates the last_seen column for a member based on their user ID.\n * @param userId - The user ID of the member.\n */\nfunction updateLastSeen(userId: number): void {\n\t// SQL query to update the last_seen field\n\tconst query = `\n\t\tUPDATE members\n\t\tSET last_seen = CURRENT_TIMESTAMP\n\t\tWHERE user_id = ?\n\t`;\n\n\ttry {\n\t\t// Execute the query with the provided userId\n\t\tconst result = db.run(query, [userId]);\n\n\t\t// Log if no changes were made\n\t\tif (result.changes === 0)\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`No changes made when updating last_seen for member of id \"${userId}\"!`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t} catch (error: unknown) {\n\t\t// Log the error for debugging purposes\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error updating last_seen for member of id \"${userId}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t}\n}\n\n// Utility -----------------------------------------------------------------------------------\n\n/**\n * Generates a unique user_id that no other member has ever used.\n * @throws If a database error occurs during uniqueness checks.\n */\nfunction genUniqueUserID(): number {\n\tlet id: number;\n\tdo {\n\t\tid = Math.floor(Math.random() * user_id_upper_cap);\n\t} while (isUserIdTaken(id));\n\treturn id;\n}\n\n/**\n * Checks if a member of a given id exists in the members table.\n * IGNORES whether the deleted_members table may contain the user_id.\n * @param user_id - The user ID to check.\n * @returns Returns true if the member exists, false otherwise.\n *\n * @throws If a database error occurs during the check.\n */\nfunction doesMemberOfIDExist(user_id: number): boolean {\n\ttry {\n\t\tconst query = 'SELECT EXISTS(SELECT 1 FROM members WHERE user_id = ?) AS found';\n\t\t// Execute query to check if the user_id exists in the members table\n\t\tconst row = db.get<{ found: 0 | 1 }>(query, [user_id]);\n\n\t\t// row.found will be 0 or 1\n\t\treturn Boolean(row?.found);\n\t} catch (error: unknown) {\n\t\t// Log the error if the query fails\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error checking if member of user_id (${user_id}) exists: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('A database error occurred.'); // Rethrow generic error\n\t}\n}\n\n/**\n * Checks if a given user_id exists in the members table OR deleted_members table.\n * @param userId - The user ID to check.\n * @returns Returns true if the user_id has been used, false otherwise.\n *\n * @throws If a database error occurs during the check.\n */\nfunction isUserIdTaken(userId: number): boolean {\n\ttry {\n\t\tconst query = `\n\t\t\tSELECT\n\t\t\t\tEXISTS(SELECT 1 FROM members WHERE user_id = ?)\n\t\t\t\tOR\n\t\t\t\tEXISTS(SELECT 1 FROM deleted_members WHERE user_id = ?)\n\t\t\tAS found\n\t\t`;\n\n\t\t// Execute query to check if the user_id exists in the members table\n\t\tconst row = db.get<{ found: 0 | 1 }>(query, [userId, userId]);\n\n\t\t// row.found will be 0 or 1\n\t\treturn Boolean(row?.found);\n\t} catch (error: unknown) {\n\t\t// Log the error if the query fails\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error checking if user_id (${userId}) has been used: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('A database error occurred.'); // Rethrow generic error\n\t}\n}\n// console.log(\"taken? \" + isUserIdTaken(14443702));\n\n/**\n * Checks if a member with the given username exists in the members table (case-insensitive,\n * a username is taken even if it has the same spelling but different capitalization).\n * @param username - The username to check.\n * @returns Returns true if the username exists, false otherwise.\n */\nfunction isUsernameTaken(username: string): boolean {\n\t// SQL query to check if a username exists in the 'members' table\n\tconst query = 'SELECT 1 FROM members WHERE username = ?';\n\n\ttry {\n\t\t// Execute the query with the username parameter\n\t\tconst row = db.get<{ '1': 1 }>(query, [username]);\n\n\t\t// If a row is found, the username exists\n\t\treturn row !== undefined;\n\t} catch (error: unknown) {\n\t\t// Log the error for debugging purposes\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error checking if username \"${username}\" is taken: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\n\t\t// Return false if there's an error (indicating the username is not found)\n\t\treturn false;\n\t}\n}\n\n/**\n * Checks if a member with the given email exists in the members table.\n * @param email - The email to check, in LOWERCASE.\n * @returns Returns true if the email exists, false otherwise.\n */\nfunction isEmailTaken(email: string): boolean {\n\t// SQL query to check if an email exists in the 'members' table\n\tconst query = 'SELECT 1 FROM members WHERE email = ?';\n\n\ttry {\n\t\t// Execute the query with the email parameter\n\t\tconst row = db.get<{ '1': 1 }>(query, [email]);\n\n\t\t// If a row is found, the email exists\n\t\treturn row !== undefined;\n\t} catch (error: unknown) {\n\t\t// Log error if the query fails\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(`Error checking if email \"${email}\" exists: ${message}`, 'errLog.txt');\n\t\treturn false; // Return false if there's an error\n\t}\n}\n\n// Exports -----------------------------------------------------------------------------\n\nexport {\n\tSQLITE_CONSTRAINT_ERROR,\n\taddUser,\n\tdeleteUser,\n\tgetMemberDataByCriteria,\n\tgetMultipleMemberDataByCriteria,\n\tupdateMemberColumns,\n\tupdateLoginCountAndLastSeen,\n\tupdateLastSeen,\n\tdoesMemberOfIDExist,\n\tisUsernameTaken,\n\tisEmailTaken,\n};\n"
  },
  {
    "path": "src/server/database/playerGamesManager.ts",
    "content": "// src/server/database/playerGamesManager.ts\n\n/**\n * This script handles queries to the player_games table.\n */\n\nimport type { Player } from '../../shared/chess/util/typeutil.js';\n\nimport jsutil from '../../shared/util/jsutil.js';\n\nimport db from './database.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js'; // Adjust path if needed\nimport { allPlayerGamesColumns } from './databaseTables.js';\n\n// Types ----------------------------------------------------------------------------------------------\n\n/** Structure of a complete player_games record. */\nexport interface PlayerGamesRecord {\n\tuser_id: number;\n\tgame_id: number;\n\tplayer_number: Player;\n\tscore: number | null;\n\tclock_at_end_millis: number | null;\n\telo_at_game: number | null;\n\telo_change_from_game: number | null;\n}\n\ntype PlayerGamesColumn = keyof PlayerGamesRecord;\n\n// Methods --------------------------------------------------------------------------------------------\n\n/**\n * Gets player_games entries for all opponents of a specific user for a list of specific games\n * @param user_id - The user_id of the player\n * @param game_id_list - A list of game_ids\n * @param columns - The columns to retrieve (e.g., ['user_id', 'player_number'])\n * @returns An array of objects with the requested columns from player_games.\n */\nfunction getOpponentsOfUserFromGames<K extends PlayerGamesColumn>(\n\tuser_id: number,\n\tgame_id_list: number[],\n\tcolumns: K[],\n): Pick<PlayerGamesRecord, K>[] {\n\t// Guard clauses... Validating the arguments...\n\n\tif (!Array.isArray(columns)) {\n\t\tlogEventsAndPrint(\n\t\t\t`When getting player_games data, columns must be an array of strings! Received: ${jsutil.ensureJSONString(columns)}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn [];\n\t}\n\tif (\n\t\t!columns.every(\n\t\t\t(column) => typeof column === 'string' && allPlayerGamesColumns.includes(column),\n\t\t)\n\t) {\n\t\tlogEventsAndPrint(\n\t\t\t`Invalid columns requested from player_games table: ${jsutil.ensureJSONString(columns)}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn [];\n\t}\n\n\t// Construct SQL query\n\tconst placeholders = game_id_list.map(() => '?').join(', ');\n\tconst query = `\n\t\tSELECT ${columns.join(', ')}\n\t\tFROM player_games\n\t\tWHERE user_id != ?\n\t\t\tAND game_id IN (${placeholders})\n\t`;\n\n\ttry {\n\t\t// Execute the query and fetch result\n\t\tconst rows = db.all<Pick<PlayerGamesRecord, K>>(query, [user_id, ...game_id_list]);\n\n\t\t// If no rows found, return undefined\n\t\tif (!rows || rows.length === 0) {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`No matches found in player_games table for game_ids: ${jsutil.ensureJSONString(game_id_list)}.`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t\treturn [];\n\t\t}\n\n\t\t// Return the fetched rows (single object)\n\t\treturn rows;\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error getting all player_games entries for game_id_list \"${jsutil.ensureJSONString(game_id_list)}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn [];\n\t}\n}\n\n/**\n * Retrieves the most recent N rated entries for a user on a specific leaderboard, returning only the specified columns from player_games.\n * Aborted games (where score is null) are skipped.\n * @param user_id - The ID of the user\n * @param leaderboard_id - The ID of the leaderboard to filter rated games\n * @param limit - Maximum number of recent games to fetch\n * @param columns - Array of column names from player_games to return (e.g., ['game_id', 'score']).\n * @returns Array of objects containing only the requested columns.\n */\nfunction getRecentNRatedGamesForUser<K extends PlayerGamesColumn>(\n\tuser_id: number,\n\tleaderboard_id: number,\n\tlimit: number,\n\tcolumns: K[],\n): Pick<PlayerGamesRecord, K>[] {\n\t// Validate columns argument\n\tif (!Array.isArray(columns)) {\n\t\tlogEventsAndPrint(\n\t\t\t`When fetching recent games, columns must be an array of strings! Received: ${jsutil.ensureJSONString(columns)}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn [];\n\t}\n\tif (!columns.every((col) => typeof col === 'string' && allPlayerGamesColumns.includes(col))) {\n\t\tlogEventsAndPrint(\n\t\t\t`Invalid columns requested from player_games table: ${jsutil.ensureJSONString(columns)}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn [];\n\t}\n\n\t// Dynamically build SELECT clause from requested columns\n\tconst selectClause = columns.map((col) => `pg.${col}`).join(', ');\n\n\t// Only include rated, non-aborted games on the specified leaderboard, sorted by game date\n\tconst query = `\n\t\tSELECT ${selectClause}\n\t\tFROM player_games pg\n\t\tJOIN games g ON g.game_id = pg.game_id\n\t\tWHERE pg.user_id = ?\n\t\t  AND g.rated = 1\n\t\t  AND g.leaderboard_id = ?\n\t\t  AND pg.score IS NOT NULL\n\t\tORDER BY g.date DESC\n\t\tLIMIT ?\n\t`;\n\n\ttry {\n\t\t// Bind parameters: user, leaderboard, and limit\n\t\treturn db.all<Pick<PlayerGamesRecord, K>>(query, [user_id, leaderboard_id, limit]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error fetching recent rated games for user ${user_id} on leaderboard ${leaderboard_id}: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn [];\n\t}\n}\n\n// Exports --------------------------------------------------------------------------------------------\n\nexport {\n\tgetOpponentsOfUserFromGames,\n\t// Commented out to emphasize this should not ever have to be used:\n\t// updatePlayerGamesColumns,\n\tgetRecentNRatedGamesForUser,\n};\n"
  },
  {
    "path": "src/server/database/ratingAbuseManager.ts",
    "content": "// src/server/database/ratingAbuseManager.ts\n\n/**\n * This script handles queries to the rating_abuse table.\n */\n\nimport type { RunResult } from 'better-sqlite3'; // Import necessary types\n\nimport jsutil from '../../shared/util/jsutil.js';\n\nimport db from './database.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js'; // Adjust path if needed\nimport { allRatingAbuseColumns } from './databaseTables.js';\n\n// Types ----------------------------------------------------------------------------------------------\n\n/** Structure of a complete rating_abuse record. */\ninterface RatingAbuseRecord {\n\tuser_id: number;\n\tleaderboard_id: number;\n\tgame_count_since_last_check: number | null;\n\tlast_alerted_at: string | null;\n}\n\ntype RatingAbuseColumn = keyof RatingAbuseRecord;\n\n/** The result of add/update operations */\ntype ModifyQueryResult = { success: true; result: RunResult } | { success: false; reason: string };\n\n// Methods --------------------------------------------------------------------------------------------\n\n/**\n * Adds an entry to the rating_abuse table\n * @param user_id - The id for the user\n * @param leaderboard_id - The id for the specific leaderboard\n * @returns A result object indicating success or failure.\n */\nfunction addEntryToRatingAbuseTable(user_id: number, leaderboard_id: number): ModifyQueryResult {\n\tconst query = `\n\tINSERT INTO rating_abuse (\n\t\tuser_id,\n\t\tleaderboard_id\n\t) VALUES (?, ?)\n\t`; // Only inserting user_id and leaderboard_id is needed if others have DB defaults or may be NULL\n\n\ttry {\n\t\t// Execute the query with the provided values\n\t\tconst result = db.run(query, [user_id, leaderboard_id]);\n\n\t\t// Return success result\n\t\treturn { success: true, result };\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t// Log the error for debugging purposes\n\t\tlogEventsAndPrint(\n\t\t\t`Error adding entry to rating_abuse table for user \"${user_id}\" and leaderboard \"${leaderboard_id}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\n\t\t// Return an error message\n\t\t// Check for specific constraint errors if possible (e.g., FOREIGN KEY failure)\n\t\tlet reason = 'Failed to add entry to rating_abuse table.';\n\t\tif (error instanceof Error && 'code' in error) {\n\t\t\t// Example check for better-sqlite3 specific error codes\n\t\t\tif (error.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') {\n\t\t\t\treason = '(User ID, Leaderboard ID) does not exist in the leaderboards table.';\n\t\t\t} else if (\n\t\t\t\terror.code === 'SQLITE_CONSTRAINT_UNIQUE' ||\n\t\t\t\terror.code === 'SQLITE_CONSTRAINT_PRIMARYKEY'\n\t\t\t) {\n\t\t\t\treason = '(User ID, Leaderboard ID) already exists in the rating_abuse table.';\n\t\t\t}\n\t\t}\n\t\treturn { success: false, reason };\n\t}\n}\n\n/**\n * Checks if an entry exists in the rating_abuse table.\n * Relies on the composite primary key (user_id, leaderboard_id).\n * @param user_id - The ID of the user to check.\n * @param leaderboard_id - The ID of the leaderboard to check within.\n * @returns True if the player exists on the specified leaderboard, false otherwise (including on error).\n */\nfunction isEntryInRatingAbuseTable(user_id: number, leaderboard_id: number): boolean {\n\t// Query to select a constant '1' if a matching row exists.\n\t// LIMIT 1 ensures the database can stop searching after finding the first match.\n\t// This is efficient, especially with the primary key index.\n\tconst query = `\n        SELECT 1\n        FROM rating_abuse\n        WHERE user_id = ? AND leaderboard_id = ?\n        LIMIT 1;\n    `;\n\n\ttry {\n\t\tconst result = db.get<{ '1': 1 }>(query, [user_id, leaderboard_id]);\n\n\t\t// If db.get returns anything (even an object like { '1': 1 }), it means a row was found.\n\t\t// If no row is found, db.get returns undefined.\n\t\t// The double negation (!!) converts a truthy value (the result object) to true,\n\t\t// and a falsy value (undefined) to false.\n\t\treturn !!result;\n\t} catch (error: unknown) {\n\t\t// Log any potential database errors during the check\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Error checking existence of rating_abuse entry for user \"${user_id}\" on leaderboard \"${leaderboard_id}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\n\t\t// On error, we cannot confirm existence, so return false.\n\t\treturn false;\n\t}\n}\n\n/**\n * Fetches specified columns of a single (user_id, leaderboard_id) from the rating_abuse table based on (user_id, leaderboard_id)\n * @param user_id - The user_id of the player\n * @param leaderboard_id - The leaderboard_id\n * @param columns - The columns to retrieve (e.g., ['game_count_since_last_check', 'last_alerted_at'])\n * @returns An object containing the requested columns, or undefined if no match is found.\n */\nfunction getRatingAbuseData<K extends RatingAbuseColumn>(\n\tuser_id: number,\n\tleaderboard_id: number,\n\tcolumns: K[],\n): Pick<RatingAbuseRecord, K> | undefined {\n\t// Guard clauses... Validating the arguments...\n\n\tif (!Array.isArray(columns)) {\n\t\tlogEventsAndPrint(\n\t\t\t`When getting rating_abuse data, columns must be an array of strings! Received: ${jsutil.ensureJSONString(columns)}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn undefined;\n\t}\n\tif (\n\t\t!columns.every(\n\t\t\t(column) => typeof column === 'string' && allRatingAbuseColumns.includes(column),\n\t\t)\n\t) {\n\t\tlogEventsAndPrint(\n\t\t\t`Invalid columns requested from rating_abuse table: ${jsutil.ensureJSONString(columns)}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn undefined;\n\t}\n\n\t// Arguments are valid, move onto the SQL query...\n\n\t// Construct SQL query\n\tconst query = `SELECT ${columns.join(', ')} FROM rating_abuse WHERE user_id = ? AND leaderboard_id = ?`;\n\n\ttry {\n\t\t// Execute the query and fetch result\n\t\tconst row = db.get<Pick<RatingAbuseRecord, K>>(query, [user_id, leaderboard_id]);\n\n\t\t// If no row is found, return undefined\n\t\tif (!row) {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`No matches found in rating_abuse table for user_id = ${user_id} and leaderboard_id = ${leaderboard_id}.`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t\treturn undefined;\n\t\t}\n\n\t\t// Return the fetched row (single object)\n\t\treturn row;\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t// Log the error and return undefined\n\t\tlogEventsAndPrint(\n\t\t\t`Error executing query when gettings rating_abuse entry of user_id ${user_id} and leaderboard_id = ${leaderboard_id}: ${message}. The query: \"${query}\"`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn undefined;\n\t}\n}\n\n/**\n * Updates multiple column values in the rating_abuse table for a given user.\n *\n * @param user_id - The user ID of the player.\n * @param leaderboard_id - The leaderboard_id\n * @param columnsAndValues - An object containing column-value pairs to update.\n * @returns - A result object indicating success or failure.\n */\nfunction updateRatingAbuseColumns(\n\tuser_id: number,\n\tleaderboard_id: number,\n\tcolumnsAndValues: Partial<RatingAbuseRecord>,\n): ModifyQueryResult {\n\t// Ensure columnsAndValues is an object and not empty\n\tif (typeof columnsAndValues !== 'object' || Object.keys(columnsAndValues).length === 0) {\n\t\tlogEventsAndPrint(\n\t\t\t`Invalid or empty columns and values provided for user ID \"${user_id}\" and leaderboard ID \"${leaderboard_id}\" when updating rating_abuse columns! Received: ${jsutil.ensureJSONString(columnsAndValues)}`,\n\t\t\t'errLog.txt',\n\t\t); // Detailed logging for debugging\n\t\treturn { success: false, reason: 'Invalid arguments.' }; // Generic error message\n\t}\n\n\tfor (const column in columnsAndValues) {\n\t\t// Validate all provided columns\n\t\tif (!allRatingAbuseColumns.includes(column)) {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`Invalid column \"${column}\" provided for user ID \"${user_id}\" and leaderboard ID \"${leaderboard_id}\" when updating rating_abuse columns! Received: ${jsutil.ensureJSONString(columnsAndValues)}`,\n\t\t\t\t'errLog.txt',\n\t\t\t); // Detailed logging for debugging\n\t\t\treturn { success: false, reason: 'Invalid column.' }; // Generic error message\n\t\t}\n\t}\n\n\t// Dynamically build the SET part of the query\n\tconst setStatements = Object.keys(columnsAndValues)\n\t\t.map((column) => `${column} = ?`)\n\t\t.join(', ');\n\tconst values = Object.values(columnsAndValues);\n\n\t// Add the user_id and leaderboard_id as the last parameters for the WHERE clause\n\tvalues.push(user_id, leaderboard_id);\n\n\t// Update query to modify multiple columns\n\tconst updateQuery = `UPDATE rating_abuse SET ${setStatements} WHERE user_id = ? AND leaderboard_id = ?`;\n\n\ttry {\n\t\t// Execute the update query\n\t\tconst result = db.run(updateQuery, values);\n\n\t\t// Check if the update was successful\n\t\tif (result.changes > 0) return { success: true, result };\n\t\telse {\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`No changes made when updating rating_abuse table columns ${JSON.stringify(columnsAndValues)} for entry in rating_abuse table with user ID \"${user_id}\" and leaderboard ID \"${leaderboard_id}\"! Received: ${jsutil.ensureJSONString(columnsAndValues)}`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t\treturn { success: false, reason: 'No changes made.' }; // Generic error message\n\t\t}\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t// Log the error for debugging purposes\n\t\tlogEventsAndPrint(\n\t\t\t`Error updating rating_abuse table columns ${JSON.stringify(Object.keys(columnsAndValues))} for user ID \"${user_id}\" and leaderboard ID \"${leaderboard_id}\": ${message}! Received: ${jsutil.ensureJSONString(columnsAndValues)}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\t// Return an error message\n\t\treturn { success: false, reason: 'Database error.' }; // Generic error message\n\t}\n}\n\n// Exports --------------------------------------------------------------------------------------------\n\nexport {\n\taddEntryToRatingAbuseTable,\n\tisEntryInRatingAbuseTable,\n\tgetRatingAbuseData,\n\tupdateRatingAbuseColumns,\n};\n"
  },
  {
    "path": "src/server/database/refreshTokenManager.ts",
    "content": "// src/server/database/refreshTokenManager.ts\n\n/**\n * This module manages refresh tokens in the database, providing functions\n * to add, find, delete, and update them in the `refresh_tokens` table.\n */\n\nimport type { Request } from 'express';\n\nimport db from './database.js';\nimport { getClientIP } from '../utility/IP.js';\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\nimport { refreshTokenExpiryMillis } from '../controllers/authenticationTokens/tokenSigner.js';\n\n/**\n * Represents a record in the `refresh_tokens` database table.\n */\nexport type RefreshTokenRecord = {\n\ttoken: string;\n\tuser_id: number;\n\t/** The Unix timestamp, in milliseconds, when the token was created. */\n\tcreated_at: number;\n\t/** The Unix timestamp, in milliseconds, when the token will expire. */\n\texpires_at: number;\n\t/** The last known IP address the user used this refresh token from. */\n\tip_address: string | null;\n\t/**\n\t * The Unix timestamp, in milliseconds, when the token was consumed for a session renewal.\n\t * Allow a small grace period for using old tokens when renewing sessions.\n\t */\n\tconsumed_at: number | null;\n};\n\n/**\n * Finds a refresh token in the database.\n * @param token - The JWT refresh token string.\n * @returns The token record if found, otherwise undefined.\n * @throws {Error} Throws a generic error if a database error occurs.\n */\nexport function findRefreshToken(token: string): RefreshTokenRecord | undefined {\n\tconst query = `\n        SELECT token, user_id, created_at, expires_at, consumed_at, ip_address\n        FROM refresh_tokens\n        WHERE token = ?\n    `;\n\ttry {\n\t\treturn db.get<RefreshTokenRecord>(query, [token]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(`Database error while finding refresh token: ${message}`, 'errLog.txt');\n\t\tthrow new Error('A database error occurred while processing the refresh token.');\n\t}\n}\n\n/**\n * Finds refresh token entries in the database associated with a list of user_ids\n * @param user_id_list - A list of user IDs\n * @returns A list of RefreshTokenRecords connected to the users in the user_id_list\n * @throws {Error} Throws a generic error if a database error occurs.\n */\nexport function findRefreshTokensForUsers(user_id_list: number[]): RefreshTokenRecord[] {\n\tconst placeholders = user_id_list.map(() => '?').join(', ');\n\tconst query = `\n        SELECT token, user_id, created_at, expires_at, ip_address\n        FROM refresh_tokens\n        WHERE user_id IN (${placeholders})\n    `;\n\ttry {\n\t\treturn db.all<RefreshTokenRecord>(query, user_id_list);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Database error while finding refresh tokens for users ${JSON.stringify(user_id_list)}: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('A database error occurred while processing the refresh token.');\n\t}\n}\n\n/**\n * Adds a new refresh token record to the database.\n * @param req - The Express request object to get the IP address.\n * @param userId - The ID of the user the token belongs to.\n * @param token - The new JWT refresh token string.\n * @throws {Error} Throws a generic error if a database error occurs.\n */\nexport function addRefreshToken(req: Request, userId: number, token: string): void {\n\tconst now = Date.now();\n\tconst query = `\n        INSERT INTO refresh_tokens (token, user_id, created_at, expires_at, ip_address)\n        VALUES (?, ?, ?, ?, ?)\n\t`;\n\tconst ip_address = getClientIP(req) || null;\n\ttry {\n\t\tdb.run(query, [\n\t\t\ttoken,\n\t\t\tuserId,\n\t\t\tnow, // created_at\n\t\t\tnow + refreshTokenExpiryMillis, // expires_at\n\t\t\tip_address,\n\t\t]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Database error while adding refresh token for userId ${userId}: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('A database error occurred while processing the refresh token.');\n\t}\n}\n\n/**\n * Deletes a specific refresh token from the database.\n * @param token - The token to delete.\n * @throws {Error} Throws a generic error if a database error occurs.\n */\nexport function deleteRefreshToken(token: string): void {\n\tconst query = `DELETE FROM refresh_tokens WHERE token = ?`;\n\ttry {\n\t\tdb.run(query, [token]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(`Database error while deleting refresh token: ${message}`, 'errLog.txt');\n\t\tthrow new Error('A database error occurred while processing the refresh token.');\n\t}\n}\n\n/**\n * Deletes all refresh tokens for a given user. Used for \"log out of all devices\".\n * Effectively terminates all login sessions for the user.\n * @param userId - The user's ID.\n * @throws {Error} Throws a generic error if a database error occurs.\n */\nexport function deleteAllRefreshTokensForUser(userId: number): void {\n\tconst query = `DELETE FROM refresh_tokens WHERE user_id = ?`;\n\ttry {\n\t\tdb.run(query, [userId]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Database error while deleting all refresh tokens for userId ${userId}: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('A database error occurred while processing the refresh token.');\n\t}\n}\n\n/**\n * Updates the IP address for a given token.\n * @param token - The token to update.\n * @param ip - The new IP address to record.\n * @throws {Error} Throws a generic error if a database error occurs.\n */\nexport function updateRefreshTokenIP(token: string, ip: string | null): void {\n\tconst query = `UPDATE refresh_tokens SET ip_address = ? WHERE token = ?`;\n\ttry {\n\t\tdb.run(query, [ip, token]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Database error while updating refresh token IP: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('A database error occurred while processing the refresh token.');\n\t}\n}\n\n/**\n * Marks a token as consumed (soft delete).\n * Used during rotation to allow a short grace period for concurrent requests.\n * @param token - The token to mark as consumed.\n * @throws {Error} Throws a generic error if a database error occurs.\n */\nexport function markRefreshTokenAsConsumed(token: string): void {\n\tconst now = Date.now();\n\tconst query = `UPDATE refresh_tokens SET consumed_at = ? WHERE token = ?`;\n\ttry {\n\t\tdb.run(query, [now, token]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(\n\t\t\t`Database error while marking refresh token as consumed: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tthrow new Error('A database error occurred while processing the refresh token.');\n\t}\n}\n"
  },
  {
    "path": "src/server/game/gamemanager/abortresigngame.ts",
    "content": "// src/server/game/gamemanager/abortresigngame.ts\n\n/**\n * This script handles the abortings and resignations of online games\n */\n\nimport type { ServerGame } from './gameutility.js';\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\n\nimport typeutil from '../../../shared/chess/util/typeutil.js';\n\nimport gameutility from './gameutility.js';\nimport { setGameConclusion } from './gamemanager.js';\n\n//--------------------------------------------------------------------------------------------------------\n\n/**\n * Called when a client tries to abort a game.\n * @param ws - The websocket\n * @param servergame - The game they are in..\n */\nfunction abortGame(_ws: CustomWebSocket, servergame: ServerGame): void {\n\t// Is it legal?...\n\n\tif (gameutility.isGameOver(servergame.basegame)) {\n\t\t// Return if game is already over\n\t\tconsole.log(\n\t\t\t`Player tried to abort game ${servergame.match.id} when the game is already over!`,\n\t\t);\n\t\treturn;\n\t} else if (gameutility.isGameBorderlineResignable(servergame.basegame)) {\n\t\t// A player might try to abort a game after his opponent has just played the second move due to latency issues...\n\t\t// In doubt, be lenient and allow him to abort here. DO NOT RETURN\n\t\tconsole.log(\n\t\t\t`Player tried to abort game ${servergame.match.id} when there's been exactly 2 moves played! Aborting game anyways...`,\n\t\t);\n\t} else if (gameutility.isGameResignable(servergame.basegame)) {\n\t\t// Return if player tries to abort when he does not have the right\n\t\tconsole.error(\n\t\t\t`Player tried to abort game ${servergame.match.id} when there's been at least 3 moves played!`,\n\t\t);\n\t\treturn;\n\t}\n\n\t// Abort\n\tsetGameConclusion(servergame, { condition: 'aborted' });\n}\n\n/**\n * Called when a client tries to resign a game.\n * @param ws - The websocket\n * @param servergame - The game they are in.\n */\nfunction resignGame(ws: CustomWebSocket, servergame: ServerGame): void {\n\t// Is it legal?...\n\n\tif (gameutility.isGameOver(servergame.basegame)) {\n\t\t// Return if game is already over\n\t\tconsole.log(\n\t\t\t`Player resign to resign game ${servergame.match.id} when the game is already over!`,\n\t\t);\n\t\treturn;\n\t} else if (!gameutility.isGameResignable(servergame.basegame)) {\n\t\t// Return if player tries to resign when he does not have the right\n\t\tconsole.error(\n\t\t\t`Player tried to resign game ${servergame.match.id} when there's less than 2 moves played! Ignoring..`,\n\t\t);\n\t\treturn;\n\t}\n\n\t// Resign\n\tconst ourColor =\n\t\tws.metadata.subscriptions.game?.color ||\n\t\tgameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws)!;\n\tconst opponentColor = typeutil.invertPlayer(ourColor);\n\tsetGameConclusion(servergame, { victor: opponentColor, condition: 'resignation' });\n}\n\nexport { abortGame, resignGame };\n"
  },
  {
    "path": "src/server/game/gamemanager/activeplayers.ts",
    "content": "// src/server/game/gamemanager/activeplayers.ts\n\n/**\n * This script keeps track of the ID's of games members and browsers are currently in.\n */\n\nimport type { Player } from '../../../shared/chess/util/typeutil.js';\nimport type { MatchInfo } from './gameutility.js';\nimport type { AuthMemberInfo } from '../../types.js';\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\n\n//--------------------------------------------------------------------------------------------------------\n\n/**\n * Contains what members are currently in a game: `{ member: gameID }`\n * Users that are present in this list are not allowed to join another game until they're\n * deleted from here. As soon as a game is over, we can {@link removeUserFromActiveGame()},\n * even though the game may not be deleted/logged yet.\n */\nconst membersInActiveGames: Record<number, number> = {};\n\n/**\n * Contains what browsers are currently in a game: `{ browser: gameID }`\n * Users that are present in this list are not allowed to join another game until they're\n * deleted from here. As soon as a game is over, we can {@link removeUserFromActiveGame()}\n * even though the game may not be deleted/logged yet.\n */\nconst browsersInActiveGames: Record<string, number> = {};\n\n//--------------------------------------------------------------------------------------------------------\n\n/**\n * Adds the user to the list of users currently in an active game.\n * Players in this are not allowed to join a second game.\n * @param id - The id of the game they are in.\n */\nfunction addUserToActiveGames(user: AuthMemberInfo, id: number): void {\n\tif (user.signedIn) membersInActiveGames[user.user_id] = id;\n\telse browsersInActiveGames[user.browser_id] = id;\n}\n\n/**\n * Removes the user from the list of users currently in an active game.\n * This allows them to join a new game.\n * Doesn't remove them if they are already in a new game of a different ID.\n * @param user - An object containing either the `member` or `browser` property.\n * @param gameID - The id of the game they are in.\n */\nfunction removeUserFromActiveGame(user: AuthMemberInfo, gameID: number): void {\n\t// Only removes them from the game if they belong to a game of that ID.\n\t// If they DON'T belong to that game, that means they speedily\n\t// resigned and started a new game, so don't modify this!\n\tif (user.signedIn) {\n\t\tif (membersInActiveGames[user.user_id] === gameID)\n\t\t\tdelete membersInActiveGames[user.user_id];\n\t\telse if (membersInActiveGames[user.user_id] !== undefined)\n\t\t\tconsole.log(\n\t\t\t\t'Not removing member from active games because they speedily joined a new game!',\n\t\t\t);\n\t} else {\n\t\tif (browsersInActiveGames[user.browser_id] === gameID)\n\t\t\tdelete browsersInActiveGames[user.browser_id];\n\t\telse if (browsersInActiveGames[user.browser_id] !== undefined)\n\t\t\tconsole.log(\n\t\t\t\t'Not removing browser from active games because they speedily joined a new game!',\n\t\t\t);\n\t}\n}\n\n/**\n * Returns true if the player behind the socket is already in an\n * active game, which means they're not allowed to join a new one.\n * @param ws - The websocket\n */\nfunction isSocketInAnActiveGame(ws: CustomWebSocket): boolean {\n\tconst player = ws.metadata.memberInfo;\n\t// Allow a member to still join a new game, even if they're browser may be connected to one already.\n\tif (player.signedIn) {\n\t\t// Their username trumps their browser id.\n\t\treturn player.user_id in membersInActiveGames;\n\t} else return player.browser_id in browsersInActiveGames;\n}\n\n/**\n * Returns true if the player behind the socket is not in an active game\n * of the provided ID (has seen the game conclusion).\n * @param match\n * @param color\n */\nfunction hasColorInGameSeenConclusion(match: MatchInfo, color: Player): boolean {\n\tconst player = match.playerData[color];\n\tif (!player)\n\t\tthrow new Error(\n\t\t\t`Invalid color \"${color}\" when checking if color in game has seen game conclusion!`,\n\t\t);\n\n\treturn getIDOfGamePlayerIsIn(player.identifier) !== match.id;\n}\n\n/**\n * Gets a game by player.\n * @param player - The player object containing all the memberinfo\n * @returns The game they are in, if they belong in one, otherwise undefined.\n */\nfunction getIDOfGamePlayerIsIn(player: AuthMemberInfo): number | undefined {\n\tif (player.signedIn) return membersInActiveGames[player.user_id];\n\telse return browsersInActiveGames[player.browser_id];\n}\n\n//--------------------------------------------------------------------------------------------------------\n\nexport {\n\taddUserToActiveGames,\n\tremoveUserFromActiveGame,\n\tisSocketInAnActiveGame,\n\thasColorInGameSeenConclusion,\n\tgetIDOfGamePlayerIsIn,\n};\n"
  },
  {
    "path": "src/server/game/gamemanager/afkdisconnect.ts",
    "content": "// src/server/game/gamemanager/afkdisconnect.ts\n\n/**\n * The script handles the setting, resetting, and cancellation\n * of both the auto resign timer when players go AFK in online games,\n * and the disconnection timer when they leave the page / lose internet.\n */\n\nimport type { Player } from '../../../shared/chess/util/typeutil.js';\nimport type { MatchInfo, ServerGame } from './gameutility.js';\n\nimport typeutil from '../../../shared/chess/util/typeutil.js';\n\nimport gameutility from './gameutility.js';\n\n//--------------------------------------------------------------------------------------------------------\n\n/**\n * The time to give players who disconnected not by choice\n * (network interruption) to reconnect to the game before\n * we tell their opponent they've disconnected, and start an auto-resign timer.\n */\nconst timeToGiveDisconnectedBeforeStartingAutoResignTimerMillis = 5_000; // 5 seconds\n\n/**\n * The duration of the auto-resign timer by disconnect, when the player\n * has intentionally left the page.\n */\nconst timeBeforeAutoResignByDisconnectMillis = 20_000; // 20 seconds\n/**\n * The duration of the auto-resign timer by disconnect (more forgiving),\n * when the player's internet cuts out.\n */\nconst timeBeforeAutoResignByDisconnectMillis_NotByChoice = 60_000; // 60 seconds\n\n//--------------------------------------------------------------------------------------------------------\n\n/**\n * Cancels the timer that automatically resigns a player due to being AFK (Away From Keyboard).\n * This function should be called when the \"AFK-Return\" websocket action is received, indicating\n * that the player has returned, OR when a client refreshes the page!\n */\nfunction cancelAutoAFKResignTimer(servergame: ServerGame, alertOpponent: boolean = false): void {\n\tif (servergame.match.autoAFKResignTime !== undefined && alertOpponent) {\n\t\t// Alert their opponent\n\t\tconst opponentColor = typeutil.invertPlayer(servergame.basegame.whosTurn);\n\t\tgameutility.sendMessageToSocketOfColor(\n\t\t\tservergame.match,\n\t\t\topponentColor,\n\t\t\t'game',\n\t\t\t'opponentafkreturn',\n\t\t);\n\t}\n\n\tclearTimeout(servergame.match.autoAFKResignTimeoutID);\n\tservergame.match.autoAFKResignTimeoutID = undefined;\n\tservergame.match.autoAFKResignTime = undefined;\n}\n\n//--------------------------------------------------------------------------------------------------------\n\n/**\n * Starts a timer to auto-resign a player from disconnection.\n * @param servergame - The game\n * @param color - The color to start the auto-resign timer for\n * @param closureNotByChoice - True if the player didn't close the connection on purpose.\n * @param onAutoResignFunc - The function to call when the player should be auto resigned from disconnection. This should have 2 arguments: The game, and the color that won.\n */\nfunction startDisconnectTimer(\n\tservergame: ServerGame,\n\tcolor: Player,\n\tclosureNotByChoice: boolean,\n\tonAutoResignFunc: (_game: ServerGame, _winner: Player) => void,\n): void {\n\t// console.log(`Starting disconnect timer to auto resign player ${color}.`);\n\n\tconst now = Date.now();\n\tconst resignable = gameutility.isGameResignable(servergame.basegame);\n\n\tlet timeBeforeAutoResign =\n\t\tclosureNotByChoice && resignable\n\t\t\t? timeBeforeAutoResignByDisconnectMillis_NotByChoice\n\t\t\t: timeBeforeAutoResignByDisconnectMillis;\n\t// console.log(`Time before auto resign: ${timeBeforeAutoResign}`)\n\tlet timeToAutoLoss = now + timeBeforeAutoResign;\n\n\t// Is there an afk timer already running for them?\n\t// If so, delete it, transferring it's time remaining to this disconnect timer.\n\t// We can do this because if player is disconnected, they are afk anyway.\n\t// And if if they reconnect, then they're not afk anymore either.\n\tif (\n\t\tservergame.basegame.whosTurn === color &&\n\t\tservergame.match.autoAFKResignTime !== undefined\n\t) {\n\t\tif (servergame.match.autoAFKResignTime > timeToAutoLoss)\n\t\t\tconsole.error(\n\t\t\t\t\"The time to auto-resign by AFK should not be greater than time to auto-resign by disconnect. We shouldn't be overwriting the AFK timer.\",\n\t\t\t);\n\t\ttimeToAutoLoss = servergame.match.autoAFKResignTime;\n\t\ttimeBeforeAutoResign = timeToAutoLoss - now;\n\t\tcancelAutoAFKResignTimer(servergame);\n\t}\n\n\tconst playerdata = servergame.match.playerData[color]!;\n\tconst opponentColor = typeutil.invertPlayer(color);\n\n\t// Clear the cushion timer state since we're transitioning to the auto-resign timer.\n\tplayerdata.disconnect.startTime = undefined;\n\n\tplayerdata.disconnect.timeoutID = setTimeout(\n\t\t() => onAutoResignFunc(servergame, opponentColor),\n\t\ttimeBeforeAutoResign,\n\t);\n\tplayerdata.disconnect.timeToAutoLoss = timeToAutoLoss;\n\tplayerdata.disconnect.wasByChoice = !closureNotByChoice;\n\n\t// Alert their opponent the time their opponent will be auto-resigned by disconnection.\n\tconst value = {\n\t\tmillisUntilAutoDisconnectResign: timeBeforeAutoResign,\n\t\twasByChoice: !closureNotByChoice,\n\t};\n\tgameutility.sendMessageToSocketOfColor(\n\t\tservergame.match,\n\t\topponentColor,\n\t\t'game',\n\t\t'opponentdisconnect',\n\t\tvalue,\n\t);\n}\n\n/**\n * Cancels both players timers to auto-resign them from disconnection if they were disconnected.\n * Typically called when a game ends.\n * @param match - The match\n */\nfunction cancelDisconnectTimers(match: MatchInfo): void {\n\tfor (const color of Object.keys(match.playerData)) {\n\t\tcancelDisconnectTimer(match, Number(color) as Player, true);\n\t}\n}\n\n/**\n * Cancels the player's timer to auto-resign them from disconnection if they were disconnected.\n * This is called when they reconnect/refresh.\n * @param match - The game\n * @param color - The color to cancel the timer for\n */\nfunction cancelDisconnectTimer(\n\tmatch: MatchInfo,\n\tcolor: Player,\n\tdontNotifyOpponent: boolean = false,\n): void {\n\t// console.log(`Canceling disconnect timer for player ${color}!`)\n\n\t/** Whether the timer (not the cushion to start the timer) for auto-resigning is RUNNING! */\n\tconst autoResignTimerWasRunning = gameutility.isAutoResignDisconnectTimerActiveForColor(\n\t\tmatch,\n\t\tcolor,\n\t);\n\n\tconst playerdata = match.playerData[color]!;\n\n\tclearTimeout(playerdata.disconnect.startID);\n\tclearTimeout(playerdata.disconnect.timeoutID);\n\tplayerdata.disconnect.startID = undefined;\n\tplayerdata.disconnect.startTime = undefined;\n\tplayerdata.disconnect.timeoutID = undefined;\n\tplayerdata.disconnect.timeToAutoLoss = undefined;\n\tplayerdata.disconnect.wasByChoice = undefined;\n\n\tif (dontNotifyOpponent) return;\n\n\t// Alert their opponent their opponent has returned...\n\n\tif (!autoResignTimerWasRunning) return; // Opponent was never notified their opponent was afk, skip telling them their opponent has returned.\n\n\tconst opponentColor = typeutil.invertPlayer(color);\n\tgameutility.sendMessageToSocketOfColor(\n\t\tmatch,\n\t\topponentColor,\n\t\t'game',\n\t\t'opponentdisconnectreturn',\n\t);\n}\n\n//--------------------------------------------------------------------------------------------------------\n\nexport {\n\ttimeToGiveDisconnectedBeforeStartingAutoResignTimerMillis,\n\tcancelAutoAFKResignTimer,\n\tstartDisconnectTimer,\n\tcancelDisconnectTimers,\n\tcancelDisconnectTimer,\n};\n"
  },
  {
    "path": "src/server/game/gamemanager/cheatreport.ts",
    "content": "// src/server/game/gamemanager/cheatreport.ts\n\n/**\n * This script handles cheat reports, aborting games when they come in.\n */\n\nimport type { Player } from '../../../shared/chess/util/typeutil.js';\nimport type { ServerGame } from './gameutility.js';\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\n\nimport * as z from 'zod';\n\nimport typeutil from '../../../shared/chess/util/typeutil.js';\nimport { isGameInstantlyDeleted } from '../../../shared/chess/variants/servervalidation.js';\n\nimport gameutility from './gameutility.js';\nimport { logEvents } from '../../middleware/logEvents.js';\nimport { setGameConclusion } from './gamemanager.js';\n\n/** The zod schema for validating the contents of the cheatreport message. */\nconst reportschem = z.strictObject({\n\t/** The client's reason they reported their opponent. */\n\treason: z.string(),\n\topponentsMoveNumber: z.int(),\n});\n\ntype ReportMessage = z.infer<typeof reportschem>;\n\n/**\n *\n * @param ws - The socket\n * @param servergame - The game they belong in.\n * @param messageContents - The contents of the socket report message\n */\nfunction onReport(\n\tws: CustomWebSocket,\n\tservergame: ServerGame,\n\tmessageContents: ReportMessage,\n): void {\n\t// { reason, opponentsMoveNumber }\n\tconsole.log('Received cheat report! - Check hackLog.txt for more details.');\n\n\tconst ourColor =\n\t\tws.metadata.subscriptions.game?.color ||\n\t\tgameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws)!;\n\tconst opponentColor = typeutil.invertPlayer(ourColor);\n\n\t// Cheat reports are only valid in games that are not instantly deleted on conclusion.\n\t// (i.e. games without server-side move validation AND are public)\n\tif (\n\t\tisGameInstantlyDeleted(\n\t\t\tservergame.match.variant,\n\t\t\tservergame.basegame.dateTimestamp,\n\t\t\tservergame.match.publicity === 'private',\n\t\t)\n\t) {\n\t\tconst errString = `Player tried to report cheating in a game that doesn't support cheat reports. Variant: ${servergame.match.variant}. Publicity: ${servergame.match.publicity}. Report message: ${JSON.stringify(messageContents)}. Reporter color: ${ourColor}. Game ID: ${servergame.match.id}`;\n\t\tlogEvents(errString, 'hackLog.txt');\n\t\tgameutility.sendMessageToSocketOfColor(\n\t\t\tservergame.match,\n\t\t\tourColor,\n\t\t\t'general',\n\t\t\t'printerror',\n\t\t\t'Cannot report opponent in this game.',\n\t\t);\n\t\treturn;\n\t}\n\n\tconst perpetratingMoveIndex = servergame.basegame.moves.length - 1;\n\tconst colorThatPlayedPerpetratingMove = gameutility.getColorThatPlayedMoveIndex(\n\t\tservergame.basegame,\n\t\tperpetratingMoveIndex,\n\t);\n\tif (colorThatPlayedPerpetratingMove === ourColor) {\n\t\tconst errString = `Silly goose player tried to report themselves for cheating. Report message: ${JSON.stringify(messageContents)}. Reporter color: ${ourColor}.\\nThe game: ${gameutility.getSimplifiedGameString(servergame)}`;\n\t\tlogEvents(errString, 'hackLog.txt');\n\t\tgameutility.sendMessageToSocketOfColor(\n\t\t\tservergame.match,\n\t\t\tourColor,\n\t\t\t'general',\n\t\t\t'printerror',\n\t\t\t\"Silly goose. You can't report yourself for cheating! You played that move!\",\n\t\t);\n\t\treturn;\n\t}\n\t// Remove the last move played.\n\tconst perpetratingMove = servergame.basegame.moves.pop();\n\tif (!perpetratingMove) return;\n\n\tconst opponentsMoveNumber = messageContents.opponentsMoveNumber;\n\n\tconst errText = `Cheating reported! Perpetrating move: ${perpetratingMove.token}. Move number: ${opponentsMoveNumber}. The report description: ${messageContents.reason} Color who reported: ${ourColor}. Probably cheater color: ${opponentColor}.\\nThe game: ${gameutility.getSimplifiedGameString(servergame)}`;\n\tconsole.error(errText);\n\tlogEvents(errText, 'hackLog.txt');\n\n\tfor (const playerStr in servergame.match.playerData) {\n\t\tconst player: Player = Number(playerStr) as Player;\n\t\tconst isSuspectedCheater = player === opponentColor;\n\t\tif (isSuspectedCheater) {\n\t\t\tgameutility.sendMessageToSocketOfColor(\n\t\t\t\tservergame.match,\n\t\t\t\tplayer,\n\t\t\t\t'general',\n\t\t\t\t'notifyerror',\n\t\t\t\t'server.javascript.ws-you_cheated',\n\t\t\t);\n\t\t} else {\n\t\t\tgameutility.sendMessageToSocketOfColor(\n\t\t\t\tservergame.match,\n\t\t\t\tplayer,\n\t\t\t\t'general',\n\t\t\t\t'notify',\n\t\t\t\t'server.javascript.ws-opponent_cheated',\n\t\t\t);\n\t\t}\n\t}\n\t// Cheating report was valid, terminate the game..\n\n\tsetGameConclusion(servergame, { condition: 'aborted' });\n}\n\nexport { onReport, reportschem };\n"
  },
  {
    "path": "src/server/game/gamemanager/drawoffers.ts",
    "content": "// src/server/game/gamemanager/drawoffers.ts\n\n/**\n * This script contains utility methods for draw offers,\n * and has almost zero dependancies.\n *\n * It does NOT contain the routes for when a player\n * extends/accepts a draw offer!\n * NOR does it send any websocket messages.\n */\n\nimport type { Player } from '../../../shared/chess/util/typeutil.js';\nimport type { MatchInfo, ServerGame } from './gameutility.js';\n\nimport { logEventsAndPrint } from '../../middleware/logEvents.js';\n\n//--------------------------------------------------------------------------------------------------------\n\n/**\n * Minimum number of plies (half-moves) that\n * must span between 2 consecutive draw offers\n * by the same player!\n *\n * THIS MUST ALWAYS MATCH THE CLIENT-SIDE!!!!\n */\nconst movesBetweenDrawOffers = 2;\n\n//--------------------------------------------------------------------------------------------------------\n\n/**\n * Returns true if the game currently has an open draw offer.\n * If so, players are not allowed to extend another.\n */\nfunction isDrawOfferOpen(match: MatchInfo): boolean {\n\treturn match.drawOfferState !== undefined;\n}\n\n/**\n * Returns true if the given color has extended a draw offer that's not confirmed yet.\n * @param color - The color who extended the draw offer\n */\nfunction doesColorHaveExtendedDrawOffer(match: MatchInfo, color: Player): boolean {\n\treturn match.drawOfferState === color;\n}\n\n/**\n * Returns true if they given color has extended a draw offer\n * too recently for them to extend another, yet.\n */\nfunction hasColorOfferedDrawTooFast({ match, basegame }: ServerGame, color: Player): boolean {\n\tconst lastPlyDrawOffered = getLastDrawOfferPlyOfColor(match, color); // number | undefined\n\tif (lastPlyDrawOffered !== undefined) {\n\t\t// They have made at least 1 offer this game\n\t\t// console.log(\"Last ply offered:\", lastPlyDrawOffered);\n\t\tconst movesSinceLastOffer = basegame.moves.length - lastPlyDrawOffered;\n\t\tif (movesSinceLastOffer < movesBetweenDrawOffers) return true;\n\t}\n\treturn false;\n}\n\n/**\n * Opens a draw offer, extended by the provided color.\n * DOES NOT INFORM the opponent.\n * @param color - The color of the player extending the offer\n */\nfunction openDrawOffer({ match, basegame }: ServerGame, color: Player): void {\n\tif (isDrawOfferOpen(match)) {\n\t\tlogEventsAndPrint(\n\t\t\t\"MUST NOT open a draw offer when there's already one open!!\",\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn;\n\t}\n\tconst playerdata = match.playerData[color]!;\n\tplayerdata.lastOfferPly = basegame.moves.length;\n\tmatch.drawOfferState = color;\n\treturn;\n}\n\n/**\n * Closes any open draw offer.\n * DOES NOT INFORM the opponent.\n */\nfunction closeDrawOffer(match: MatchInfo): void {\n\tmatch.drawOfferState = undefined;\n}\n\n/**\n * Returns the last ply move the provided color has offered a draw,\n * if they have, otherwise undefined.\n */\nfunction getLastDrawOfferPlyOfColor(match: MatchInfo, color: Player): number | undefined {\n\treturn match.playerData[color]?.lastOfferPly;\n}\n\n//--------------------------------------------------------------------------------------------------------\n\nexport {\n\tisDrawOfferOpen,\n\tdoesColorHaveExtendedDrawOffer,\n\thasColorOfferedDrawTooFast,\n\topenDrawOffer,\n\tcloseDrawOffer,\n\tgetLastDrawOfferPlyOfColor,\n};\n"
  },
  {
    "path": "src/server/game/gamemanager/gamecount.ts",
    "content": "// src/server/game/gamemanager/gamecount.ts\n\n/**\n * Derives the active game count from the activeGames object in gamemanager.ts.\n */\n\nimport { activeGames } from './gamemanager.js';\nimport { broadcastToAllInviteSubs } from '../invitesmanager/invitessubscribers.js';\n\n/** Broadcasts the current game count to all sockets subscribed to the invites list. */\nfunction broadcastGameCountToInviteSubs(): void {\n\tbroadcastToAllInviteSubs('gamecount', getActiveGameCount());\n}\n\n/** Returns the active game count. */\nfunction getActiveGameCount(): number {\n\treturn Object.keys(activeGames).length;\n}\n\nexport { getActiveGameCount, broadcastGameCountToInviteSubs };\n"
  },
  {
    "path": "src/server/game/gamemanager/gamelogger.ts",
    "content": "// src/server/game/gamemanager/gamelogger.ts\n\n/**\n * This script logs all completed games into the \"games\" database table\n * It also computes the players' ratings in rated games and logs them into the \"ratings\" table\n * It also updates the players' stats in the \"players_stats\" table\n */\n\nimport type { Game } from '../../../shared/chess/logic/gamefile.js';\nimport type { RatingData } from './ratingcalculation.js';\nimport type { MatchInfo, ServerGame } from './gameutility.js';\n\nimport timeutil from '../../../shared/util/timeutil.js';\nimport clockutil from '../../../shared/chess/util/clockutil.js';\nimport metadatautil from '../../../shared/chess/util/metadatautil.js';\nimport icnconverter from '../../../shared/chess/logic/icn/icnconverter.js';\nimport { VariantLeaderboards } from '../../../shared/chess/variants/validleaderboard.js';\nimport { PlayerGroup, Player, players } from '../../../shared/chess/util/typeutil.js';\n\nimport db from '../../database/database.js';\nimport gameutility from './gameutility.js';\nimport { logEvents, logEventsAndPrint } from '../../middleware/logEvents.js';\nimport {\n\tcomputeRatingDataChanges,\n\tDEFAULT_LEADERBOARD_ELO,\n\tDEFAULT_LEADERBOARD_RD,\n} from './ratingcalculation.js';\nimport {\n\taddUserToLeaderboard,\n\tgetPlayerLeaderboardRating_core,\n\tisPlayerInLeaderboard,\n\tupdatePlayerLeaderboardRating,\n} from '../../database/leaderboardsManager.js';\n\n// Functions -------------------------------------------------------------------------------\n\n/**\n * Logs a completed game to the database by executing an atomic transaction.\n * Adds to and updates tables: games, player_games, player_stats, and leaderboards.\n * Either all database queries succeed, or none do (rollback on error).\n * This ensures data integrity and consistency.\n * @param servergame - The game to log\n * @returns The rating data if the game was rated and not aborted, otherwise undefined.\n */\nfunction logGame(servergame: ServerGame): RatingData | undefined {\n\tif (servergame.basegame.moves.length === 0) return; // Don't log games with zero moves\n\n\ttry {\n\t\t// Create the transaction by wrapping our orchestrator function.\n\t\t// We no longer need to pass any parameters here.\n\t\tconst transaction = db.transaction<[ServerGame], RatingData | undefined>((g) => {\n\t\t\treturn logGame_orchestrator(g);\n\t\t});\n\n\t\t// Execute the transaction. Typically takes 2-8 milliseconds when using NVME storage.\n\t\tconst ratingData = transaction(servergame);\n\n\t\t// If we reach here, the transaction was successful.\n\t\treturn ratingData;\n\t} catch (error) {\n\t\t// This block will only execute if the orchestrator throws an error, causing a rollback.\n\t\tconst errorMessage = error instanceof Error ? error.message : String(error);\n\t\tconst errorStack = error instanceof Error ? error.stack : 'No stack trace available';\n\t\tvoid logEventsAndPrint(\n\t\t\t`FATAL: Game log transaction failed and was rolled back for Game ID ${servergame.match.id}. Check unloggedGames log. Error: ${errorMessage}\\n${errorStack}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\tvoid logEvents(\n\t\t\t`Game: ${gameutility.getSimplifiedGameString(servergame)}`,\n\t\t\t'unloggedGames.txt',\n\t\t);\n\t\treturn;\n\t}\n}\n\n/**\n * This is the core orchestrator that runs INSIDE the transaction of logging the game.\n * It performs all reads, calculations, and writes in a single, atomic operation.\n * It is designed to throw an error on any failure to trigger a rollback of the database.\n * Either ALL operations succeed, or NONE do.\n */\nfunction logGame_orchestrator(servergame: ServerGame): RatingData | undefined {\n\tconst { victor, condition: termination } = servergame.basegame.gameConclusion!;\n\n\t// --- Part 1: Handle Rating Updates ---\n\tconst ratingData = updateLeaderboardsInTransaction(servergame.match, victor);\n\t// Immediately stamp the rating diffs onto the game's metadata so that\n\t// they're present for ICN generation and any other downstream use.\n\tif (ratingData !== undefined) {\n\t\tservergame.basegame.metadata.WhiteRatingDiff = metadatautil.getWhiteBlackRatingDiff(\n\t\t\tratingData[players.WHITE]!.elo_change_from_game!,\n\t\t);\n\t\tservergame.basegame.metadata.BlackRatingDiff = metadatautil.getWhiteBlackRatingDiff(\n\t\t\tratingData[players.BLACK]!.elo_change_from_game!,\n\t\t);\n\t}\n\n\t// --- Part 2: Create Game Records in games and player_games tables ---\n\taddGameRecordsInTransaction(servergame, victor, termination, ratingData);\n\n\t// --- Part 3: Update Player Stats ---\n\tupdateAllPlayerStatsInTransaction(servergame, victor);\n\n\t// If all steps succeed, return the rating data.\n\treturn ratingData;\n}\n\n/**\n * Updates leaderboards within the transaction. It calculates rating changes\n * and calls the unsafe (error-throwing) _core functions to update the database.\n * @returns The final rating data object, or undefined if the game was not rated, or aborted.\n * @throws An error if any database write fails.\n */\nfunction updateLeaderboardsInTransaction(\n\tmatch: MatchInfo,\n\tvictor: Player | null | undefined,\n): RatingData | undefined {\n\tif (!match.rated || victor === undefined) return undefined; // If game is unrated or aborted, then no ratings get updated\n\n\tconst leaderboard_id = VariantLeaderboards[match.variant]!; // Will always be defined if the game is rated.\n\n\t// 1. Build initial rating data by reading from the DB.\n\tlet ratingdata: RatingData = {};\n\tfor (const playerStr in match.playerData) {\n\t\tconst player: Player = Number(playerStr) as Player;\n\t\tconst user_id = match.playerData[player]?.identifier.signedIn\n\t\t\t? match.playerData[player].identifier.user_id\n\t\t\t: undefined;\n\t\tif (user_id === undefined)\n\t\t\tthrow new Error(\n\t\t\t\t`Attempted to process rating for player ${playerStr} in rated game ${match.id} without a user_id.`,\n\t\t\t);\n\n\t\t// If a player isn't on the leaderboard, add them first.\n\t\t// We use the _core (error-throwing) version because we are inside a transaction.\n\t\tif (!isPlayerInLeaderboard(user_id, leaderboard_id)) {\n\t\t\taddUserToLeaderboard(\n\t\t\t\tuser_id,\n\t\t\t\tleaderboard_id,\n\t\t\t\tDEFAULT_LEADERBOARD_ELO,\n\t\t\t\tDEFAULT_LEADERBOARD_RD,\n\t\t\t);\n\t\t}\n\n\t\t// We can now safely assume the player has a rating record.\n\t\tconst leaderboard_data = getPlayerLeaderboardRating_core(user_id, leaderboard_id);\n\t\tif (leaderboard_data === undefined)\n\t\t\tthrow Error(\n\t\t\t\t`Unable to read leaderboard data for user_id ${user_id} in leaderboard ${leaderboard_id}. This should never happen, they should have been added!`,\n\t\t\t);\n\n\t\tratingdata[player] = {\n\t\t\telo_at_game: leaderboard_data.elo,\n\t\t\trating_deviation_at_game: leaderboard_data.rating_deviation,\n\t\t\trd_last_update_date: leaderboard_data.rd_last_update_date,\n\t\t};\n\t}\n\n\t// 2. Calculate the new ratings.\n\tratingdata = computeRatingDataChanges(ratingdata, victor);\n\n\t// 3. Write the new ratings to the database.\n\tfor (const playerStr in ratingdata) {\n\t\tconst player: Player = Number(playerStr) as Player;\n\t\t// TS is annoying sometimes, we already know all the players have user_ids\n\t\tconst user_id = match.playerData[player]!.identifier.signedIn\n\t\t\t? match.playerData[player]!.identifier.user_id\n\t\t\t: undefined;\n\t\tconst data = ratingdata[player]!;\n\t\tupdatePlayerLeaderboardRating(\n\t\t\tuser_id!,\n\t\t\tleaderboard_id,\n\t\t\tdata.elo_after_game!,\n\t\t\tdata.rating_deviation_after_game!,\n\t\t);\n\t}\n\n\treturn ratingdata;\n}\n\n/**\n * [INTERNAL] Adds records to `games` and `player_games` tables. This function contains the \"merged logic\". Throws on error.\n * @returns The new game_id.\n */\nfunction addGameRecordsInTransaction(\n\t{ match, basegame }: ServerGame,\n\tvictor: Player | null | undefined,\n\ttermination: string,\n\tratingData: RatingData | undefined,\n): void {\n\tconst { base_time_seconds, increment_seconds } = clockutil.splitTimeControl(match.clock);\n\n\t// --- Prepare ICN ---\n\tconst icn = getICNOfGame(basegame); // This will throw on failure.\n\n\tconst dateSqliteString = timeutil.timestampToSqlite(match.timeCreated);\n\n\t// 1. Insert the main record into the 'games' table.\n\tconst gameQuery = `\n\t\tINSERT INTO games (\n\t\t\tgame_id, date, base_time_seconds, increment_seconds, variant, rated,\n\t\t\tleaderboard_id, private, result, termination, move_count,\n\t\t\ttime_duration_millis, icn\n\t\t) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;\n\n\tconst gameResult = db.run(gameQuery, [\n\t\tmatch.id,\n\t\tdateSqliteString,\n\t\tbase_time_seconds,\n\t\tincrement_seconds,\n\t\tmatch.variant,\n\t\tmatch.rated ? 1 : 0,\n\t\tVariantLeaderboards[match.variant] ?? null,\n\t\tmatch.publicity === 'private' ? 1 : 0,\n\t\tbasegame.metadata.Result!,\n\t\ttermination,\n\t\tbasegame.moves.length,\n\t\tmatch.timeEnded ? match.timeEnded - match.timeCreated : null,\n\t\ticn, // Use the pre-generated ICN\n\t]);\n\tconst game_id = gameResult.lastInsertRowid as number;\n\n\t// 2. Loop through players and insert records into the 'player_games' table.\n\tconst playerGamesQuery = `\n\t\tINSERT INTO player_games (\n\t\t\tuser_id, game_id, player_number, score,\n\t\t\tclock_at_end_millis, elo_at_game, elo_change_from_game\n\t\t) VALUES (?, ?, ?, ?, ?, ?, ?)`;\n\n\tconst ending_clocks = !basegame.untimed\n\t\t? gameutility.getGameClockValues(basegame).clocks\n\t\t: undefined;\n\tfor (const playerStr in match.playerData) {\n\t\tconst player = Number(playerStr) as Player;\n\t\tconst user_id = match.playerData[player]!.identifier.signedIn\n\t\t\t? match.playerData[player]!.identifier.user_id\n\t\t\t: undefined;\n\t\tif (!user_id) continue;\n\n\t\t// prettier-ignore\n\t\tdb.run(playerGamesQuery, [\n\t\t\tuser_id,\n\t\t\tgame_id,\n\t\t\tplayer,\n\t\t\tvictor === undefined ? null : victor === player ? 1 : victor === null ? 0.5 : 0,\n\t\t\t!basegame.untimed ? ending_clocks![player]! : null,\n\t\t\tratingData?.[player]?.elo_at_game ?? null,\n\t\t\tratingData?.[player]?.elo_change_from_game ?? null,\n\t\t]);\n\t}\n}\n\n/**\n * [INTERNAL] Loops through all players in a game and updates their stats by calling\n * the single-player update function.\n */\nfunction updateAllPlayerStatsInTransaction(\n\t{ basegame, match }: ServerGame,\n\tvictor: Player | null | undefined,\n): void {\n\tconst playerMoveCounts = getPlayerMoveCountsInGame({ basegame, match });\n\n\tfor (const playerStr in match.playerData) {\n\t\tconst player = Number(playerStr) as Player;\n\t\tconst user_id = match.playerData[player]!.identifier.signedIn\n\t\t\t? match.playerData[player]!.identifier.user_id\n\t\t\t: undefined;\n\t\tif (!user_id) continue; // Guests dono't have any stats to update.\n\n\t\t// prettier-ignore\n\t\tupdateSinglePlayerStatsInTransaction(user_id, {\n\t\t\tmoves_played_increment: playerMoveCounts[player]!,\n\t\t\toutcome: victor === undefined ? 'aborted' : victor === player ? \"wins\" : victor === null ? \"draws\" : \"losses\",\n\t\t\tis_rated: match.rated,\n\t\t\tpublicity: match.publicity,\n\t\t});\n\t}\n}\n\n/**\n * [INTERNAL] Updates a player's aggregate stats in the `player_stats` table.\n * This logic is co-located here because it is only ever used by the logGame transaction.\n * This version uses direct SQL increments for efficiency (`col = col + 1`).\n * It does not throw an error if the user is not found, as a user might be\n * deleted mid-game. It logs this event instead.\n */\nfunction updateSinglePlayerStatsInTransaction(\n\tuser_id: number,\n\tstatsToUpdate: {\n\t\tmoves_played_increment: number;\n\t\toutcome: 'wins' | 'losses' | 'draws' | 'aborted';\n\t\tis_rated: boolean;\n\t\tpublicity: 'public' | 'private';\n\t},\n): void {\n\t// Start building the list of columns to update and the values for them.\n\tconst setClauses: string[] = ['moves_played = moves_played + ?', 'game_count = game_count + 1'];\n\tconst values: (number | string)[] = [statsToUpdate.moves_played_increment];\n\n\tif (statsToUpdate.outcome === 'aborted') {\n\t\tsetClauses.push('game_count_aborted = game_count_aborted + 1');\n\t} else {\n\t\tconst ratedString: 'rated' | 'casual' = statsToUpdate.is_rated ? 'rated' : 'casual';\n\n\t\t// Increment the correct rated/casual counter.\n\t\tsetClauses.push(`game_count_${ratedString} = game_count_${ratedString} + 1`);\n\n\t\t// Increment the correct public/private counter.\n\t\t// This is safe because `statsToUpdate.publicity` can only be 'public' or 'private'.\n\t\tsetClauses.push(\n\t\t\t`game_count_${statsToUpdate.publicity} = game_count_${statsToUpdate.publicity} + 1`,\n\t\t);\n\n\t\t// Increment the correct win/loss/draw counter.\n\t\tsetClauses.push(\n\t\t\t`game_count_${statsToUpdate.outcome} = game_count_${statsToUpdate.outcome} + 1`,\n\t\t);\n\n\t\t// Increment the correct combined outcome + rated/casual counter.\n\t\tsetClauses.push(\n\t\t\t`game_count_${statsToUpdate.outcome}_${ratedString} = game_count_${statsToUpdate.outcome}_${ratedString} + 1`,\n\t\t);\n\t}\n\n\tconst query = `UPDATE player_stats SET ${setClauses.join(', ')} WHERE user_id = ?`;\n\tvalues.push(user_id);\n\n\tconst result = db.run(query, values);\n\n\tif (result.changes === 0) {\n\t\t// This should now be impossible. If it happens, it's a critical error.\n\t\tthrow new Error(\n\t\t\t`CRITICAL: User ${user_id} not found in player_stats during game log. This should not be possible. Did we allow them to delete their account mid-game?`,\n\t\t);\n\t}\n}\n\n/** Converts a server-side {@link Game} into an ICN */\nfunction getICNOfGame(game: Game): string {\n\t// Get ICN of game\n\tlet ICN: string;\n\ttry {\n\t\tICN = icnconverter.LongToShort_Format(\n\t\t\t{\n\t\t\t\t...game,\n\t\t\t\tfullMove: 1,\n\t\t\t\tstate_global: {\n\t\t\t\t\tmoveRuleState: game.gameRules.moveRule !== undefined ? 0 : undefined,\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tskipPosition: true,\n\t\t\t\tcompact: true,\n\t\t\t\tspaces: false,\n\t\t\t\tcomments: true,\n\t\t\t\tmake_new_lines: false,\n\t\t\t\tmove_numbers: false,\n\t\t\t},\n\t\t);\n\t} catch (error: unknown) {\n\t\tconst errMessage = error instanceof Error ? error.message : String(error);\n\t\tconst errStack = error instanceof Error ? error.stack : 'No stack trace available';\n\t\t// Re-throw error with additional context, the orchestrator will catch it and roll back the transaction.\n\t\tthrow Error(\n\t\t\t`Error converting game to ICN: ${errMessage}\\nThe primed gamefile:\\n${JSON.stringify(game)}\\n${errStack}`,\n\t\t);\n\t}\n\n\treturn ICN;\n}\n\n/**\n * Counts the number of moves each player has made in the game.\n *\n * TODO: Move to moveutil script, once its dependancies are healthy!!!\n */\nfunction getPlayerMoveCountsInGame({ match, basegame }: ServerGame): PlayerGroup<number> {\n\t// Optimized to not require iterating through each move in the list.\n\tconst playerMoveCounts: PlayerGroup<number> = {};\n\tconst fullmoves_completed_total = Math.floor(\n\t\tbasegame.moves.length / basegame.gameRules.turnOrder.length,\n\t);\n\tconst last_partial_move_length = basegame.moves.length % basegame.gameRules.turnOrder.length;\n\tfor (const playerStr in match.playerData) {\n\t\tconst player: Player = Number(playerStr) as Player;\n\t\tplayerMoveCounts[player] =\n\t\t\tfullmoves_completed_total *\n\t\t\tbasegame.gameRules.turnOrder.filter((p: Player) => p === player).length;\n\t\tplayerMoveCounts[player] += basegame.gameRules.turnOrder\n\t\t\t.slice(0, last_partial_move_length)\n\t\t\t.filter((p: Player) => p === player).length;\n\t}\n\treturn playerMoveCounts;\n}\n\nexport default {\n\tlogGame,\n};\n"
  },
  {
    "path": "src/server/game/gamemanager/gamemanager.ts",
    "content": "// src/server/game/gamemanager/gamemanager.ts\n\n/**\n * The script keeps track of all our active online games.\n */\n\nimport type { Invite } from '../invitesmanager/inviteutility.js';\nimport type { Rating } from '../../../shared/types.js';\nimport type { ServerGame } from './gameutility.js';\nimport type { AuthMemberInfo } from '../../types.js';\nimport type { GameConclusion } from '../../../shared/chess/util/winconutil.js';\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\nimport type { Player, PlayerGroup } from '../../../shared/chess/util/typeutil.js';\n\nimport WebSocket from 'ws';\n\nimport clock from '../../../shared/chess/logic/clock.js';\nimport typeutil from '../../../shared/chess/util/typeutil.js';\nimport gamefile from '../../../shared/chess/logic/gamefile.js';\nimport winconutil from '../../../shared/chess/util/winconutil.js';\nimport gamefileutility from '../../../shared/chess/util/gamefileutility.js';\nimport { Leaderboards } from '../../../shared/chess/variants/validleaderboard.js';\nimport {\n\tdoesVariantSupportServerValidation,\n\tisGameInstantlyDeleted,\n} from '../../../shared/chess/variants/servervalidation.js';\n\nimport statlogger from '../statlogger.js';\nimport gamelogger from './gamelogger.js';\nimport gameutility from './gameutility.js';\nimport ratingabuse from './ratingabuse.js';\nimport socketUtility from '../../socket/socketUtility.js';\nimport liveGameValues from './liveGameValues.js';\nimport { executeSafely } from '../../utility/errorGuard.js';\nimport { closeDrawOffer } from './drawoffers.js';\nimport { genUniqueGameID } from '../../database/gamesManager.js';\nimport { sendSocketMessage } from '../../socket/sendSocketMessage.js';\nimport { restoreAllLiveGames } from './liveGameRestore.js';\nimport { getEloOfPlayerInLeaderboard } from '../../database/leaderboardsManager.js';\nimport { timeBeforeGameDeletionMillis } from './gameutility.js';\nimport { broadcastGameCountToInviteSubs } from './gamecount.js';\nimport {\n\taddUserToActiveGames,\n\tremoveUserFromActiveGame,\n\tgetIDOfGamePlayerIsIn,\n\thasColorInGameSeenConclusion,\n} from './activeplayers.js';\nimport {\n\tcancelAutoAFKResignTimer,\n\tstartDisconnectTimer,\n\tcancelDisconnectTimers,\n\ttimeToGiveDisconnectedBeforeStartingAutoResignTimerMillis,\n} from './afkdisconnect.js';\n\n// Constants ----------------------------------------------------------------------------------\n\n/** Whether to log all new and ending games to the console. */\nconst PRINT_GAMES = false;\n\n// State --------------------------------------------------------------------------------------\n\n/**\n * The object containing all currently active games. Each game's id is the key: `{ id: Game }`\n * This may temporarily include games that are over, but not yet deleted/logged.\n *\n * The game's ids are the same id they will receive in the database! For this reason they must\n * be unique across the games table, and all other live games.\n */\nconst activeGames: Record<number, ServerGame> = {};\n\n// Functions -----------------------------------------------------------------------------------\n\n/**\n * Creates the `ServerGame` object and subscibes each player to the game\n * Auto-subscribes the players to receive game updates.\n * @param invite - The invite with the properties `id`, `owner`, `variant`, `clock`, `color`, `rated`, `publicity`.\n * @param assignments - The color each player has\n * @param actingPlayer - The color of the player that started the game and sent the socket message\n * @param replyto - The ID of the incoming socket message of the player that started the game. This is used for the `replyto` property on our response.\n */\nfunction createGame(\n\tinvite: Invite,\n\tassignments: PlayerGroup<{ identifier: AuthMemberInfo; socket?: CustomWebSocket }>,\n\tactingPlayer: Player,\n\treplyto?: number,\n): void {\n\tconst ratinginfo: typeof assignments & PlayerGroup<{ rating?: Rating }> = {};\n\tfor (const [color, data] of Object.entries(assignments)) {\n\t\tconst player: Player = Number(color) as Player;\n\n\t\tratinginfo[player] = data;\n\n\t\tif (data.identifier.signedIn) {\n\t\t\tratinginfo[player].rating = getEloOfPlayerInLeaderboard(\n\t\t\t\tdata.identifier.user_id,\n\t\t\t\tLeaderboards.INFINITY,\n\t\t\t);\n\t\t}\n\t}\n\n\tconst gameID = issueUniqueGameId();\n\tconst now = Date.now();\n\tconst metadata = gameutility.constructMetadataOfGame(\n\t\tinvite.rated === 'rated',\n\t\tinvite.variant,\n\t\tinvite.clock,\n\t\tnow,\n\t\tratinginfo,\n\t);\n\tconst basegame = gamefile.initGame(metadata, now, invite.variant);\n\tconst match = gameutility.initMatch(invite, gameID, assignments);\n\n\t// If the variant is small, construct the board for server-side move legality validation.\n\tconst boardsim = doesVariantSupportServerValidation(match.variant, basegame.dateTimestamp)\n\t\t? gamefile.initBoard(basegame.gameRules, match.variant, basegame.dateTimestamp)\n\t\t: undefined;\n\n\tconst servergame: ServerGame = { basegame, match, boardsim };\n\tfor (const [strcolor, { socket }] of Object.entries(assignments)) {\n\t\tconst player = Number(strcolor) as Player;\n\t\tif (socket)\n\t\t\tgameutility.subscribeClientToGame(\n\t\t\t\tservergame,\n\t\t\t\tsocket,\n\t\t\t\tplayer,\n\t\t\t\tactingPlayer === player ? { replyto } : {},\n\t\t\t);\n\t\telse startDisconnectTimer(servergame, player, false, onPlayerLostByDisconnect);\n\t}\n\n\tfor (const data of Object.values(match.playerData)) {\n\t\taddUserToActiveGames(data.identifier, servergame.match.id);\n\t}\n\n\tactiveGames[servergame.match.id] = servergame;\n\n\t// Persist the new game to the database for restoration after server restart.\n\tliveGameValues.onGameCreated(servergame);\n\n\tif (PRINT_GAMES) {\n\t\tconsole.log('Starting new game:');\n\t\tgameutility.printGame(servergame);\n\t}\n}\n\n/**\n * Returns an id that is unique across BOTH the games table\n * AND the live games inside {@link activeGames}.\n *\n * The game will receive this same id in the database when it is logged.\n */\nfunction issueUniqueGameId(): number {\n\tlet id: number;\n\tdo {\n\t\tid = genUniqueGameID(); // This is already unique against all game_ids in the table.\n\t} while (activeGames[id] !== undefined); // Repeat until we have an id unique against all active games.\n\t// console.log(`Issued game_id (${id})!`);\n\treturn id;\n}\n\n/**\n * Checks if member with a given username is currently listed as being in some active game\n * @param username - username of some member\n * @returns true if member is currently in active game, otherwise false\n */\nfunction isMemberInSomeActiveGame(username: string): boolean {\n\tfor (const servergame of Object.values(activeGames)) {\n\t\tfor (const player of Object.values(servergame.match.playerData)) {\n\t\t\tif (!player.identifier.signedIn) continue;\n\t\t\tif (player.identifier.username === username) return true;\n\t\t}\n\t}\n\treturn false;\n}\n\n/**\n * Starts the 5-second cushion timer for a player who disconnected not by their own choice\n * (network interruption). After the cushion elapses, if they have not yet reconnected,\n * the full disconnect auto-resign timer is started.\n * Also persists the cushion state to the database.\n * @param servergame - The game\n * @param color - The player who disconnected\n */\nfunction startDisconnectCushionTimerAndPersist(servergame: ServerGame, color: Player): void {\n\tservergame.match.playerData[color]!.disconnect.startID = setTimeout(\n\t\t() => startDisconnectTimerAndPersist(servergame, color, true),\n\t\ttimeToGiveDisconnectedBeforeStartingAutoResignTimerMillis,\n\t);\n\tservergame.match.playerData[color]!.disconnect.startTime =\n\t\tDate.now() + timeToGiveDisconnectedBeforeStartingAutoResignTimerMillis;\n\tliveGameValues.onPlayerDisconnected(servergame, color);\n}\n\n/** Starts the auto-resign disconnect timer and immediately persists the new disconnect state to the database. */\nfunction startDisconnectTimerAndPersist(\n\tservergame: ServerGame,\n\tcolor: Player,\n\tclosureNotByChoice: boolean,\n): void {\n\tstartDisconnectTimer(servergame, color, closureNotByChoice, onPlayerLostByDisconnect);\n\tliveGameValues.onPlayerDisconnected(servergame, color);\n}\n\n/**\n * Unsubscribes a websocket from the game their connected to after a socket closure.\n * Detaches their socket from the game, updates their metadata.subscriptions.\n * @param ws - Their websocket.\n * @param options - Additional options.\n * @param [unsubNotByChoice] When true, we will give them a 5-second cushion to re-sub before we start an auto-resignation timer. Set to false if we call this due to them closing the tab.\n */\nfunction unsubClientFromGameBySocket(ws: CustomWebSocket, { unsubNotByChoice = true } = {}): void {\n\tconst gameID = ws.metadata.subscriptions.game?.id;\n\tif (gameID === undefined)\n\t\treturn console.error(\"Cannot unsub client from game when it's not subscribed to one.\");\n\n\tconst servergame = getGameByID(gameID);\n\tif (!servergame)\n\t\treturn console.log(\n\t\t\t`Cannot unsub client from game when game doesn't exist! Metadata: ${socketUtility.stringifySocketMetadata(ws)}`,\n\t\t);\n\n\tgameutility.unsubClientFromGame(servergame.match, ws); // Don't tell the client to unsub because their socket is CLOSING\n\n\t// Let their OPPONENT know they've disconnected though...\n\n\tif (gameutility.isGameOver(servergame.basegame)) return; // It's fine if players unsub/disconnect after the game has ended.\n\n\tconst color = gameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws)! as Player;\n\tif (unsubNotByChoice) {\n\t\t// Internet interruption. Give them 5 seconds before starting auto-resign timer.\n\t\t// console.log('Waiting 5 seconds before starting disconnection timer.');\n\t\tstartDisconnectCushionTimerAndPersist(servergame, color);\n\t} else {\n\t\t// Closed tab manually. Immediately start auto-resign timer.\n\t\tstartDisconnectTimerAndPersist(servergame, color, unsubNotByChoice);\n\t}\n}\n\n/**\n * Returns the game with the specified id.\n * @param id - The id of the game to pull.\n * @returns The game\n */\nfunction getGameByID(id: number): ServerGame | undefined {\n\treturn activeGames[id];\n}\n\n/**\n * Gets a game by player.\n * @param player - The player object with one of 2 properties: `member` or `browser`, depending on if they are signed in.\n * @returns The game they are in, if they belong in one, otherwise undefined..\n */\nfunction getGameByPlayer(player: AuthMemberInfo): ServerGame | undefined {\n\tconst gameID = getIDOfGamePlayerIsIn(player);\n\tif (gameID === undefined) return; // Not in a game;\n\treturn getGameByID(gameID);\n}\n\n/**\n * Gets a game by socket, first checking if they are subscribed to a game,\n * if not then it checks if they are in the players in active games list.\n * @param ws - Their websocket\n * @returns The game they are in, if they belong in one, otherwise undefined.\n */\nfunction getGameBySocket(ws: CustomWebSocket): ServerGame | undefined {\n\tconst gameID = ws.metadata.subscriptions.game?.id;\n\tif (gameID) return getGameByID(gameID);\n\n\t// The socket is not subscribed to any game. Perhaps this is a resync/refresh?\n\n\t// Is the client in a game? What's their username/browser-id?\n\tconst player = ws.metadata.memberInfo;\n\treturn getGameByPlayer(player);\n}\n\n/**\n * Called when the client sees the game conclusion. Tries to remove them from the players\n * in active games list, which then allows them to join a new game.\n *\n * THIS SHOULD ALSO be the point when the server knows this player\n * agrees with the resulting game conclusion (no cheating detected),\n * and the server may change the players elos once both players send this.\n * @param ws - Their websocket\n * @param servergame - The game they are in.\n */\nfunction onRequestRemovalFromPlayersInActiveGames(\n\tws: CustomWebSocket,\n\tservergame: ServerGame,\n): void {\n\tif (!gameutility.isGameOver(servergame.basegame)) return; // Game is still going, can't let them join a new game.\n\n\tconst user = ws.metadata.memberInfo;\n\tremoveUserFromActiveGame(user, servergame.match.id);\n\n\t// If both players have requested this (i.e. have seen the game conclusion),\n\t// and the game is scheduled to be deleted, just delete it now!\n\n\t// Is the opponent still in the players in active games list? (has not seen the game results)\n\tconst color =\n\t\tws.metadata.subscriptions.game?.color ||\n\t\tgameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws)!;\n\tconst opponentColor = typeutil.invertPlayer(color);\n\tif (!hasColorInGameSeenConclusion(servergame.match, opponentColor)) return; // They are still in the active games list because they have not seen the game conclusion yet.\n\n\t// console.log(\"Deleting game immediately, instead of waiting 15 seconds, because both players have seen the game conclusion and requested to be removed from the players in active games list.\")\n\n\t// Both players have seen the game conclusion and requested to be removed\n\t// from the players in active games list, just delete the game now!\n\tgameutility.cancelDeleteGameTimer(servergame.match);\n\tdeleteGame(servergame);\n}\n\n/**\n * Pushes the game clock, adding increment. Resets the timer\n * to auto terminate the game when a player loses on time.\n * @param servergame - The game\n * @returns The new time (in ms) of the player that just moved after increment is added.\n */\nfunction pushGameClock({ basegame, match }: ServerGame): number | undefined {\n\tbasegame.whosTurn =\n\t\tbasegame.gameRules.turnOrder[basegame.moves.length % basegame.gameRules.turnOrder.length]!;\n\n\tif (basegame.untimed) return; // Don't adjust the times if the game isn't timed.\n\n\tconst data = clock.push(basegame, basegame.clocks);\n\n\t// Reset the timer that will auto terminate the game when one player loses on time.\n\tif (!gameutility.isGameOver(basegame) && gameutility.isGameResignable(basegame)) {\n\t\t// Cancel previous auto loss timer if it exists\n\t\tclearTimeout(match.autoTimeLossTimeoutID);\n\t\t// Set the next one\n\t\tconst timeUntilLoseOnTime = Math.max(basegame.clocks.timeRemainAtTurnStart!, 0);\n\t\tmatch.autoTimeLossTimeoutID = setTimeout(\n\t\t\t() => onPlayerLostOnTime({ basegame, match }),\n\t\t\ttimeUntilLoseOnTime,\n\t\t);\n\t}\n\n\treturn data;\n}\n\n/**\n * Finalizes the game conclusion and immediately deletes and logs the game.\n * Use this for all conclusions not triggered by a move (time, disconnect, abort, resign, draw).\n * For move-triggered conclusions use {@link finalizeConclusion} and {@link teardownGame}\n * directly so messages can be sent between finalization and teardown.\n * @param servergame - The game\n * @param conclusion - The new game conclusion\n */\nfunction setGameConclusion(servergame: ServerGame, conclusion: GameConclusion | undefined): void {\n\tfinalizeConclusion(servergame, conclusion);\n\tif (conclusion !== undefined) teardownGame(servergame);\n}\n\n/**\n * Finalizes the game conclusion: sets basegame state and metadata, stops the clock,\n * cancels all timers, closes the draw offer, stamps the end time, and persists to the DB.\n * After this returns, the game state is final and consistent with what will be logged.\n * Does NOT broadcast to clients or touch socket/game-object teardown.\n * @param servergame - The game\n * @param conclusion - The new game conclusion\n */\nfunction finalizeConclusion(servergame: ServerGame, conclusion: GameConclusion | undefined): void {\n\tgamefileutility.setConclusion(servergame.basegame, conclusion);\n\n\tif (conclusion === undefined) return;\n\n\tconst players: Record<string, any> = {};\n\tfor (const [c, data] of Object.entries(servergame.match.playerData)) {\n\t\tplayers[c] = {\n\t\t\tid: data.identifier.signedIn ? data.identifier.username : data.identifier.browser_id,\n\t\t\ts: data.identifier.signedIn,\n\t\t};\n\t}\n\tif (PRINT_GAMES)\n\t\tconsole.log(\n\t\t\t`Game ${servergame.match.id} over. Players: ${JSON.stringify(players)}. Conclusion: ${JSON.stringify(servergame.basegame.gameConclusion)}. Moves: ${servergame.basegame.moves.length}.`,\n\t\t);\n\n\tclock.stop(servergame.basegame);\n\t// Cancel the timer that will auto terminate\n\t// the game when the next player runs out of time\n\tclearTimeout(servergame.match.autoTimeLossTimeoutID);\n\t// Also cancel the one that auto loses by AFK\n\tcancelAutoAFKResignTimer(servergame);\n\tcancelDisconnectTimers(servergame.match);\n\tcloseDrawOffer(servergame.match);\n\n\t// The ending time of the game is set, if it is undefined\n\tif (servergame.match.timeEnded === undefined) servergame.match.timeEnded = Date.now();\n\n\t// Persist the final game state to the database.\n\tliveGameValues.onGameConcluded(servergame);\n}\n\n/**\n * Executes game teardown: broadcasts the final game state to\n * clients if the conclusion was not move-triggered, then either\n * deletes the game immediately or schedules deletion after a short cushion.\n * Must be called after {@link finalizeConclusion}.\n * @param servergame - The game (basegame.gameConclusion must already be set)\n */\nfunction teardownGame(servergame: ServerGame): void {\n\tconst conclusion = servergame.basegame.gameConclusion!;\n\n\t// Move-triggered conclusions already send the gameConclusion in the move response.\n\tif (!winconutil.isConclusionMoveTriggered(conclusion.condition))\n\t\tgameutility.broadcastGameUpdate(servergame);\n\n\tgameutility.cancelDeleteGameTimer(servergame.match); // Cancel first, in case a hacking report just occurred.\n\tif (\n\t\tisGameInstantlyDeleted(\n\t\t\tservergame.match.variant,\n\t\t\tservergame.basegame.dateTimestamp,\n\t\t\tservergame.match.publicity === 'private',\n\t\t)\n\t) {\n\t\t// Server validated every move — cheating is impossible,\n\t\t// OR we disallow cheat reports (private game).\n\t\t// We can log and unsubscribe clients immediately.\n\t\tdeleteGame(servergame);\n\t} else {\n\t\t// No server-side validation (e.g. large variant, or pasted position).\n\t\t// Give the opponent time to oppose the conclusion.\n\t\tservergame.match.deleteTimeoutID = setTimeout(\n\t\t\t() => deleteGame(servergame),\n\t\t\ttimeBeforeGameDeletionMillis,\n\t\t);\n\t}\n}\n\n/**\n * Called when a player in the game loses on time.\n * Sets the gameConclusion, notifies both players.\n * Sets a 5 second timer to delete the game in case\n * one of them was disconnected when this happened.\n * @param servergame - The game\n */\nfunction onPlayerLostOnTime(servergame: ServerGame): void {\n\t// console.log('Someone has lost on time!');\n\n\t// Who lost on time?\n\tconst loser = servergame.basegame.whosTurn!;\n\tconst winner = typeutil.invertPlayer(loser);\n\n\tclock.stop(servergame.basegame);\n\t// Sometimes their clock can have 1ms left. Just make that zero.\n\tif (servergame.basegame.clocks) servergame.basegame.clocks.currentTime[loser] = 0;\n\n\tsetGameConclusion(servergame, { victor: winner, condition: 'time' });\n}\n\n/**\n * Called when a player in the game loses by disconnection.\n * Sets the gameConclusion, notifies the opponent.\n * @param servergame - The game\n * @param colorWon - The color that won by opponent disconnection\n */\nfunction onPlayerLostByDisconnect(servergame: ServerGame, colorWon: Player): void {\n\tif (gameutility.isGameOver(servergame.basegame))\n\t\treturn console.error(\n\t\t\t'We should have cancelled the auto-loss-by-disconnection timer when the game ended!',\n\t\t);\n\n\tif (gameutility.isGameResignable(servergame.basegame)) {\n\t\t// console.log('Someone has lost by disconnection!');\n\t\tsetGameConclusion(servergame, { victor: colorWon, condition: 'disconnect' });\n\t} else {\n\t\t// console.log('Game aborted from disconnection.');\n\t\tsetGameConclusion(servergame, { condition: 'aborted' });\n\t}\n}\n\n/**\n * Called when a player in the game loses by abandonment (AFK).\n * Sets the gameConclusion, notifies both players.\n * Sets a 5 second timer to delete the game in case\n * one of them was disconnected when this happened.\n * @param servergame - The game\n * @param colorWon - The color that won by opponent abandonment (AFK)\n */\nfunction onPlayerLostByAbandonment(servergame: ServerGame, colorWon: Player): void {\n\tif (gameutility.isGameResignable(servergame.basegame)) {\n\t\t// console.log('Someone has lost by abandonment!');\n\t\tsetGameConclusion(servergame, { victor: colorWon, condition: 'disconnect' });\n\t} else {\n\t\t// console.log('Game aborted from abandonment.');\n\t\tsetGameConclusion(servergame, { condition: 'aborted' });\n\t}\n}\n\n/**\n * Deletes the game. Prints the active game count.\n * This should not be called until after both clients have had a chance\n * to see the game result, or after 15 seconds after the game ends\n * to give players time to cheat report.\n * @param servergame\n */\nfunction deleteGame(servergame: ServerGame): void {\n\t// Delete is BEFORE logging, since the user may still send us game actions like \"removefromplayersinactivegames\"\n\t// and because of async stuff below, the game isn't actually deleted yet, which may trigger a second deleteGame() call.\n\tdelete activeGames[servergame.match.id]; // Delete the game from the activeGames list\n\tbroadcastGameCountToInviteSubs();\n\n\t// Remove the live game from the persistence database.\n\tliveGameValues.onGameDeleted(servergame.match.id);\n\n\t// If the pastedGame flag is present, skip logging to the database.\n\t// We don't know the starting position.\n\tif (servergame.match.positionPasted) {\n\t\t// console.log('Skipping logging custom game.');\n\t} else {\n\t\t// The gamelogger logs the completed game information into the database tables \"games\", \"player_stats\" and \"ratings\"\n\t\t// The ratings are calculated during the logging of the game into the database\n\t\tconst ratingdata = gamelogger.logGame(servergame);\n\n\t\t// Mostly deprecated:\n\t\t// The statlogger logs games with at least 2 moves played (resignable) into /database/stats.json for stat collection\n\t\texecuteSafely(\n\t\t\t() => statlogger.logGame(servergame),\n\t\t\t`statlogger unable to log game! ${gameutility.getSimplifiedGameString(servergame)}`,\n\t\t);\n\n\t\t// Send rating changes to all players of game, if relevant\n\t\tif (ratingdata !== undefined)\n\t\t\tgameutility.sendRatingChangeToAllPlayers(servergame.match, ratingdata);\n\t}\n\n\t// Unsubscribe both players' sockets from the game if they still are connected.\n\t// If the socket is undefined, they will have already been auto-unsubscribed.\n\t// And remove them from the list of users in active games to allow them to join a new game.\n\tfor (const data of Object.values(servergame.match.playerData)) {\n\t\tremoveUserFromActiveGame(data.identifier, servergame.match.id);\n\t\tif (!data.socket) continue; // They don't have a socket connected.\n\t\t// We inform their opponent they have disconnected inside js when we call this method.\n\t\t// Tell the client to unsub on their end, IF the socket isn't closing.\n\t\tif (data.socket.readyState === WebSocket.OPEN)\n\t\t\tsendSocketMessage(data.socket, 'game', 'unsub');\n\t\tgameutility.unsubClientFromGame(servergame.match, data.socket);\n\t}\n\n\t// Monitor suspicion levels for all players who participated in the game\n\t// Doesn't have to be in the same transaction as the game logging,\n\t// as the rating abuse table's data does not reference other tables.\n\tratingabuse.measureRatingAbuseAfterGame(servergame);\n\n\tif (PRINT_GAMES) console.log(`Deleted game ${servergame.match.id}.`);\n}\n\n// Shutdown Preparation & Startup Restoration ------------------------------------------------\n\n/**\n * Call when server's about to restart.\n * Stop all runtime timers and close sockets gracefully.\n * The games will be restored from the database on the next startup.\n * Their state is already stored inside live_games and live_game_players tables.\n */\nfunction prepGamesForShutdown(): void {\n\tfor (const gameID in activeGames) {\n\t\tconst servergame = activeGames[gameID]!;\n\n\t\t// Cancel all runtime timers\n\t\tclearTimeout(servergame.match.autoTimeLossTimeoutID);\n\t\tcancelAutoAFKResignTimer(servergame);\n\t\tcancelDisconnectTimers(servergame.match);\n\t\tgameutility.cancelDeleteGameTimer(servergame.match);\n\n\t\t// Unsubscribe all sockets (we will resub them when they reconnect)\n\t\tfor (const data of Object.values(servergame.match.playerData)) {\n\t\t\tif (!data.socket) continue;\n\t\t\tgameutility.unsubClientFromGame(servergame.match, data.socket);\n\t\t}\n\n\t\tdelete activeGames[gameID];\n\t}\n}\n\n/**\n * Restores all live games from the database on server startup.\n * Should be called after initDatabase() and before accepting client connections.\n */\nfunction restoreLiveGames(): void {\n\tconst restoredGames = restoreAllLiveGames();\n\n\tfor (const { servergame, pendingTimers } of restoredGames) {\n\t\t// Add the game to the active games list\n\t\tactiveGames[servergame.match.id] = servergame;\n\n\t\t// Register players in the active players list\n\t\tfor (const data of Object.values(servergame.match.playerData)) {\n\t\t\taddUserToActiveGames(data.identifier, servergame.match.id);\n\t\t}\n\n\t\t// Start timers\n\n\t\t// 1. Delete timer (for concluded games)\n\t\tif (pendingTimers.deleteTimerMs !== undefined) {\n\t\t\tif (pendingTimers.deleteTimerMs <= 0) {\n\t\t\t\t// Timer already expired, delete immediately\n\t\t\t\tdeleteGame(servergame);\n\t\t\t\tcontinue; // Skip to next game since this one is being deleted\n\t\t\t}\n\t\t\tservergame.match.deleteTimeoutID = setTimeout(\n\t\t\t\t() => deleteGame(servergame),\n\t\t\t\tpendingTimers.deleteTimerMs,\n\t\t\t);\n\t\t}\n\n\t\t// Skip remaining timers for concluded games\n\t\tif (gameutility.isGameOver(servergame.basegame)) continue;\n\n\t\t// 2. Auto time loss timer (for timed games)\n\t\tif (pendingTimers.autoTimeLossMs !== undefined) {\n\t\t\tif (pendingTimers.autoTimeLossMs <= 0) {\n\t\t\t\t// Clock already expired during downtime\n\t\t\t\tonPlayerLostOnTime(servergame);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tservergame.match.autoTimeLossTimeoutID = setTimeout(\n\t\t\t\t() => onPlayerLostOnTime(servergame),\n\t\t\t\tpendingTimers.autoTimeLossMs,\n\t\t\t);\n\t\t}\n\n\t\t// 3. AFK resign timer\n\t\tif (pendingTimers.afkResignTimerMs !== undefined) {\n\t\t\tconst opponentColor = typeutil.invertPlayer(servergame.basegame.whosTurn!);\n\t\t\tif (pendingTimers.afkResignTimerMs <= 0) {\n\t\t\t\t// AFK timer already expired during downtime\n\t\t\t\tonPlayerLostByAbandonment(servergame, opponentColor);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tservergame.match.autoAFKResignTimeoutID = setTimeout(\n\t\t\t\t() => onPlayerLostByAbandonment(servergame, opponentColor),\n\t\t\t\tpendingTimers.afkResignTimerMs,\n\t\t\t);\n\t\t}\n\n\t\t// 4. Per-player disconnect timers\n\t\tfor (const [playerStr, timerState] of Object.entries(pendingTimers.disconnectTimers)) {\n\t\t\tconst player = Number(playerStr) as Player;\n\t\t\tconst opponentColor = typeutil.invertPlayer(player);\n\n\t\t\tif (timerState.type === 'timer') {\n\t\t\t\t// Disconnect auto-resign timer was active\n\t\t\t\tif (timerState.remainingMs <= 0) {\n\t\t\t\t\t// Timer already expired, immediately resign\n\t\t\t\t\tonPlayerLostByDisconnect(servergame, opponentColor);\n\t\t\t\t\tbreak; // Game is over\n\t\t\t\t}\n\t\t\t\t// Revive the timer for the remaining duration exactly.\n\t\t\t\t// No sockets are connected yet at startup, so skip the opponent notification.\n\t\t\t\tconst playerdata = servergame.match.playerData[player]!;\n\t\t\t\tplayerdata.disconnect.startTime = undefined;\n\t\t\t\tplayerdata.disconnect.timeoutID = setTimeout(\n\t\t\t\t\t() => onPlayerLostByDisconnect(servergame, opponentColor),\n\t\t\t\t\ttimerState.remainingMs,\n\t\t\t\t);\n\t\t\t\tplayerdata.disconnect.timeToAutoLoss = Date.now() + timerState.remainingMs;\n\t\t\t\tplayerdata.disconnect.wasByChoice = timerState.byChoice;\n\t\t\t} else if (timerState.type === 'cushion') {\n\t\t\t\t// Still in the 5-second cushion period\n\t\t\t\tif (timerState.remainingMs <= 0) {\n\t\t\t\t\t// Cushion has elapsed, start the disconnect timer immediately and persist that state.\n\t\t\t\t\tstartDisconnectTimerAndPersist(servergame, player, !timerState.byChoice);\n\t\t\t\t} else {\n\t\t\t\t\t// Revive the cushion timer for the remaining duration\n\t\t\t\t\tservergame.match.playerData[player]!.disconnect.startID = setTimeout(\n\t\t\t\t\t\t() =>\n\t\t\t\t\t\t\tstartDisconnectTimerAndPersist(\n\t\t\t\t\t\t\t\tservergame,\n\t\t\t\t\t\t\t\tplayer,\n\t\t\t\t\t\t\t\t!timerState.byChoice,\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\ttimerState.remainingMs,\n\t\t\t\t\t);\n\t\t\t\t\tservergame.match.playerData[player]!.disconnect.startTime =\n\t\t\t\t\t\tDate.now() + timerState.remainingMs;\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Fresh: was connected before restart, now disconnected due to server restart.\n\t\t\t\t// Give them the same 5-second cushion as a normal internet interruption.\n\t\t\t\tstartDisconnectCushionTimerAndPersist(servergame, player);\n\t\t\t}\n\t\t}\n\t}\n}\n\n//--------------------------------------------------------------------------------------------------------\n\nexport {\n\tactiveGames,\n\tcreateGame,\n\tisMemberInSomeActiveGame,\n\tunsubClientFromGameBySocket,\n\tonPlayerLostByAbandonment,\n\tgetGameBySocket,\n\tonRequestRemovalFromPlayersInActiveGames,\n\tsetGameConclusion,\n\tfinalizeConclusion,\n\tteardownGame,\n\tpushGameClock,\n\tgetGameByID,\n\t// Shutdown Preparation & Startup Restoration\n\tprepGamesForShutdown,\n\trestoreLiveGames,\n};\n"
  },
  {
    "path": "src/server/game/gamemanager/gamerouter.ts",
    "content": "// src/server/game/gamemanager/gamerouter.ts\n\n/*\n * This script routes all incoming websocket messages\n * with the \"game\" route to where they need to go.\n */\n\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\n\nimport * as z from 'zod';\n\nimport { onPaste } from './pastereport.js';\nimport { onJoinGame } from './joingame.js';\nimport { resyncToGame } from './resync.js';\nimport { onAFK, onAFK_Return } from './onAFK.js';\nimport { abortGame, resignGame } from './abortresigngame.js';\nimport { onReport, reportschem } from './cheatreport.js';\nimport { submitMove, submitmoveschem } from './movesubmission.js';\nimport { offerDraw, acceptDraw, declineDraw } from './onOfferDraw.js';\nimport { getGameBySocket, onRequestRemovalFromPlayersInActiveGames } from './gamemanager.js';\n\nconst GameSchema = z.discriminatedUnion('action', [\n\tz.strictObject({ action: z.literal('abort') }),\n\tz.strictObject({ action: z.literal('resync'), value: z.int() }),\n\tz.strictObject({ action: z.literal('AFK') }),\n\tz.strictObject({ action: z.literal('AFK-Return') }),\n\tz.strictObject({ action: z.literal('offerdraw') }),\n\tz.strictObject({ action: z.literal('acceptdraw') }),\n\tz.strictObject({ action: z.literal('declinedraw') }),\n\tz.strictObject({ action: z.literal('joingame') }),\n\tz.strictObject({ action: z.literal('resign') }),\n\tz.strictObject({ action: z.literal('removefromplayersinactivegames') }),\n\tz.strictObject({ action: z.literal('paste') }),\n\tz.strictObject({ action: z.literal('report'), value: reportschem }),\n\tz.strictObject({ action: z.literal('submitmove'), value: submitmoveschem }),\n]);\n\ntype GameMessage = z.infer<typeof GameSchema>;\n\n/**\n * Handles all incoming websocket messages related to active games.\n * Possible actions: submitmove/offerdraw/abort/resign/joingame/resync/paste...\n * @param ws - The socket\n * @param contents - The incoming websocket message, with the properties `route`, `action`, `value`, `id`.\n * @param id - The id of the incoming message. This should be included in our response as the `replyto` property.\n */\nfunction routeGameMessage(ws: CustomWebSocket, contents: GameMessage, id: number): void {\n\t// All actions that don't require a game\n\tswitch (contents.action) {\n\t\tcase 'resync':\n\t\t\tresyncToGame(ws, contents.value, id);\n\t\t\treturn;\n\t\tcase 'joingame':\n\t\t\tonJoinGame(ws);\n\t\t\treturn;\n\t}\n\n\tconst servergame = getGameBySocket(ws); // The game they belong in, if they belong in one.\n\tif (!servergame) {\n\t\t// This is rare but can happen if the game is deleted on the server while their message is in transit.\n\t\tconsole.log(\n\t\t\t`Received game message of action \"${contents.action}\" when player is not in a game. Maybe it was just deleted?`,\n\t\t);\n\t\treturn;\n\t}\n\n\t// All remaining actions requiring the game they're in\n\tswitch (contents.action) {\n\t\tcase 'submitmove':\n\t\t\tsubmitMove(ws, servergame, contents.value);\n\t\t\tbreak;\n\t\tcase 'removefromplayersinactivegames':\n\t\t\tonRequestRemovalFromPlayersInActiveGames(ws, servergame);\n\t\t\tbreak;\n\t\tcase 'abort':\n\t\t\tabortGame(ws, servergame);\n\t\t\tbreak;\n\t\tcase 'resign':\n\t\t\tresignGame(ws, servergame);\n\t\t\tbreak;\n\t\tcase 'offerdraw':\n\t\t\tofferDraw(ws, servergame);\n\t\t\tbreak;\n\t\tcase 'acceptdraw':\n\t\t\tacceptDraw(ws, servergame);\n\t\t\tbreak;\n\t\tcase 'declinedraw':\n\t\t\tdeclineDraw(ws, servergame);\n\t\t\tbreak;\n\t\tcase 'AFK':\n\t\t\tonAFK(ws, servergame);\n\t\t\tbreak;\n\t\tcase 'AFK-Return':\n\t\t\tonAFK_Return(ws, servergame);\n\t\t\tbreak;\n\t\tcase 'report':\n\t\t\tonReport(ws, servergame, contents.value);\n\t\t\tbreak;\n\t\tcase 'paste':\n\t\t\tonPaste(ws, servergame);\n\t\t\tbreak;\n\t\tdefault:\n\t\t\t// @ts-ignore\n\t\t\tconsole.error(`UNKNOWN web socket action received in game route! \"${contents.action}\"`);\n\t}\n}\n\nexport { routeGameMessage, GameSchema };\n"
  },
  {
    "path": "src/server/game/gamemanager/gameutility.ts",
    "content": "// src/server/game/gamemanager/gameutility.ts\n\n/**\n * This script contains our Game constructor for the server-side,\n * and contains many utility methods for working with them!\n *\n * At most this ever handles a single game, not multiple.\n */\n\nimport type { MoveRecord } from '../../../shared/chess/logic/movepiece.js';\nimport type { RatingData } from './ratingcalculation.js';\nimport type { VariantCode } from '../../../shared/chess/variants/variantdictionary.js';\nimport type { Board, Game } from '../../../shared/chess/logic/gamefile.js';\nimport type { AuthMemberInfo } from '../../types.js';\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\nimport type { Player, PlayerGroup } from '../../../shared/chess/util/typeutil.js';\nimport type {\n\tClockValues,\n\tGameUpdateMessage,\n\tMetaData,\n\tOpponentsMoveMessage,\n\tParticipantState,\n\tPlayerRatingChangeInfo,\n\tRating,\n\tTimeControl,\n} from '../../../shared/types.js';\n\nimport clock from '../../../shared/chess/logic/clock.js';\nimport typeutil from '../../../shared/chess/util/typeutil.js';\nimport metadatautil from '../../../shared/chess/util/metadatautil.js';\nimport { players as p } from '../../../shared/chess/util/typeutil.js';\nimport {\n\tLeaderboards,\n\tVariantLeaderboards,\n} from '../../../shared/chess/variants/validleaderboard.js';\n\nimport servermetadatautil from '../servermetadatautil.js';\nimport { logEventsAndPrint } from '../../middleware/logEvents.js';\nimport { memberInfoEq, Invite } from '../invitesmanager/inviteutility.js';\nimport { UNCERTAIN_LEADERBOARD_RD } from './ratingcalculation.js';\nimport { getEloOfPlayerInLeaderboard } from '../../database/leaderboardsManager.js';\nimport { sendNotify, sendNotifyError, sendSocketMessage } from '../../socket/sendSocketMessage.js';\nimport { doesColorHaveExtendedDrawOffer, getLastDrawOfferPlyOfColor } from './drawoffers.js';\n\n// Constants ------------------------------------------------------------------------------------\n\n/**\n * The cushion time, before the game is deleted, if one player\n * has disconnected and has not yet seen the game conclusion.\n * This gives them a little bit of time to reconnect and submit a cheat report,\n * which is only useful in variants where we're not doing server-side move validation.\n */\nexport const timeBeforeGameDeletionMillis = 1000 * 8;\n\n// Types ----------------------------------------------------------------------------------------\n\n/** Contains information about this player's disconnection and auto resign timer. */\ntype PlayerDisconnect = {\n\t/**\n\t * The timeout id of the timer that will START the auto disconnection timer\n\t * This is triggered if their socket unexpectedly closes,\n\t * and lasts for 5 seconds to give them a chance to reconnect.\n\t */\n\tstartID?: NodeJS.Timeout;\n\t/**\n\t * The epoch-ms timestamp when the 5-second reconnection cushion expires.\n\t * Set alongside startID when the cushion timer is started.\n\t * Used for persistence: on server restart, this allows reviving the cushion timer.\n\t */\n\tstartTime?: number;\n} & (\n\t| {\n\t\t\t/**\n\t\t\t * The timeout id of the timer that will auto-resign the\n\t\t\t * player if they are disconnected for too long.\n\t\t\t */\n\t\t\ttimeoutID: NodeJS.Timeout;\n\t\t\t/**\n\t\t\t * The estimated timestamp that the player will\n\t\t\t * be auto-resigned from being disconnected too long.\n\t\t\t */\n\t\t\ttimeToAutoLoss: number;\n\t\t\t/**\n\t\t\t * Whether the player was disconnected by choice or not.\n\t\t\t * If not, they are given extra time to reconnect.\n\t\t\t */\n\t\t\twasByChoice: boolean;\n\t  }\n\t| {\n\t\t\ttimeoutID: undefined;\n\t\t\ttimeToAutoLoss: undefined;\n\t\t\twasByChoice: undefined;\n\t  }\n);\n\n/** Information about a single player in an online game. */\ninterface PlayerData {\n\t/**\n\t * The identifier of each color.\n\t *\n\t * If they are signed in, their identifier is `{ member: string }`, where member is their username.\n\t * If they are signed out, their identifier is `{ browser: string }`, where browser is their browser-id cookie.\n\t *\n\t */\n\tidentifier: AuthMemberInfo;\n\t/** Player's socket, if they are connected. */\n\tsocket?: CustomWebSocket;\n\t/** The last move ply this player extended a draw offer, if they have. 0-based, where 0 is the start of the game. */\n\tlastOfferPly?: number;\n\t/** Contains information about this players disconnection and auto resign timer. */\n\tdisconnect: PlayerDisconnect;\n}\n\n/** The info for the server hosting the game */\ninterface MatchInfo {\n\t/** The match's unique ID. This is also the same ID the game will have when logged to the database. */\n\tid: number;\n\n\t/** The variant code of the game being played. */\n\tvariant: VariantCode;\n\n\t/** The time this match was created. The number of milliseconds that have elapsed since the Unix epoch. */\n\ttimeCreated: number;\n\t/** The time this game ended, the game conclusion was set and the clocks were stopped serverside. The number of milliseconds that have elapsed since the Unix epoch. @type {number | undefined} */\n\ttimeEnded?: number;\n\t/** Whether this match is \"public\" or \"private\". */\n\tpublicity: 'public' | 'private';\n\t/** Whether the match is rated. */\n\trated: boolean;\n\t/**\n\t * The time control of the game (e.g. `\"600+5\"` or `\"-\"` for untimed).\n\t * Guaranteed defined here because we can't read it from MetaData since it is optional there.\n\t */\n\tclock: TimeControl;\n\t/** The data held for each player */\n\tplayerData: PlayerGroup<PlayerData>;\n\n\t/** The ID of the timeout which will auto-lose the player\n\t * whos turn it currently is when they run out of time. */\n\tautoTimeLossTimeoutID?: ReturnType<typeof setTimeout>;\n\n\t/** The ID of the timeout which will auto-lose the player\n\t * whos turn it currently is if they go AFK too long. */\n\tautoAFKResignTimeoutID?: ReturnType<typeof setTimeout>;\n\t/** The time the current player will be auto-resigned by\n\t * AFK if they are currently AFK. */\n\tautoAFKResignTime?: number;\n\n\t/** Whether a current draw offer is extended. If so, this is the color who extended it, otherwise null. */\n\tdrawOfferState?: Player;\n\n\t/** The ID of the timer to delete the match after it has ended.\n\t * This can be used to cancel it in case a hacking was reported. */\n\tdeleteTimeoutID?: ReturnType<typeof setTimeout>;\n\n\t/**\n\t * Whether a custom position was pasted in by either player.\n\t * The game will NOT be logged, because it will crash if we try\n\t * to paste it since we don't know the starting position.\n\t */\n\tpositionPasted: boolean;\n}\n\n/** The game stored in the server */\ntype ServerGame = {\n\tbasegame: Game;\n\tmatch: MatchInfo;\n\t/**\n\t * Used for server-side move legality validation.\n\t * Present only for small variants.\n\t */\n\tboardsim?: Board;\n};\n\n// Functions --------------------------------------------------------------------------------------\n\n/**\n * Construct the match bject based on the invite options and how players have been assigned\n */\nfunction initMatch(\n\tinvite: Invite,\n\tid: number,\n\tassignedPlayers: PlayerGroup<{ identifier: AuthMemberInfo }>,\n): MatchInfo {\n\tconst playerData: MatchInfo['playerData'] = {};\n\n\tfor (const [c, { identifier }] of Object.entries(assignedPlayers)) {\n\t\tplayerData[Number(c) as Player] = {\n\t\t\tidentifier,\n\t\t\tdisconnect: {\n\t\t\t\ttimeoutID: undefined,\n\t\t\t\ttimeToAutoLoss: undefined,\n\t\t\t\twasByChoice: undefined,\n\t\t\t},\n\t\t};\n\t}\n\n\treturn {\n\t\tid,\n\t\tvariant: invite.variant,\n\t\tplayerData,\n\t\ttimeCreated: Date.now(),\n\t\tpublicity: invite.publicity,\n\t\trated: invite.rated === 'rated',\n\t\tclock: invite.clock,\n\t\tpositionPasted: false,\n\t};\n}\n\n/**\n * Assigns which player is what color, depending on the `color` property of the invite.\n *\n * WE MUST EXPLICITLY have arguments for each player, as otherwise a bug is introduced\n * if this is called with only 1 player!! And type safety doesn't catch it.\n * @param inviteColor - The color property of the invite. \"Random\" / \"White\" / \"Black\"\n * @param player1 - The first player (the invite owner).\n * @param player2 - The second player (the invite accepter).\n * @returns An object with 2 properties:\n * - `colorData`: An object mapping player color to player info\n * - `playerColors`: the colors of each player, in order of ascending player number.\n */\nfunction assignWhiteBlackPlayersFromInvite(\n\tinviteColor: Player | null,\n\tplayer1: AuthMemberInfo,\n\tplayer2: AuthMemberInfo,\n): PlayerGroup<AuthMemberInfo> {\n\t// { id, owner, variant, clock, color, rated, publicity }\n\tconst colorData: PlayerGroup<AuthMemberInfo> = {};\n\tif (inviteColor === p.WHITE) {\n\t\tcolorData[p.WHITE] = player1;\n\t\tcolorData[p.BLACK] = player2;\n\t} else if (inviteColor === p.BLACK) {\n\t\tcolorData[p.WHITE] = player2;\n\t\tcolorData[p.BLACK] = player1;\n\t} else if (inviteColor === null) {\n\t\t// Random\n\t\tif (Math.random() > 0.5) {\n\t\t\tcolorData[p.WHITE] = player1;\n\t\t\tcolorData[p.BLACK] = player2;\n\t\t} else {\n\t\t\tcolorData[p.WHITE] = player2;\n\t\t\tcolorData[p.BLACK] = player1;\n\t\t}\n\t} else throw Error(`Unsupported color ${inviteColor} when assigning players to game.`);\n\n\treturn colorData;\n}\n\n/**\n * Links their socket to this game, modifies their metadata.subscriptions, and sends them the game info.\n * @param servergame - The game they are a part of.\n * @param playerSocket - Their websocket.\n * @param playerColor - What color they are playing in this game. p.NEU\n * @param options - An object that may contain the option `sendGameInfo`, that when *true* won't send the game information over. Default: *true*\n * @param options.sendGameInfo\n * @param options.replyto - The ID of the incoming socket message. This is used for the `replyto` property on our response.\n */\nfunction subscribeClientToGame(\n\tservergame: ServerGame,\n\tplayerSocket: CustomWebSocket,\n\tplayerColor: Player,\n\t{ sendGameInfo = true, replyto }: { sendGameInfo?: boolean; replyto?: number } = {},\n): void {\n\tconst { match } = servergame;\n\t// 1. Attach their socket to the game for receiving updates\n\tconst playerData = match.playerData[playerColor];\n\tif (playerData === undefined)\n\t\treturn console.error(\n\t\t\t`Cannot subscribe client to game when game does not expect color ${playerColor} to be present`,\n\t\t);\n\tif (playerData.socket) {\n\t\tsendSocketMessage(playerData.socket, 'game', 'leavegame');\n\t\tunsubClientFromGame(match, playerData.socket);\n\t}\n\tplayerData.socket = playerSocket;\n\n\t// 2. Modify their socket metadata to add the 'game', subscription,\n\t// and indicate what game the belong in and what color they are!\n\tplayerSocket.metadata.subscriptions.game = {\n\t\tid: match.id,\n\t\tcolor: playerColor,\n\t};\n\n\t// 3. Send the game information, unless this is a reconnection,\n\t// at which point we verify if they are in sync\n\tif (sendGameInfo) sendGameInfoToPlayer(servergame, playerSocket, playerColor, replyto);\n}\n\n/**\n * Detaches the websocket from the game.\n * Updates the socket's subscriptions.\n * @param match\n * @param ws - Their websocket.\n */\nfunction unsubClientFromGame(match: MatchInfo, ws: CustomWebSocket): void {\n\tif (ws.metadata.subscriptions.game === undefined) return; // Already unsubbed (they aborted)\n\n\t// 1. Detach their socket from the game so we no longer send updates\n\tdelete match.playerData[ws.metadata.subscriptions.game.color]?.socket;\n\n\t// 2. Remove the game key-value pair from the sockets metadata subscription list.\n\tdelete ws.metadata.subscriptions.game;\n}\n\n/**\n * Sends the game info to the player, the info they need to load the online game.\n *\n * Makes sure not to send sensitive info, such as player's browser-id cookies.\n * @param servergame - The game they're in.\n * @param playerSocket - Their websocket\n * @param playerColor - The color they are.\n * @param replyto - The ID of the incoming socket message. This is used for the `replyto` property on our response.\n */\nfunction sendGameInfoToPlayer(\n\tservergame: ServerGame,\n\tplayerSocket: CustomWebSocket,\n\tplayerColor: Player,\n\treplyto?: number,\n): void {\n\tconst ratings = getRatingDataForGamePlayers(\n\t\tservergame.match.playerData,\n\t\tservergame.match.variant,\n\t);\n\n\tconst gameUpdateContents = getGameUpdateMessageContents(servergame, playerColor, false);\n\n\tconst messageContents = {\n\t\tgameInfo: {\n\t\t\tid: servergame.match.id,\n\t\t\trated: servergame.match.rated,\n\t\t\tpublicity: servergame.match.publicity,\n\t\t\tplayerRatings: ratings,\n\t\t},\n\t\tmetadata: servergame.basegame.metadata,\n\t\tyouAreColor: playerColor,\n\t\t...gameUpdateContents,\n\t};\n\n\tsendSocketMessage(playerSocket, 'game', 'joingame', messageContents, replyto);\n}\n\n/**\n * Returns the current elo of all players in the game on the leaderboard\n * of the variant being played, or the INFINITY leaderboard if the variant does not have a leaderboard.\n * @returns An object containing the rating for non-guests in the game, and whether we are confident in that rating, IF the variant has a leaderboard.\n */\nfunction getRatingDataForGamePlayers(\n\tplayers: PlayerGroup<{ identifier: AuthMemberInfo }>,\n\tvariant: VariantCode,\n): PlayerGroup<Rating> {\n\t// Fallback to INFINITY leaderboard if the variant does not have a leaderboard.\n\tconst leaderboardId = VariantLeaderboards[variant] ?? Leaderboards.INFINITY;\n\n\tconst ratingData: PlayerGroup<Rating> = {};\n\tfor (const [color, { identifier }] of Object.entries(players)) {\n\t\tif (!identifier.signedIn) continue; // Not a member, no rating to send\n\t\tconst user_id = identifier.user_id;\n\t\tratingData[Number(color) as Player] = getEloOfPlayerInLeaderboard(user_id, leaderboardId);\n\t}\n\n\treturn ratingData;\n}\n\n/**\n * Generates metadata for a game including event details, player information, and timestamps.\n */\nfunction constructMetadataOfGame(\n\trated: boolean,\n\tvariantKey: VariantCode,\n\tclock: TimeControl,\n\tdateTimestamp: number,\n\tplayerdata: PlayerGroup<{ rating?: Rating; identifier: AuthMemberInfo }>,\n): MetaData {\n\tconst white = playerdata[p.WHITE]!.identifier;\n\tconst black = playerdata[p.BLACK]!.identifier;\n\tconst whiteIdentity = {\n\t\tname: white.signedIn ? white.username : metadatautil.GUEST_NAME_ICN_METADATA, // Protect browser's browser-id cookie\n\t\tid: white.signedIn ? white.user_id : undefined,\n\t\telo: playerdata[p.WHITE]?.rating\n\t\t\t? metadatautil.getFormattedElo(playerdata[p.WHITE]!.rating!)\n\t\t\t: undefined,\n\t};\n\tconst blackIdentity = {\n\t\tname: black.signedIn ? black.username : metadatautil.GUEST_NAME_ICN_METADATA, // Protect browser's browser-id cookie\n\t\tid: black.signedIn ? black.user_id : undefined,\n\t\telo: playerdata[p.BLACK]?.rating\n\t\t\t? metadatautil.getFormattedElo(playerdata[p.BLACK]!.rating!)\n\t\t\t: undefined,\n\t};\n\treturn servermetadatautil.buildGameMetadata(\n\t\trated,\n\t\tvariantKey,\n\t\tclock,\n\t\tdateTimestamp,\n\t\twhiteIdentity,\n\t\tblackIdentity,\n\t);\n}\n\n/**\n * Resyncs a client's websocket to a game. The client already\n * knows the game id and much other information. We only need to send\n * them the current move list, player timers, and game conclusion.\n * @param ws - Their websocket\n * @param servergame - The game\n * @param colorPlayingAs - Their color\n * @param [replyToMessageID] - If specified, the id of the incoming socket message this update will be the reply to\n */\nfunction resyncToGame(\n\tws: CustomWebSocket,\n\tservergame: ServerGame,\n\tcolorPlayingAs: Player,\n\treplyToMessageID?: number,\n): void {\n\t// If their socket isn't subscribed, connect them to the game!\n\tif (!ws.metadata.subscriptions.game)\n\t\tsubscribeClientToGame(servergame, ws, colorPlayingAs, { sendGameInfo: false });\n\n\t// This function ALREADY sends all the information the client needs to resync!\n\tsendGameUpdateToColor(servergame, colorPlayingAs, false, { replyTo: replyToMessageID });\n}\n\n/**\n * Alerts both players in the game of the game conclusion if it has ended,\n * and the current moves list and timers.\n * @param servergame - The game\n */\nfunction broadcastGameUpdate(servergame: ServerGame): void {\n\tfor (const player in servergame.match.playerData) {\n\t\tsendGameUpdateToColor(servergame, Number(player) as Player, false);\n\t}\n}\n\n/**\n * Alerts the player of the specified color of the game conclusion if it has ended,\n * and the current moves list and timers.\n * @param servergame - The game\n * @param color - The color of the player\n * @param forceSync - If true, the client will force its move list to exactly match the server's (not re-submitting any extra move)\n * @param [options.replyTo] - If specified, the id of the incoming socket message this update will be the reply to\n */\nfunction sendGameUpdateToColor(\n\tservergame: ServerGame,\n\tcolor: Player,\n\tforceSync: boolean,\n\t{ replyTo }: { replyTo?: number } = {},\n): void {\n\tconst playerdata = servergame.match.playerData[color];\n\tif (playerdata?.socket === undefined) return; // Not connected, can't send message\n\n\tconst messageContents = getGameUpdateMessageContents(servergame, color, forceSync);\n\tsendSocketMessage(playerdata.socket, 'game', 'gameupdate', messageContents, replyTo);\n}\n\n/**\n * Constructs a gameupdate message UNIQUE to the player!\n * Unique because only one person receives the millisUntilAutoAFKResign\n * property - the opposite player of the one who has gone AFK.\n */\nfunction getGameUpdateMessageContents(\n\tservergame: ServerGame,\n\tcolor: Player,\n\tforceSync: boolean,\n): GameUpdateMessage {\n\tconst messageContents: GameUpdateMessage = {\n\t\tgameConclusion: servergame.basegame.gameConclusion,\n\t\tmoves: servergame.basegame.moves.map((m) => simplifyMove(m)),\n\t\tparticipantState: getParticipantState(\n\t\t\tservergame.match,\n\t\t\tcolor,\n\t\t\tservergame.basegame.whosTurn,\n\t\t),\n\t\tforceSync,\n\t};\n\n\t// Include timer info if it's timed\n\tif (!servergame.basegame.untimed)\n\t\tmessageContents.clockValues = getGameClockValues(servergame.basegame);\n\n\treturn messageContents;\n}\n\n/**\n * Alerts all players in the game of the rating changes of the game\n * @param match - The game\n * @param ratingdata - The rating data\n */\nfunction sendRatingChangeToAllPlayers(match: MatchInfo, ratingdata: RatingData): void {\n\tconst messageContents = getRatingChangeMessageContents(ratingdata);\n\tfor (const playerdata of Object.values(match.playerData)) {\n\t\tif (playerdata.socket === undefined) continue; // Not connected, can't send message\n\t\tsendSocketMessage(playerdata.socket, 'game', 'gameratingchange', messageContents);\n\t}\n}\n\n/**\n * Calculates the json object we send to the client's containing the\n * rating changes from the results of the rated game.\n */\nfunction getRatingChangeMessageContents(\n\tratingdata: RatingData,\n): PlayerGroup<PlayerRatingChangeInfo> {\n\tconst messageContents: PlayerGroup<PlayerRatingChangeInfo> = {};\n\tfor (const [playerStr, playerRating] of Object.entries(ratingdata)) {\n\t\tmessageContents[Number(playerStr) as Player] = {\n\t\t\tnewRating: {\n\t\t\t\tvalue: playerRating.elo_after_game!,\n\t\t\t\tconfident: playerRating.rating_deviation_after_game! <= UNCERTAIN_LEADERBOARD_RD,\n\t\t\t},\n\t\t\tchange: playerRating.elo_change_from_game!,\n\t\t};\n\t}\n\n\treturn messageContents;\n}\n\nfunction getParticipantState(match: MatchInfo, color: Player, whosTurn: Player): ParticipantState {\n\tconst opponentColor = typeutil.invertPlayer(color);\n\tconst now = Date.now();\n\tconst opponentData = match.playerData[opponentColor]!;\n\n\tconst participantState: ParticipantState = {\n\t\tdrawOffer: {\n\t\t\tunconfirmed: doesColorHaveExtendedDrawOffer(match, opponentColor), // True if our opponent has extended a draw offer we haven't yet confirmed/denied\n\t\t\tlastOfferPly: getLastDrawOfferPlyOfColor(match, color), // The move ply WE HAVE last offered a draw, if we have, otherwise undefined.\n\t\t},\n\t};\n\n\t// Include other relevant stuff if defined...\n\n\t// Only send AFK countdown to the opponent, not to the AFK player themselves.\n\tif (match.autoAFKResignTime !== undefined && color !== whosTurn) {\n\t\tconst millisLeftUntilAutoAFKResign = match.autoAFKResignTime - now;\n\t\tparticipantState.millisUntilAutoAFKResign = millisLeftUntilAutoAFKResign;\n\t}\n\n\t// If their opponent has disconnected, send them that info too.\n\tif (opponentData.disconnect.timeToAutoLoss !== undefined) {\n\t\tparticipantState.disconnect = {\n\t\t\tmillisUntilAutoDisconnectResign: opponentData.disconnect.timeToAutoLoss - now,\n\t\t\twasByChoice: opponentData.disconnect.wasByChoice,\n\t\t};\n\t}\n\n\treturn participantState;\n}\n\n/**\n * Tests if the given socket belongs in the game. If so, it returns the color they are.\n * @param match - The game\n * @param ws - The websocket\n * @returns The color they are, if they belong, otherwise *undefined*.\n */\nfunction doesSocketBelongToGame_ReturnColor(\n\tmatch: MatchInfo,\n\tws: CustomWebSocket,\n): Player | undefined {\n\tif (match.id === ws.metadata.subscriptions.game?.id)\n\t\treturn ws.metadata.subscriptions.game?.color;\n\t// Color isn't provided in their subscriptions, perhaps this is a resync/refresh?\n\treturn doesPlayerBelongToGame_ReturnColor(match, ws.metadata.memberInfo);\n}\n\n/**\n * Tests if the given player belongs in the game. If so, it returns the color they are.\n * @param match - The game\n * @param player - The player object with one of 2 properties: `member` or `browser`, depending on if they are signed in.\n * @returns The color they are, if they belong, otherwise *false*.\n */\nfunction doesPlayerBelongToGame_ReturnColor(\n\tmatch: MatchInfo,\n\tplayer: AuthMemberInfo,\n): Player | undefined {\n\tfor (const [splayer, data] of Object.entries(match.playerData)) {\n\t\tconst playercolor = Number(splayer) as Player;\n\t\tif (memberInfoEq(player, data.identifier)) return playercolor;\n\t}\n\treturn undefined;\n}\n\n/**\n * Sends a websocket message to the specified color in the game.\n * @param match - The game\n * @param color - The color of the player in this game to send the message to\n * @param sub - Where this message should be routed to, client side.\n * @param action - The action the client should perform. If sub is \"general\" and action is \"notify\" or \"notifyerror\", then this needs to be the key of the message in the TOML, and we will auto-translate it!\n * @param value - The value to send to the client.\n */\nfunction sendMessageToSocketOfColor(\n\tmatch: MatchInfo,\n\tcolor: Player,\n\tsub: string,\n\taction: string,\n\tvalue?: any,\n): void {\n\tconst data = match.playerData[color];\n\tif (data === undefined) {\n\t\tlogEventsAndPrint(\n\t\t\t`Tried to send a message to player ${color} when there isn't one in game!`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn;\n\t}\n\tconst ws = data.socket;\n\tif (!ws) return; // They are not connected, can't send message\n\tif (sub === 'general') {\n\t\tif (action === 'notify') return sendNotify(ws, value); // The value needs translating\n\t\tif (action === 'notifyerror') return sendNotifyError(ws, value); // The value needs translating\n\t}\n\tsendSocketMessage(ws, sub, action, value); // Value doesn't need translating, send normally.\n}\n\n/**\n * Safely prints a game to the console. Temporarily stringifies the\n * player sockets to remove self-referencing, and removes Node timers.\n * @param servergame - The game\n */\nfunction printGame(servergame: ServerGame): void {\n\tconst stringifiedGame = getSimplifiedGameString(servergame);\n\tconsole.log(JSON.parse(stringifiedGame)); // Turning it back into an object gives it a special formatting in the console, instead of just printing a string.\n}\n\n/**\n * Stringifies a game, by removing any recursion or Node timers from within, so it's JSON.stringify()'able.\n * @param servergame - The game\n * @returns The simplified game string\n */\nfunction getSimplifiedGameString(servergame: ServerGame): string {\n\t// Only transfer interesting information.\n\tconst players: PlayerGroup<AuthMemberInfo> = {};\n\tfor (const [c, data] of Object.entries(servergame.match.playerData)) {\n\t\tplayers[Number(c) as Player] = data.identifier;\n\t}\n\tlet moves: undefined | string[];\n\tif (servergame.basegame.moves.length > 0) moves = servergame.basegame.moves.map((m) => m.token);\n\tconst simplifiedGame = {\n\t\tid: servergame.match.id,\n\t\ttimeCreated: `${servergame.basegame.metadata.UTCDate} ${servergame.basegame.metadata.UTCTime}`,\n\t\ttimeEnded: servergame.match.timeEnded,\n\t\tvariant: servergame.match.variant,\n\t\tclock: servergame.basegame.metadata.TimeControl,\n\t\trated: servergame.match.rated,\n\t\tplayers,\n\t\tmoves,\n\t};\n\n\treturn JSON.stringify(simplifiedGame);\n}\n\n/**\n * Returns *true* if the provided game has ended (gameConclusion truthy).\n * Games that are over are retained for a short period of time\n * to allow disconnected players to reconnect to see the results.\n * @param basegame - The game\n * @returns true if the game is over (gameConclusion truthy)\n */\nfunction isGameOver(basegame: Game): boolean {\n\treturn basegame.gameConclusion !== undefined;\n}\n\n/**\n * Returns true if the provided color has an actively running auto-resign timer.\n * NOT whether the 5-second reconnection cushion window timer has started.\n * @param match - The game they're in\n * @param color - The color they are in this game\n */\nfunction isAutoResignDisconnectTimerActiveForColor(match: MatchInfo, color: Player): boolean {\n\t// If these are defined, then the timer is defined.\n\treturn match.playerData[color]!.disconnect.timeToAutoLoss !== undefined;\n}\n\n/**\n * Sends the current clock values to the player who just moved.\n * @param servergame - The game\n */\nfunction sendUpdatedClockToColor(servergame: ServerGame, color: Player): void {\n\tif (color !== p.BLACK && color !== p.WHITE) {\n\t\tlogEventsAndPrint(\n\t\t\t`Color must be white or black when sending clock to color! Got: ${color}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn;\n\t}\n\tif (servergame.basegame.untimed) return; // Don't send clock values in an untimed game\n\n\tconst message = getGameClockValues(servergame.basegame);\n\tconst playerSocket = servergame.match.playerData[color]!.socket;\n\tif (!playerSocket) return; // They are not connected, can't send message\n\tsendSocketMessage(playerSocket, 'game', 'clock', message);\n}\n\n/**\n * Return the clock values of the servergame that can be sent to a client or logged.\n * It also includes who's clock is currently counting down, if one is.\n * This also updates the clocks, as the players current time should not be the same as when their turn first started.\n * @param basegame - The game\n */\nfunction getGameClockValues(basegame: Game): ClockValues {\n\tif (basegame.untimed)\n\t\tthrow new Error('Tried to get values of clocks from a game that had none!');\n\tupdateClockValues(basegame);\n\treturn clock.createEdit(basegame.clocks);\n}\n\n/**\n * Update the games clock values. This is NOT called after the clocks are pushed,\n * This is called right before we send clock information to the client,\n *  so that it's as accurate as possible.\n * @param basegame - The game\n */\nfunction updateClockValues(basegame: Game): undefined {\n\tconst now = Date.now();\n\tif (basegame.untimed || !isGameResignable(basegame) || isGameOver(basegame)) return;\n\tif (basegame.clocks.timeAtTurnStart === undefined)\n\t\tthrow new Error('cannot update clock values when timeAtTurnStart is not defined!');\n\n\tconst timeElapsedSinceTurnStart = now - basegame.clocks.timeAtTurnStart;\n\tconst newTime = basegame.clocks.timeRemainAtTurnStart! - timeElapsedSinceTurnStart;\n\tconst playerdata = basegame.clocks.currentTime;\n\tif (playerdata[basegame.whosTurn] === undefined) {\n\t\tlogEventsAndPrint(\n\t\t\t`Cannot update games clock values when whose turn doesn't have a clock! \"${basegame.whosTurn}\"`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn;\n\t}\n\tplayerdata[basegame.whosTurn] = newTime;\n\treturn;\n}\n\n/**\n * Sends a move to the player provided\n * @param servergame - The game\n * @param color - The color of the player to send the latest move to\n */\nfunction sendMoveToColor({ basegame, match }: ServerGame, color: Player, move: MoveRecord): void {\n\tif (!(color in match.playerData)) {\n\t\tlogEventsAndPrint(\n\t\t\t`Color to send move to must be one that is in the game (white or black)! ${color}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn;\n\t}\n\n\tconst message: OpponentsMoveMessage = {\n\t\tmove: simplifyMove(move),\n\t\tgameConclusion: basegame.gameConclusion,\n\t\tmoveNumber: basegame.moves.length,\n\t};\n\tif (!basegame.untimed) message.clockValues = getGameClockValues(basegame);\n\tconst sendToSocket = match.playerData[color]!.socket;\n\tif (!sendToSocket) return; // They are not connected, can't send message\n\tsendSocketMessage(sendToSocket, 'game', 'move', message);\n}\n\n/**\n * Simplifies a game's move into the minimal info needed for the client to reconstruct the move.\n */\nfunction simplifyMove(move: MoveRecord): { token: string } {\n\treturn { token: move.token };\n}\n\n/**\n * Cancel the timer to delete a game after it has ended if it is currently running.\n */\nfunction cancelDeleteGameTimer(match: MatchInfo): void {\n\tclearTimeout(match.deleteTimeoutID);\n}\n\n/**\n * Tests if the game is resignable (at least 2 moves have been played).\n * If not, then the game is abortable.\n * @param basegame - The game\n * @returns *true* if the game is resignable.\n */\nfunction isGameResignable(basegame: Game): boolean {\n\treturn basegame.moves.length > 1;\n}\n\n/**\n * Tests if the game has just become resignable with the latest move (exactly 2 moves have been played).\n * @param basegame - The game\n * @returns *true* if the game has just become resignable after the last move.\n */\nfunction isGameBorderlineResignable(basegame: Game): boolean {\n\treturn basegame.moves.length === 2;\n}\n\n/**\n * Returns the color of the player that played that moveIndex within the moves list.\n * Returns error if index -1\n * @param basegame\n * @param i - The moveIndex\n * @returns - The color that played the moveIndex\n */\nfunction getColorThatPlayedMoveIndex(basegame: Game, i: number): Player {\n\tconst turnOrder = basegame.gameRules.turnOrder;\n\tif (i === -1) return turnOrder[turnOrder.length - 1]!;\n\n\treturn turnOrder[i % turnOrder.length]!;\n}\n\nexport type { ServerGame, MatchInfo, PlayerData, PlayerDisconnect };\n\nexport default {\n\tinitMatch,\n\tsubscribeClientToGame,\n\tunsubClientFromGame,\n\tresyncToGame,\n\tassignWhiteBlackPlayersFromInvite,\n\tconstructMetadataOfGame,\n\tbroadcastGameUpdate,\n\tsendGameUpdateToColor,\n\tsendRatingChangeToAllPlayers,\n\tdoesSocketBelongToGame_ReturnColor,\n\tsendMessageToSocketOfColor,\n\tprintGame,\n\tgetSimplifiedGameString,\n\tisGameOver,\n\tisAutoResignDisconnectTimerActiveForColor,\n\tgetGameClockValues,\n\tsendUpdatedClockToColor,\n\tsendMoveToColor,\n\tcancelDeleteGameTimer,\n\tisGameResignable,\n\tisGameBorderlineResignable,\n\tgetColorThatPlayedMoveIndex,\n\tgetRatingDataForGamePlayers,\n};\n"
  },
  {
    "path": "src/server/game/gamemanager/joingame.ts",
    "content": "// src/server/game/gamemanager/joingame.ts\n\n/**\n * This script checks if a user belongs to a game, when they send the 'joingame'\n * message, and if so, sends them the game info\n */\n\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\n\nimport gameutility from './gameutility.js';\nimport liveGameValues from './liveGameValues.js';\nimport { getGameBySocket } from './gamemanager.js';\nimport { cancelAutoAFKResignTimer, cancelDisconnectTimer } from './afkdisconnect.js';\n\n/**\n * The method that fires when a client sends the 'joingame' command after refreshing the page.\n * This should fetch any game their in and reconnect them to it.\n * @param ws - Their new websocket\n */\nfunction onJoinGame(ws: CustomWebSocket): void {\n\tconst servergame = getGameBySocket(ws);\n\tif (!servergame) return; // They don't belong in a game, don't join them in one.\n\n\tconst colorPlayingAs = gameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws)!;\n\tgameutility.subscribeClientToGame(servergame, ws, colorPlayingAs);\n\n\t// Cancel the timer that auto loses them by AFK, IF IT is their turn!\n\tif (servergame.basegame.whosTurn === colorPlayingAs) {\n\t\tconst hadAFKTimer = servergame.match.autoAFKResignTime !== undefined;\n\t\tcancelAutoAFKResignTimer(servergame, true);\n\t\tif (hadAFKTimer) liveGameValues.onPlayerAFKReturn(servergame);\n\t}\n\tcancelDisconnectTimer(servergame.match, colorPlayingAs);\n\tliveGameValues.onPlayerReconnected(servergame, colorPlayingAs);\n}\n\nexport { onJoinGame };\n"
  },
  {
    "path": "src/server/game/gamemanager/liveGameRestore.ts",
    "content": "// src/server/game/gamemanager/liveGameRestore.ts\n\n/**\n * This script restores live games from the database on server startup.\n *\n * It reads all rows from live_games and live_player_games, reconstructs\n * the full ServerGame objects (metadata, clocks, boardsim, player identities),\n * and determines which pending timers (AFK resign, auto time loss, disconnect,\n * delete) need to be reinstated.\n *\n * See dev-utils/live-game-persistence.md for the schema and restoration details.\n */\n\nimport type { MoveRecord } from '../../../shared/chess/logic/movepiece.js';\nimport type { VariantCode } from '../../../shared/chess/variants/variantdictionary.js';\nimport type { AuthMemberInfo } from '../../types.js';\nimport type { GameConclusion } from '../../../shared/chess/util/winconutil.js';\nimport type { LiveGamesRecord } from '../../database/liveGamesManager.js';\nimport type { Player, PlayerGroup } from '../../../shared/chess/util/typeutil.js';\nimport type { LivePlayerGamesRecord } from '../../database/livePlayerGamesManager.js';\nimport type { MatchInfo, PlayerData, ServerGame } from './gameutility.js';\nimport type { ClockValues, MetaData, TimeControl } from '../../../shared/types.js';\nimport type {\n\tCondition,\n\tDrawCondition,\n\tWinCondition,\n} from '../../../shared/chess/util/winconutil.js';\n\nimport jsutil from '../../../shared/util/jsutil.js';\nimport gamefile from '../../../shared/chess/logic/gamefile.js';\nimport movepiece from '../../../shared/chess/logic/movepiece.js';\nimport icnconverter from '../../../shared/chess/logic/icn/icnconverter.js';\nimport metadatautil from '../../../shared/chess/util/metadatautil.js';\nimport { players as p } from '../../../shared/chess/util/typeutil.js';\n\nimport servermetadatautil from '../servermetadatautil.js';\nimport { logEventsAndPrint } from '../../middleware/logEvents.js';\nimport { getMemberDataByCriteria } from '../../database/memberManager.js';\nimport { getLivePlayerGamesForGame } from '../../database/livePlayerGamesManager.js';\nimport { getAllLiveGames, deleteLiveGame } from '../../database/liveGamesManager.js';\n\n// Types -----------------------------------------------------------------------------------------\n\n/**\n * Result of restoring games. The caller is responsible for adding them\n * to activeGames and setting up their event connections.\n */\ninterface RestoredGame {\n\tservergame: ServerGame;\n\t/** Timers that need to be started after adding to activeGames. */\n\tpendingTimers: PendingTimers;\n}\n\n/** Timers that may need to be started for a restored game, based on its state at the time of server shutdown. */\ninterface PendingTimers {\n\t/** If defined, the delete game timer should fire after this many ms. 0 means immediately. */\n\tdeleteTimerMs?: number;\n\t/** If defined, the AFK resign timer should fire after this many ms. 0 means immediately. */\n\tafkResignTimerMs?: number;\n\t/** Per-player disconnect state to restore. */\n\tdisconnectTimers: PlayerGroup<DisconnectTimerState>;\n\t/**\n\t * If defined, the auto time loss timer for the current player's\n\t * turn should fire after this many ms. 0 means immediately.\n\t */\n\tautoTimeLossMs?: number;\n}\n\n/** Represents the state of a player's disconnect timer that needs to be restored. */\ninterface DisconnectTimerState {\n\t/** 'cushion' = still in 5s cushion, 'timer' = auto-resign timer active, 'fresh' = was connected before restart */\n\ttype: 'cushion' | 'timer' | 'fresh';\n\t/** Milliseconds remaining until the timer fires. 0 or negative means immediately. */\n\tremainingMs: number;\n\t/** Whether the disconnect was by choice. */\n\tbyChoice: boolean;\n}\n\n// Restoration ------------------------------------------------------------------------------------\n\n/**\n * Restores all live games from the database.\n * Called once during server startup, after initDatabase() and before accepting connections.\n *\n * @returns An array of restored ServerGame objects with their pending timers.\n * The caller is responsible for integrating these into the active game system.\n */\nfunction restoreAllLiveGames(): RestoredGame[] {\n\tconst liveGameRows = getAllLiveGames();\n\tif (liveGameRows.length === 0) return [];\n\n\tconsole.log(`Restoring ${liveGameRows.length} live game(s) from database.`);\n\n\tconst restored: RestoredGame[] = [];\n\n\tfor (const gameRow of liveGameRows) {\n\t\ttry {\n\t\t\tconst playerRows = getLivePlayerGamesForGame(gameRow.game_id);\n\t\t\tif (playerRows.length !== 2) {\n\t\t\t\tlogEventsAndPrint(\n\t\t\t\t\t`Live game ${gameRow.game_id} has ${playerRows.length} player rows, expected 2. Skipping restoration of this game.`,\n\t\t\t\t\t'errLog.txt',\n\t\t\t\t);\n\t\t\t\tdeleteLiveGame(gameRow.game_id);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst result = restoreSingleGame(gameRow, playerRows);\n\t\t\trestored.push(result);\n\t\t} catch (error: unknown) {\n\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\tlogEventsAndPrint(\n\t\t\t\t`Failed to restore live game ${gameRow.game_id}: ${message}`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t\t// Delete the corrupt game from the database so it doesn't block future restarts.\n\t\t\tdeleteLiveGame(gameRow.game_id);\n\t\t}\n\t}\n\n\treturn restored;\n}\n\n/**\n * Restores a single live game from its database rows.\n */\nfunction restoreSingleGame(\n\tgameRow: LiveGamesRecord,\n\tplayerRows: LivePlayerGamesRecord[],\n): RestoredGame {\n\t// 1. Reconstruct AuthMemberInfo for each player\n\tconst playerIdentities = reconstructPlayerIdentities(playerRows);\n\n\t// 2. Reconstruct MetaData\n\tconst gameMetadata = reconstructMetadata(gameRow, playerRows, playerIdentities);\n\n\t// 3. Reconstruct clock values for timed games\n\tconst clockValues = reconstructClockValues(gameRow, playerRows);\n\n\t// 4. Reconstruct game conclusion\n\tconst gameConclusion = reconstructConclusion(gameRow);\n\n\t// 8. Reconstruct MatchInfo\n\tconst matchInfo = reconstructMatchInfo(gameRow, playerRows, playerIdentities);\n\n\t// 5. Create the basegame\n\tconst basegame = gamefile.initGame(\n\t\tgameMetadata,\n\t\tgameRow.time_created,\n\t\tmatchInfo.variant,\n\t\tgameConclusion,\n\t\tclockValues,\n\t);\n\n\t// Note: clock state (ticking color, timeAtTurnStart) is already set correctly\n\t// by clock.edit() inside initGame() via the clockValues we pass in.\n\n\tconst servergame: ServerGame = { match: matchInfo, basegame };\n\n\t// 6. Parse & replay moves, conditionally constructing boardsim\n\tconst moves: MoveRecord[] = parseMoves(gameRow.moves);\n\n\tif (gameRow.validate_moves) {\n\t\tconst boardsim = gamefile.initBoard(\n\t\t\tbasegame.gameRules,\n\t\t\tmatchInfo.variant,\n\t\t\tbasegame.dateTimestamp,\n\t\t);\n\t\tservergame.boardsim = boardsim;\n\t\t// Pushes moves to BOTH the basegame and boardsim\n\t\tmovepiece.makeAllMovesInGame({ basegame, boardsim }, moves);\n\t} else {\n\t\t// Push all the moves to JUST the basegame\n\t\tfor (const move of moves) {\n\t\t\tbasegame.moves.push(jsutil.deepCopyObject(move));\n\t\t}\n\n\t\t// Update whosTurn based on move count\n\t\tbasegame.whosTurn =\n\t\t\tbasegame.gameRules.turnOrder[\n\t\t\t\tbasegame.moves.length % basegame.gameRules.turnOrder.length\n\t\t\t]!;\n\t}\n\n\t// 9. Compute pending timers\n\tconst pendingTimers = computePendingTimers(gameRow, playerRows, servergame);\n\n\treturn { servergame, pendingTimers };\n}\n\n// Helper functions ---------------------------------------------------------------------------------\n\n/**\n * Reconstructs AuthMemberInfo for each player from the database rows.\n */\nfunction reconstructPlayerIdentities(\n\tplayerRows: LivePlayerGamesRecord[],\n): PlayerGroup<AuthMemberInfo> {\n\tconst identities: PlayerGroup<AuthMemberInfo> = {};\n\n\tfor (const row of playerRows) {\n\t\tconst player = row.player_number as Player;\n\n\t\tif (row.user_id !== null) {\n\t\t\t// Signed-in player: look up username and roles from members table\n\t\t\tconst memberData = getMemberDataByCriteria(\n\t\t\t\t['username', 'roles'],\n\t\t\t\t'user_id',\n\t\t\t\trow.user_id,\n\t\t\t);\n\n\t\t\tif (memberData) {\n\t\t\t\tlet roles = null;\n\t\t\t\ttry {\n\t\t\t\t\troles = memberData.roles ? JSON.parse(memberData.roles) : null;\n\t\t\t\t} catch {\n\t\t\t\t\tlogEventsAndPrint(\n\t\t\t\t\t\t`Failed to parse roles for user_id ${row.user_id} during game restoration.`,\n\t\t\t\t\t\t'errLog.txt',\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tidentities[player] = {\n\t\t\t\t\tsignedIn: true,\n\t\t\t\t\tuser_id: row.user_id,\n\t\t\t\t\tusername: memberData.username,\n\t\t\t\t\troles,\n\t\t\t\t\tbrowser_id: row.browser_id,\n\t\t\t\t};\n\t\t\t} else {\n\t\t\t\t// User was deleted since the game started. Treat as guest.\n\t\t\t\tidentities[player] = {\n\t\t\t\t\tsignedIn: false,\n\t\t\t\t\tbrowser_id: row.browser_id,\n\t\t\t\t};\n\t\t\t}\n\t\t} else {\n\t\t\t// Guest player\n\t\t\tidentities[player] = {\n\t\t\t\tsignedIn: false,\n\t\t\t\tbrowser_id: row.browser_id,\n\t\t\t};\n\t\t}\n\t}\n\n\treturn identities;\n}\n\n/**\n * Reconstructs MetaData from the stored atomic values.\n */\nfunction reconstructMetadata(\n\tgameRow: LiveGamesRecord,\n\tplayerRows: LivePlayerGamesRecord[],\n\tplayerIdentities: PlayerGroup<AuthMemberInfo>,\n): MetaData {\n\tconst white = playerIdentities[p.WHITE]!;\n\tconst black = playerIdentities[p.BLACK]!;\n\n\t// Find per-player rows for signed-in identity lookup\n\tconst whiteRow = playerRows.find((r) => r.player_number === p.WHITE)!;\n\tconst blackRow = playerRows.find((r) => r.player_number === p.BLACK)!;\n\n\treturn servermetadatautil.buildGameMetadata(\n\t\tBoolean(gameRow.rated),\n\t\tgameRow.variant as VariantCode,\n\t\tgameRow.clock as TimeControl,\n\t\tgameRow.time_created,\n\t\t{\n\t\t\tname: white.signedIn ? white.username : metadatautil.GUEST_NAME_ICN_METADATA, // Protect browser's browser-id cookie\n\t\t\tid: white.signedIn ? white.user_id : undefined,\n\t\t\telo: whiteRow.elo ?? undefined,\n\t\t},\n\t\t{\n\t\t\tname: black.signedIn ? black.username : metadatautil.GUEST_NAME_ICN_METADATA, // Protect browser's browser-id cookie\n\t\t\tid: black.signedIn ? black.user_id : undefined,\n\t\t\telo: blackRow.elo ?? undefined,\n\t\t},\n\t);\n}\n\n/**\n * Reconstructs ClockValues from stored per-player times.\n */\nfunction reconstructClockValues(\n\tgameRow: LiveGamesRecord,\n\tplayerRows: LivePlayerGamesRecord[],\n): ClockValues | undefined {\n\t// Untimed games don't have clock values\n\tif (gameRow.clock === '-') return undefined;\n\n\tconst clocks: PlayerGroup<number> = {};\n\tfor (const row of playerRows) {\n\t\tif (row.time_remaining_ms !== null) {\n\t\t\tclocks[row.player_number as Player] = row.time_remaining_ms;\n\t\t}\n\t}\n\n\tconst colorTicking =\n\t\tgameRow.color_ticking === null ? undefined : (gameRow.color_ticking as Player);\n\tconst timeColorTickingLosesAt =\n\t\tcolorTicking !== undefined\n\t\t\t? gameRow.clock_snapshot_time! + clocks[colorTicking]!\n\t\t\t: undefined;\n\n\treturn {\n\t\tclocks,\n\t\tcolorTicking,\n\t\ttimeColorTickingLosesAt,\n\t};\n}\n\n/**\n * Reconstructs GameConclusion from stored values.\n */\nfunction reconstructConclusion(gameRow: LiveGamesRecord): GameConclusion | undefined {\n\tif (gameRow.conclusion_condition === null) return undefined; // Game is ongoing still\n\n\tconst condition = gameRow.conclusion_condition as Condition;\n\n\tif (gameRow.conclusion_victor !== null) {\n\t\t// Decisive result — someone won\n\t\treturn {\n\t\t\tcondition: condition as WinCondition,\n\t\t\tvictor: gameRow.conclusion_victor as Player,\n\t\t};\n\t} else if (condition === 'aborted') {\n\t\t// Aborted — victor is undefined\n\t\treturn { condition: 'aborted' };\n\t} else {\n\t\t// Draw — victor is null\n\t\treturn {\n\t\t\tcondition: condition as DrawCondition,\n\t\t\tvictor: null,\n\t\t};\n\t}\n}\n\n/**\n * Reconstructs the MatchInfo from stored values.\n */\nfunction reconstructMatchInfo(\n\tgameRow: LiveGamesRecord,\n\tplayerRows: LivePlayerGamesRecord[],\n\tplayerIdentities: PlayerGroup<AuthMemberInfo>,\n): MatchInfo {\n\tconst playerData: PlayerGroup<PlayerData> = {};\n\n\tfor (const row of playerRows) {\n\t\tconst identity = playerIdentities[row.player_number as Player]!;\n\n\t\tplayerData[row.player_number as Player] = {\n\t\t\tidentifier: identity,\n\t\t\tlastOfferPly: row.last_draw_offer_ply ?? undefined,\n\t\t\tdisconnect: {\n\t\t\t\tstartID: undefined,\n\t\t\t\tstartTime: row.disconnect_cushion_end_time ?? undefined,\n\t\t\t\ttimeoutID: undefined,\n\t\t\t\ttimeToAutoLoss: undefined,\n\t\t\t\twasByChoice: undefined,\n\t\t\t},\n\t\t};\n\t}\n\n\treturn {\n\t\tid: gameRow.game_id,\n\t\tvariant: gameRow.variant as VariantCode,\n\t\ttimeCreated: gameRow.time_created,\n\t\ttimeEnded: gameRow.time_ended ?? undefined,\n\t\tpublicity: gameRow.private === 1 ? 'private' : 'public',\n\t\trated: gameRow.rated === 1,\n\t\tclock: gameRow.clock as TimeControl,\n\t\tplayerData,\n\t\tdrawOfferState:\n\t\t\tgameRow.draw_offer_state === null ? undefined : (gameRow.draw_offer_state as Player),\n\t\tautoAFKResignTime: gameRow.afk_resign_time ?? undefined,\n\t\tpositionPasted: gameRow.position_pasted === 1,\n\t};\n}\n\n/**\n * Parses the moves string back into move objects.\n */\nfunction parseMoves(movesString: string): MoveRecord[] {\n\tif (movesString === '') return [];\n\treturn icnconverter.parseShortFormMoves(movesString);\n}\n\n/**\n * Computes which timers need to be started after restoration.\n */\nfunction computePendingTimers(\n\tgameRow: LiveGamesRecord,\n\tplayerRows: LivePlayerGamesRecord[],\n\tservergame: ServerGame,\n): PendingTimers {\n\tconst now = Date.now();\n\n\tconst timers: PendingTimers = {\n\t\tdisconnectTimers: {},\n\t};\n\n\t// Delete timer for concluded games\n\tif (gameRow.delete_time !== null) {\n\t\tconst remaining = gameRow.delete_time - now;\n\t\ttimers.deleteTimerMs = Math.max(remaining, 0);\n\t}\n\n\t// AFK resign timer\n\tif (gameRow.afk_resign_time !== null) {\n\t\tconst remaining = gameRow.afk_resign_time - now;\n\t\ttimers.afkResignTimerMs = Math.max(remaining, 0);\n\t}\n\n\t// Auto time loss timer for timed, ongoing games\n\tif (!servergame.basegame.untimed && gameRow.color_ticking !== null) {\n\t\tconst tickingTime =\n\t\t\tservergame.basegame.clocks.currentTime[gameRow.color_ticking as Player]!;\n\t\ttimers.autoTimeLossMs = Math.max(tickingTime, 0);\n\t}\n\n\t// Per-player disconnect timers\n\tfor (const row of playerRows) {\n\t\tconst player = row.player_number as Player;\n\n\t\tif (row.disconnect_resign_time !== null) {\n\t\t\t// Case 1: Auto-resign timer was already active\n\t\t\tconst remaining = row.disconnect_resign_time - now;\n\t\t\ttimers.disconnectTimers[player] = {\n\t\t\t\ttype: 'timer',\n\t\t\t\tremainingMs: Math.max(remaining, 0),\n\t\t\t\tbyChoice: row.disconnect_by_choice === 1,\n\t\t\t};\n\t\t} else if (row.disconnect_cushion_end_time !== null) {\n\t\t\t// Case 2: Still in the 5-second cushion period\n\t\t\tconst remaining = row.disconnect_cushion_end_time - now;\n\t\t\ttimers.disconnectTimers[player] = {\n\t\t\t\ttype: 'cushion',\n\t\t\t\tremainingMs: Math.max(remaining, 0),\n\t\t\t\tbyChoice: row.disconnect_by_choice === 1,\n\t\t\t};\n\t\t} else {\n\t\t\t// Case 3: Was connected before restart. Give them a fresh disconnect timer\n\t\t\t// (not by choice, since the server restart caused the disconnection).\n\t\t\ttimers.disconnectTimers[player] = {\n\t\t\t\ttype: 'fresh',\n\t\t\t\tremainingMs: -1, // Signal that a fresh timer should be started\n\t\t\t\tbyChoice: false,\n\t\t\t};\n\t\t}\n\t}\n\n\treturn timers;\n}\n\n// Exports --------------------------------------------------------------------------------------------\n\nexport { restoreAllLiveGames };\n"
  },
  {
    "path": "src/server/game/gamemanager/liveGameValues.ts",
    "content": "// src/server/game/gamemanager/liveGameValues.ts\n\n/**\n * This script keeps the live-state of the active games in the database up to date.\n * It computes the column values to be persisted for each state-change event,\n * then updates the live_games and live_player_games tables accordingly.\n *\n * See dev-utils/live-game-persistence.md for the schema and event matrix.\n */\n\nimport type { Player } from '../../../shared/chess/util/typeutil.js';\nimport type { LiveGameData, LiveGamesRecord } from '../../database/liveGamesManager.js';\nimport type { ServerGame, PlayerData, PlayerDisconnect } from './gameutility.js';\nimport type {\n\tLivePlayerDisconnectData,\n\tLivePlayerGamesRecord,\n} from '../../database/livePlayerGamesManager.js';\n\nimport { Game } from '../../../shared/chess/logic/gamefile.js';\nimport icnconverter from '../../../shared/chess/logic/icn/icnconverter.js';\nimport { players as p } from '../../../shared/chess/util/typeutil.js';\n\nimport { timeBeforeGameDeletionMillis } from './gameutility.js';\nimport { insertLiveGame, updateLiveGame, deleteLiveGame } from '../../database/liveGamesManager.js';\nimport {\n\tinsertLivePlayerGame,\n\tupdateLivePlayerGame,\n} from '../../database/livePlayerGamesManager.js';\n\n// Value Computation ----------------------------------------------------------------------------------\n\n/**\n * Computes the moves string from a ServerGame's move list, including embedded clock stamps.\n * Uses the ICN compact format: `1,2>3,4{[%clk 0:09:56.7]}|5,6>7,8=Q{[%clk 0:09:45.2]}`\n */\nfunction getMovesString(servergame: ServerGame): string {\n\tconst { basegame } = servergame;\n\tif (basegame.moves.length === 0) return '';\n\n\treturn icnconverter.getShortFormMovesFromMoves(basegame.moves, {\n\t\tcompact: true,\n\t\tspaces: false,\n\t\tcomments: !basegame.untimed,\n\t\tmove_numbers: false,\n\t});\n}\n\n/**\n * Extracts the elo display string for a player from game metadata.\n */\nfunction getPlayerEloString(basegame: Game, player: Player): string | null {\n\t// The elo is stored in metadata as WhiteElo/BlackElo strings like \"1500\" or \"1200?\"\n\t// prettier-ignore\n\tconst eloKey = player === p.WHITE ? 'WhiteElo' :\n\t\t\t\t   player === p.BLACK ? 'BlackElo' :\n\t\t\t\t   (() => { throw new Error(`Invalid player ${player} when getting elo string`); })();\n\treturn basegame.metadata[eloKey] ?? null;\n}\n\n/**\n * Returns the disconnect-related live_player_games columns for a player's current disconnect state.\n */\nfunction getDisconnectColumnData(disconnect: PlayerDisconnect): LivePlayerDisconnectData {\n\treturn {\n\t\tdisconnect_cushion_end_time: disconnect.startTime ?? null,\n\t\tdisconnect_resign_time: disconnect.timeToAutoLoss ?? null,\n\t\tdisconnect_by_choice:\n\t\t\tdisconnect.wasByChoice !== undefined ? (disconnect.wasByChoice ? 1 : 0) : null,\n\t};\n}\n\n/**\n * Updates time_remaining_ms for all players from the current clock state.\n * No-op for untimed games.\n */\nfunction persistCurrentClockTimes(servergame: ServerGame): void {\n\tconst { basegame, match } = servergame;\n\tif (basegame.untimed) return;\n\tfor (const playerStr of Object.keys(match.playerData)) {\n\t\tconst player = Number(playerStr) as Player;\n\t\tupdateLivePlayerGame(match.id, player, {\n\t\t\ttime_remaining_ms: basegame.clocks.currentTime[player] ?? null,\n\t\t});\n\t}\n}\n\n/**\n * Builds a LivePlayerGamesRecord from player data.\n */\nfunction buildPlayerRecord(\n\tgame_id: number,\n\tplayer: Player,\n\tplayerData: PlayerData,\n\tbasegame: Game,\n): LivePlayerGamesRecord {\n\tconst { identifier, disconnect } = playerData;\n\n\treturn {\n\t\tgame_id,\n\t\tplayer_number: player,\n\t\tuser_id: identifier.signedIn ? identifier.user_id : null,\n\t\tbrowser_id: identifier.browser_id,\n\t\telo: getPlayerEloString(basegame, player),\n\t\tlast_draw_offer_ply: playerData.lastOfferPly ?? null,\n\t\ttime_remaining_ms: basegame.untimed ? null : (basegame.clocks.currentTime[player] ?? null),\n\t\t...getDisconnectColumnData(disconnect),\n\t};\n}\n\n// Persistence Events ---------------------------------------------------------------------------------\n\n/**\n * Called when a new game is created. Inserts the full initial state into both tables.\n */\nfunction onGameCreated(servergame: ServerGame): void {\n\tconst { basegame, match } = servergame;\n\n\tconst record: LiveGamesRecord = {\n\t\tgame_id: match.id,\n\t\ttime_created: match.timeCreated,\n\t\tvariant: match.variant,\n\t\tclock: match.clock,\n\t\trated: match.rated ? 1 : 0,\n\t\tprivate: match.publicity === 'private' ? 1 : 0,\n\t\tmoves: '',\n\t\tcolor_ticking: null,\n\t\tclock_snapshot_time: null,\n\t\tdraw_offer_state: null,\n\t\tconclusion_condition: null,\n\t\tconclusion_victor: null,\n\t\ttime_ended: null,\n\t\tafk_resign_time: null,\n\t\tdelete_time: null,\n\t\tposition_pasted: 0,\n\t\tvalidate_moves: servergame.boardsim !== undefined ? 1 : 0,\n\t};\n\n\tinsertLiveGame(record);\n\n\t// Insert one row per player\n\tfor (const [playerStr, playerData] of Object.entries(match.playerData)) {\n\t\tconst player = Number(playerStr) as Player;\n\t\tconst playerRecord = buildPlayerRecord(match.id, player, playerData, basegame);\n\t\tinsertLivePlayerGame(playerRecord);\n\t}\n}\n\n/**\n * Called after a move is submitted and the game state is updated.\n * Updates the moves string, clock state, and per-player time.\n */\nfunction onMoveSubmitted(servergame: ServerGame): void {\n\tconst { basegame, match } = servergame;\n\n\tconst gameUpdates: Partial<LiveGameData> = {\n\t\tmoves: getMovesString(servergame),\n\t};\n\n\tif (!basegame.untimed) {\n\t\tgameUpdates.color_ticking = basegame.clocks.colorTicking ?? null;\n\t\tgameUpdates.clock_snapshot_time = basegame.clocks.timeAtTurnStart ?? null;\n\t}\n\n\tupdateLiveGame(match.id, gameUpdates);\n\n\tpersistCurrentClockTimes(servergame);\n}\n\n/**\n * Called when a game conclusion is set (checkmate, resignation, time loss, etc.).\n * Updates conclusion columns and sets the delete timer target.\n */\nfunction onGameConcluded(servergame: ServerGame): void {\n\tconst { basegame, match } = servergame;\n\tconst conclusion = basegame.gameConclusion!;\n\n\tconst gameUpdates: Partial<LiveGameData> = {\n\t\tconclusion_condition: conclusion.condition,\n\t\tconclusion_victor: conclusion.victor ?? null,\n\t\ttime_ended: match.timeEnded!,\n\t\tdelete_time: match.timeEnded! + timeBeforeGameDeletionMillis,\n\t\tdraw_offer_state: null, // Draw offers are closed on conclusion\n\t\tafk_resign_time: null, // AFK timers are cancelled on conclusion\n\t};\n\n\t// Stop clock state\n\tif (!basegame.untimed) {\n\t\t// Both color ticking and timeAtTurnStart are set to null on game end\n\t\tgameUpdates.color_ticking = null;\n\t\tgameUpdates.clock_snapshot_time = null;\n\t}\n\n\tupdateLiveGame(match.id, gameUpdates);\n\n\t// Update time_remaining_ms for timed games (e.g., time loss sets loser to 0)\n\tpersistCurrentClockTimes(servergame);\n}\n\n/**\n * Called when a draw offer is extended.\n */\nfunction onDrawOfferExtended(servergame: ServerGame, offeringColor: Player): void {\n\tupdateLiveGame(servergame.match.id, {\n\t\tdraw_offer_state: offeringColor,\n\t});\n\n\tupdateLivePlayerGame(servergame.match.id, offeringColor, {\n\t\tlast_draw_offer_ply: servergame.match.playerData[offeringColor]!.lastOfferPly ?? null,\n\t});\n}\n\n/**\n * Called when a draw offer is declined (or auto-declined on move).\n */\nfunction onDrawOfferDeclined(servergame: ServerGame): void {\n\tupdateLiveGame(servergame.match.id, {\n\t\tdraw_offer_state: null,\n\t});\n}\n\n/**\n * Called when a player disconnects (either by choice or network interruption).\n * Persists the disconnect state for that player.\n */\nfunction onPlayerDisconnected(servergame: ServerGame, color: Player): void {\n\tconst playerDisconnectData = servergame.match.playerData[color]!.disconnect;\n\tupdateLivePlayerGame(servergame.match.id, color, getDisconnectColumnData(playerDisconnectData));\n}\n\n/**\n * Called when a player reconnects. Clears their disconnect state.\n */\nfunction onPlayerReconnected(servergame: ServerGame, color: Player): void {\n\tupdateLivePlayerGame(servergame.match.id, color, {\n\t\tdisconnect_cushion_end_time: null,\n\t\tdisconnect_resign_time: null,\n\t\tdisconnect_by_choice: null,\n\t});\n}\n\n/**\n * Called when a player goes AFK. Persists the AFK resign timestamp.\n */\nfunction onPlayerAFK(servergame: ServerGame): void {\n\tupdateLiveGame(servergame.match.id, {\n\t\tafk_resign_time: servergame.match.autoAFKResignTime ?? null,\n\t});\n}\n\n/**\n * Called when a player returns from AFK. Clears the AFK resign timestamp.\n */\nfunction onPlayerAFKReturn(servergame: ServerGame): void {\n\tupdateLiveGame(servergame.match.id, {\n\t\tafk_resign_time: null,\n\t});\n}\n\n/**\n * Called when a position is pasted. Sets position_pasted and clears validate_moves.\n */\nfunction onPositionPasted(servergame: ServerGame): void {\n\tupdateLiveGame(servergame.match.id, {\n\t\tposition_pasted: 1,\n\t\tvalidate_moves: 0,\n\t});\n}\n\n/**\n * Called when a game is fully deleted/logged. Removes the live game from the database.\n */\nfunction onGameDeleted(game_id: number): void {\n\tdeleteLiveGame(game_id);\n}\n\n// Exports --------------------------------------------------------------------------------------------\n\nexport default {\n\t// Persistence Events\n\tonGameCreated,\n\tonMoveSubmitted,\n\tonGameConcluded,\n\tonDrawOfferExtended,\n\tonDrawOfferDeclined,\n\tonPlayerDisconnected,\n\tonPlayerReconnected,\n\tonPlayerAFK,\n\tonPlayerAFKReturn,\n\tonPositionPasted,\n\tonGameDeleted,\n};\n"
  },
  {
    "path": "src/server/game/gamemanager/movesubmission.ts",
    "content": "// src/server/game/gamemanager/movesubmission.ts\n\n/**\n * The script handles when a user submits a move in\n * the game they are in, and does basic checks to make sure it's valid.\n */\n\nimport type { Player } from '../../../shared/chess/util/typeutil.js';\nimport type { FullGame } from '../../../shared/chess/logic/gamefile.js';\nimport type { MoveRecord } from '../../../shared/chess/logic/movepiece.js';\nimport type { MoveParsed } from '../../../shared/chess/logic/icn/icnconverter.js';\nimport type { GameConclusion } from '../../../shared/chess/util/winconutil.js';\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\n\nimport * as z from 'zod';\n\nimport bimath from '../../../shared/util/math/bimath.js';\nimport typeutil from '../../../shared/chess/util/typeutil.js';\nimport movepiece from '../../../shared/chess/logic/movepiece.js';\nimport winconutil from '../../../shared/chess/util/winconutil.js';\nimport icnconverter from '../../../shared/chess/logic/icn/icnconverter.js';\nimport movevalidation from '../../../shared/chess/logic/movevalidation.js';\nimport gamefileutility from '../../../shared/chess/util/gamefileutility.js';\n\nimport liveGameValues from './liveGameValues.js';\nimport { declineDraw } from './onOfferDraw.js';\nimport { resyncToGame } from './resync.js';\nimport { logEventsAndPrint } from '../../middleware/logEvents.js';\nimport { sendSocketMessage } from '../../socket/sendSocketMessage.js';\nimport gameutility, { ServerGame } from './gameutility.js';\nimport { pushGameClock, finalizeConclusion, teardownGame } from './gamemanager.js';\n\n/** The zod schema for validating the contents of the submitmove message. */\nconst submitmoveschem = z.strictObject({\n\tmove: z.string(),\n\tmoveNumber: z.int(),\n\tgameConclusion: winconutil.gameConclusionSchema.optional(),\n});\n\ntype SubmitMoveMessage = z.infer<typeof submitmoveschem>;\n\n/** The number of additional coordinate digits allowed per second of game duration. */\nconst DIGITS_PER_SECOND = 4.5;\n\n/**\n *\n * Call when a websocket submits a move. Performs some checks,\n * adds the move to the game's move list, adjusts the game's\n * properties, and alerts their opponent of the move.\n * @param ws - The websocket submitting the move.\n * @param servergame - The game they are in.\n * @param messageContents - An object containing the properties `move`, `moveNumber`, and `gameConclusion`.\n */\nfunction submitMove(\n\tws: CustomWebSocket,\n\tservergame: ServerGame,\n\tmessageContents: SubmitMoveMessage,\n): void {\n\t// They can't submit a move if they aren't subscribed to a game\n\tif (!ws.metadata.subscriptions.game) {\n\t\tconsole.error(\n\t\t\t'Player tried to submit a move when not subscribed. They should only send move when they are in sync, not right after the socket opens.',\n\t\t);\n\t\tsendSocketMessage(\n\t\t\tws,\n\t\t\t'general',\n\t\t\t'printerror',\n\t\t\t'Failed to submit move. You are not subscribed to a game.',\n\t\t);\n\t\treturn;\n\t}\n\n\t// Their subscription info should tell us what game they're in, including the color they are.\n\tconst color = ws.metadata.subscriptions.game.color;\n\tconst opponentColor = typeutil.invertPlayer(color);\n\n\t// If the game is already over, don't accept it.\n\tif (gameutility.isGameOver(servergame.basegame)) return;\n\n\t// Make sure it's their turn\n\tif (servergame.basegame.whosTurn !== color) {\n\t\t// Can occasionally happen if they in rapid succession send a resync request and\n\t\t// a move submission, then when their client resyncs they submit their move again.\n\t\t// Just discard this submission and resync just in case they are actually out of sync.\n\t\tresyncToGame(ws, servergame.match.id);\n\t\treturn;\n\t}\n\n\t// Make sure the move number matches up. If not, they're out of sync, resync them!\n\tconst expectedMoveNumber = servergame.basegame.moves.length + 1;\n\tif (messageContents.moveNumber !== expectedMoveNumber) {\n\t\tconst errString = `Client submitted a move with incorrect move number! Expected: ${expectedMoveNumber}   Message: ${JSON.stringify(messageContents)}. User: ${JSON.stringify(ws.metadata.memberInfo)}`;\n\t\tlogEventsAndPrint(errString, 'hackLog.txt');\n\t\tresyncToGame(ws, servergame.match.id);\n\t\treturn;\n\t}\n\n\t// Verify the move is in the correct format\n\tconst moveParsed = doesMoveCheckOut(messageContents.move);\n\tif (moveParsed === false) {\n\t\tconst errString = `Player sent a move in an invalid format. The message: ${JSON.stringify(messageContents)}. User: ${JSON.stringify(ws.metadata.memberInfo)}`;\n\t\tlogEventsAndPrint(errString, 'hackLog.txt');\n\t\tsendSocketMessage(ws, 'general', 'printerror', 'Invalid move format.');\n\t\treturn;\n\t}\n\n\t// Check if the move exceeds the soft distance cap based on game duration\n\tif (!isMoveWithinDistanceCap(moveParsed, servergame.match.timeCreated)) {\n\t\tconst errString = `Player sent a move that exceeds the distance cap for game duration. The message: ${JSON.stringify(messageContents)}. User: ${JSON.stringify(ws.metadata.memberInfo)}`;\n\t\tlogEventsAndPrint(errString, 'hackLog.txt');\n\t\tsendSocketMessage(\n\t\t\tws,\n\t\t\t'general',\n\t\t\t'notifyerror',\n\t\t\t'Move not accepted. Distance exceeds allowed limit for game duration.',\n\t\t);\n\t\treturn;\n\t}\n\n\t// Use server-side validation if the boardsim exists, otherwise trust the client's reported conclusion.\n\tconst moveRecord =\n\t\tservergame.boardsim !== undefined\n\t\t\t? applyServerValidatedMove(ws, servergame, messageContents, moveParsed, color)\n\t\t\t: applyClientReportedMove(ws, servergame, messageContents, moveParsed, color);\n\tif (moveRecord === undefined) return; // The move was illegal, or the conclusion was invalid, and we've already sent the appropriate error message to the client, so just exit.\n\n\t// console.log(`Accepted a move! Their websocket message data:`)\n\t// console.log(messageContents)\n\t// console.log(\"New move list:\")\n\t// console.log(game.moves);\n\n\tdeclineDraw(ws, servergame); // Auto-decline any open draw offer on move submissions\n\n\t// Persist the move and updated game state to the database.\n\tliveGameValues.onMoveSubmitted(servergame);\n\n\tconst gameIsOver = gameutility.isGameOver(servergame.basegame);\n\n\tif (gameIsOver) {\n\t\t// If the game ended, finalize state before sending: stops the clock and persists to DB.\n\t\t// This ensures both clients receive the same frozen clock values that are in the DB.\n\t\tfinalizeConclusion(servergame, servergame.basegame.gameConclusion);\n\t\t// Send a whole gameupdate to the move-submitter\n\t\tgameutility.sendGameUpdateToColor(servergame, color, false);\n\t} else {\n\t\t// Just send updated clocks to the move-submitter\n\t\tgameutility.sendUpdatedClockToColor(servergame, color);\n\t}\n\t// Send their move to their opponent.\n\tgameutility.sendMoveToColor(servergame, opponentColor, moveRecord);\n\n\t// Tear down the game after sends. teardownGame skips broadcastGameUpdate for\n\t// move-triggered conclusions since clients were already notified individually above.\n\tif (gameIsOver) teardownGame(servergame);\n}\n\n/**\n * Validates the move against the server-side board simulation, makes it, and updates the game state.\n * Returns the resulting MoveRecord, or undefined if the move was illegal (error messages are sent to the client).\n */\nfunction applyServerValidatedMove(\n\tws: CustomWebSocket,\n\tservergame: ServerGame,\n\tmessageContents: SubmitMoveMessage,\n\tmoveParsed: MoveParsed,\n\tcolor: Player,\n): MoveRecord | undefined {\n\t// Makes ts happy knowing boardsim is already defined\n\tconst gamefile: FullGame = { basegame: servergame.basegame, boardsim: servergame.boardsim! };\n\n\tconst validationResult = movevalidation.validateMove(gamefile, moveParsed);\n\tif (!validationResult.valid) {\n\t\tconst errString = `Player sent an illegal move: \"${messageContents.move}\" Reason: ${validationResult.reason} User: ${JSON.stringify(ws.metadata.memberInfo)}`;\n\t\tlogEventsAndPrint(errString, 'hackLog.txt');\n\t\t// Send the sender a gameupdate to correct their board if a bug somehow caused this\n\t\tgameutility.sendGameUpdateToColor(servergame, color, true); // forceSync true to force their move list to match ours\n\t\t// Send notifyerror last to override any previous toasts\n\t\tsendSocketMessage(\n\t\t\tws,\n\t\t\t'general',\n\t\t\t'notifyerror',\n\t\t\t'Oops! That was an illegal move. If this is a bug, please report it!',\n\t\t);\n\t\treturn;\n\t}\n\n\t// Generate and make the move in the logical game\n\tconst fullMove = movepiece.generateAndMakeMove(gamefile, validationResult.tagged);\n\n\t// Set the clock stamp on both the boardsim's MoveFull and the basegame's MoveRecord.\n\t// (makeMove creates a separate MoveRecord object for basegame, so we must set both.)\n\tconst moveRecord = servergame.basegame.moves[servergame.basegame.moves.length - 1]!;\n\tconst clockStamp = pushGameClock(servergame);\n\tif (clockStamp !== undefined) {\n\t\tfullMove.clockStamp = clockStamp;\n\t\tmoveRecord.clockStamp = clockStamp;\n\t}\n\n\t// The server determines the game conclusion; discard any client-claimed conclusion.\n\t// Auto-sets basegame.gameConclusion if the move triggers a conclusion.\n\tgamefileutility.doGameOverChecks(gamefile);\n\n\treturn moveRecord;\n}\n\n/**\n * Accepts a move for large variants without server-side validation, and updates the game state.\n * Returns the resulting MoveRecord, or undefined if the claimed game conclusion was invalid.\n */\nfunction applyClientReportedMove(\n\tws: CustomWebSocket,\n\tservergame: ServerGame,\n\tmessageContents: SubmitMoveMessage,\n\tmoveParsed: MoveParsed,\n\tcolor: Player,\n): MoveRecord | undefined {\n\tif (!doesGameConclusionCheckOut(messageContents.gameConclusion, color)) {\n\t\tconst errString = `Player sent a conclusion that doesn't check out! Invalid. The message: \"${JSON.stringify(messageContents)}\" User: ${JSON.stringify(ws.metadata.memberInfo)}`;\n\t\tlogEventsAndPrint(errString, 'hackLog.txt');\n\t\tsendSocketMessage(ws, 'general', 'printerror', 'Invalid game conclusion.');\n\t\treturn;\n\t}\n\n\tconst moveRecord: MoveRecord = {\n\t\tstartCoords: moveParsed.startCoords,\n\t\tendCoords: moveParsed.endCoords,\n\t\ttoken: moveParsed.token,\n\t\t// clockStamp added below\n\t};\n\tif (moveParsed.promotion !== undefined) moveRecord.promotion = moveParsed.promotion;\n\t// Must be BEFORE pushing the clock, because pushGameClock() depends on the length of the moves.\n\tservergame.basegame.moves.push(moveRecord); // Add the move to the list!\n\t// Must be AFTER pushing the move, because pushGameClock() depends on the length of the moves.\n\tconst clockStamp = pushGameClock(servergame); // Flip whos turn and adjust the game properties\n\tif (clockStamp !== undefined) moveRecord.clockStamp = clockStamp; // If the clock stamp was set, add it to the move.\n\n\t// Manually set basegame.gameConclusion to client-reported conclusion\n\tgamefileutility.setConclusion(servergame.basegame, messageContents.gameConclusion);\n\n\treturn moveRecord;\n}\n\n/**\n * Calculates the maximum distance a move should be allowed based on game duration.\n * @param gameStartTime - When the game was created (in milliseconds)\n * @returns Maximum allowed coordinate digits\n */\nfunction getMaxAllowedCoordinateDigits(gameStartTime: number): number {\n\tconst currentTime = Date.now();\n\tconst gameElapsedSeconds = (currentTime - gameStartTime) / 1000;\n\n\t// Start with a baseline of 1 digit (allows coordinates like -9 to 9)\n\tconst baselineDigits = 1;\n\tconst extraDigits = gameElapsedSeconds * DIGITS_PER_SECOND;\n\n\treturn Math.floor(baselineDigits + extraDigits);\n}\n\n/**\n * Checks if a move's coordinates exceed the soft distance cap based on game duration.\n * Only checks end coordinates since start coordinates are known to be safe.\n * @param moveParsed - The parsed move to check\n * @param gameStartTime - When the game was created (in milliseconds)\n * @returns true if the move is within allowed distance, false otherwise\n */\nfunction isMoveWithinDistanceCap(moveParsed: MoveParsed, gameStartTime: number): boolean {\n\tconst maxAllowedDigits = getMaxAllowedCoordinateDigits(gameStartTime);\n\n\t// Only check end coordinates since start coordinates are safe\n\tconst endXDigits = bimath.countDigits(moveParsed.endCoords[0]);\n\tconst endYDigits = bimath.countDigits(moveParsed.endCoords[1]);\n\n\tconst maxDigitsInMove = Math.max(endXDigits, endYDigits);\n\n\treturn maxDigitsInMove <= maxAllowedDigits;\n}\n\n/**\n * Returns true if their submitted move is in the format `x,y>x,y=3N`.\n * @param move - Their move submission.\n * @returns The move, if correctly formatted, otherwise false.\n */\nfunction doesMoveCheckOut(move: string): MoveParsed | false {\n\t// Is the move in the correct format? \"x,y>x,y=N\"\n\tlet moveParsed: MoveParsed;\n\ttry {\n\t\tmoveParsed = icnconverter.parseTokenMove(move);\n\t} catch {\n\t\t// It either didn't pass the regex, or the promoted piece abbreviation was invalid.\n\t\treturn false;\n\t}\n\n\treturn moveParsed;\n}\n\n/**\n * Returns true if the provided game conclusion seems reasonable for their move submission.\n * An example of a not reasonable one would be if they claimed they won by their opponent resigning.\n * This does not run the checkmate algorithm, so it's not foolproof.\n * @param gameConclusion - Their claimed game conclusion.\n * @param color - The color they are in the game.\n * @returns *true* if their claimed conclusion seems reasonable.\n */\nfunction doesGameConclusionCheckOut(\n\tgameConclusion: GameConclusion | undefined,\n\tcolor: Player,\n): boolean {\n\tif (gameConclusion === undefined) return true;\n\n\tconst { victor, condition } = gameConclusion;\n\tif (!winconutil.isConclusionMoveTriggered(condition)) return false;\n\t// We can't submit a move where our opponent wins\n\tconst oppositeColor = typeutil.invertPlayer(color);\n\treturn victor !== oppositeColor;\n}\n\nexport { submitMove, submitmoveschem };\n"
  },
  {
    "path": "src/server/game/gamemanager/onAFK.ts",
    "content": "// src/server/game/gamemanager/onAFK.ts\n\n/**\n * The script handles the route when users inform us they have gone AFK or returned from being AFK.\n */\n\nimport type { ServerGame } from './gameutility.js';\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\n\nimport typeutil from '../../../shared/chess/util/typeutil.js';\n\nimport gameutility from './gameutility.js';\nimport liveGameValues from './liveGameValues.js';\nimport { cancelAutoAFKResignTimer } from './afkdisconnect.js';\nimport { onPlayerLostByAbandonment } from './gamemanager.js';\n\n//--------------------------------------------------------------------------------------------------------\n\n/**\n * The length of the timer to auto resign somebody by being AFK/disconnected for too long.\n * This cannot change because the client is hard coded to play a low-time sound on timer start,\n * and a unique 10 second countdown at 10 seconds remaining.\n * Plus, they are the ones who tell us when they are AFK. This does not include the by default\n * 40-second pretimer they are allowed to be AFK before this 20s timer starts.\n */\nconst durationOfAutoResignTimerMillis = 1000 * 20; // 20 seconds.\n\n/**\n * Called when a client alerts us they have gone AFK.\n * Alerts their opponent, and starts a timer to auto-resign.\n * @param ws - The socket\n * @param servergame - The game they are in.\n */\nfunction onAFK(ws: CustomWebSocket, servergame: ServerGame): void {\n\tconst { match, basegame } = servergame;\n\n\t// console.log(\"Client alerted us they are AFK.\")\n\tconst color = gameutility.doesSocketBelongToGame_ReturnColor(match, ws)!;\n\n\tif (gameutility.isGameOver(basegame))\n\t\treturn console.error(\n\t\t\t'Client submitted they are afk when the game is already over. Ignoring.',\n\t\t);\n\n\t// Verify it's their turn (can't lose by afk if not)\n\tif (basegame.whosTurn !== color)\n\t\treturn console.error(\"Client submitted they are afk when it's not their turn. Ignoring.\");\n\n\tif (!basegame.untimed && gameutility.isGameResignable(basegame))\n\t\treturn console.error(\n\t\t\t'Client submitted they are afk in a timed, resignable game. There is no afk auto-resign timers in timed games anymore.',\n\t\t);\n\n\tif (\n\t\tmatch.playerData[color]!.disconnect.startID !== undefined ||\n\t\tmatch.playerData[color]!.disconnect.timeToAutoLoss !== undefined\n\t) {\n\t\treturn console.error(\n\t\t\t\"Player's disconnect timer should have been cancelled before starting their afk timer!\",\n\t\t);\n\t}\n\n\tconst opponentColor = typeutil.invertPlayer(color);\n\n\t// Start a 20s timer to auto terminate the game by abandonment.\n\tmatch.autoAFKResignTimeoutID = setTimeout(\n\t\t() => onPlayerLostByAbandonment(servergame, opponentColor),\n\t\tdurationOfAutoResignTimerMillis,\n\t); // The auto resign function should have 2 arguments: The game, and the color that won.\n\tmatch.autoAFKResignTime = Date.now() + durationOfAutoResignTimerMillis;\n\n\t// Persist the AFK state to the database.\n\tliveGameValues.onPlayerAFK(servergame);\n\n\t// Alert their opponent\n\tconst value = { millisUntilAutoAFKResign: durationOfAutoResignTimerMillis };\n\tgameutility.sendMessageToSocketOfColor(match, opponentColor, 'game', 'opponentafk', value);\n}\n\n/**\n * Called when a client alerts us they have returned from being AFK.\n * Alerts their opponent, and cancels the timer to auto-resign.\n * @param ws - The socket\n * @param servergame - The game they are in.\n */\nfunction onAFK_Return(ws: CustomWebSocket, servergame: ServerGame): void {\n\t// console.log(\"Client alerted us they no longer AFK.\")\n\tconst color = gameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws);\n\n\tif (gameutility.isGameOver(servergame.basegame))\n\t\treturn console.error(\n\t\t\t'Client submitted they are back from being afk when the game is already over. Ignoring.',\n\t\t);\n\n\t// Verify it's their turn (can't lose by afk if not)\n\tif (servergame.basegame.whosTurn !== color)\n\t\treturn console.error(\n\t\t\t\"Client submitted they are back from being afk when it's not their turn. Ignoring.\",\n\t\t);\n\n\tif (!servergame.basegame.untimed && gameutility.isGameResignable(servergame.basegame))\n\t\treturn console.error(\n\t\t\t'Client submitted they are back from being afk in a timed, resignable game. There is no afk auto-resign timers in timed games anymore.',\n\t\t);\n\n\tcancelAutoAFKResignTimer(servergame, true);\n\tliveGameValues.onPlayerAFKReturn(servergame);\n}\n\nexport { onAFK, onAFK_Return };\n"
  },
  {
    "path": "src/server/game/gamemanager/onOfferDraw.ts",
    "content": "// src/server/game/gamemanager/onOfferDraw.ts\n\n/**\n * This script contains the routes for extending, accepting, and rejecting\n * draw offers in online games.\n */\n\nimport type { ServerGame } from './gameutility.js';\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\n\nimport typeutil from '../../../shared/chess/util/typeutil.js';\n\nimport gameutility from './gameutility.js';\nimport liveGameValues from './liveGameValues.js';\nimport { setGameConclusion } from './gamemanager.js';\nimport {\n\tisDrawOfferOpen,\n\thasColorOfferedDrawTooFast,\n\topenDrawOffer,\n\tdoesColorHaveExtendedDrawOffer,\n\tcloseDrawOffer,\n} from './drawoffers.js';\n\n//--------------------------------------------------------------------------------------------------------\n\n/**\n * Called when client wants to offer a draw. Sends confirmation to opponent.\n * @param ws - The socket\n * @param servergame - The game they are in.\n */\nfunction offerDraw(ws: CustomWebSocket, servergame: ServerGame): void {\n\t// console.log('Client offers a draw.');\n\tconst { match, basegame } = servergame;\n\tconst color = gameutility.doesSocketBelongToGame_ReturnColor(match, ws)!;\n\n\tif (gameutility.isGameOver(basegame))\n\t\treturn console.error('Client offered a draw when the game is already over. Ignoring.');\n\tif (isDrawOfferOpen(match))\n\t\treturn console.error(\n\t\t\t`${color} tried to offer a draw when the game already has a draw offer!`,\n\t\t);\n\tif (hasColorOfferedDrawTooFast(servergame, color))\n\t\treturn console.error('Client tried to offer a draw too fast.');\n\tif (!gameutility.isGameResignable(basegame))\n\t\treturn console.error('Client tried to offer a draw on the first 2 moves');\n\n\t// Extend the draw offer!\n\n\topenDrawOffer(servergame, color);\n\tliveGameValues.onDrawOfferExtended(servergame, color);\n\n\t// Alert their opponent\n\tconst opponentColor = typeutil.invertPlayer(color);\n\tgameutility.sendMessageToSocketOfColor(match, opponentColor, 'game', 'drawoffer');\n}\n\n/**\n * Called when client accepts a draw. Ends the game.\n * @param ws - The socket\n * @param servergame - The game they are in.\n */\nfunction acceptDraw(ws: CustomWebSocket, servergame: ServerGame): void {\n\t// console.log('Client accepts a draw.');\n\tconst color = gameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws)!;\n\n\tif (gameutility.isGameOver(servergame.basegame))\n\t\treturn console.error('Client accepted a draw when the game is already over. Ignoring.');\n\tif (!isDrawOfferOpen(servergame.match))\n\t\treturn console.error(\"Client tried to accept a draw offer when there isn't one.\");\n\tif (doesColorHaveExtendedDrawOffer(servergame.match, color))\n\t\treturn console.error('Client tried to accept their own draw offer, silly!');\n\n\t// Accept draw offer!\n\n\tcloseDrawOffer(servergame.match);\n\tsetGameConclusion(servergame, { victor: null, condition: 'agreement' });\n}\n\n/**\n * Called when client declines a draw. Alerts opponent.\n * @param ws - The socket\n * @param servergame - The game they are in.\n */\nfunction declineDraw(ws: CustomWebSocket, servergame: ServerGame): void {\n\tconst color = gameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws)!;\n\tconst opponentColor = typeutil.invertPlayer(color);\n\n\t// Since this method is run every time a move is submitted, we have to early exit\n\t// if their opponent doesn't have an open draw offer.\n\tif (!doesColorHaveExtendedDrawOffer(servergame.match, opponentColor)) return;\n\n\t// console.log('Client declines a draw.');\n\n\tif (gameutility.isGameOver(servergame.basegame))\n\t\treturn console.error('Client declined a draw when the game is already over. Ignoring.');\n\n\t// Decline the draw!\n\n\tcloseDrawOffer(servergame.match);\n\n\t// Alert their opponent\n\tgameutility.sendMessageToSocketOfColor(servergame.match, opponentColor, 'game', 'declinedraw');\n\tliveGameValues.onDrawOfferDeclined(servergame);\n}\n\n//--------------------------------------------------------------------------------------------------------\n\nexport { offerDraw, acceptDraw, declineDraw };\n"
  },
  {
    "path": "src/server/game/gamemanager/pastereport.ts",
    "content": "// src/server/game/gamemanager/pastereport.ts\n\n/**\n * This script flags private games that have a custom position pasted.\n */\n\nimport type { ServerGame } from './gameutility.js';\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\n\nimport gameutility from './gameutility.js';\nimport liveGameValues from './liveGameValues.js';\nimport { logEventsAndPrint } from '../../middleware/logEvents.js';\n\n/**\n * Called when a player submits a websocket message informing us they\n * pasted a game in a private match.\n *\n * We don't want to log custom games when they're finished,\n * because we don't know their starting position, the game\n * would crash if we attempted to paste it.\n * @param ws - The socket\n * @param servergame - The game they belong in, if they belong in one.\n */\nfunction onPaste(ws: CustomWebSocket, servergame: ServerGame): void {\n\t// { reason, opponentsMoveNumber }\n\tconsole.log('Client pasted a game.');\n\n\tconst ourColor =\n\t\tws.metadata.subscriptions.game?.color ||\n\t\tgameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws);\n\n\tif (servergame.match.publicity !== 'private') {\n\t\tconst errString = `Player reported pasting in a non-private game. Reporter color: ${ourColor}. Number of moves played: ${servergame.basegame.moves.length}.\\nThe game: ${gameutility.getSimplifiedGameString(servergame)}`;\n\t\tlogEventsAndPrint(errString, 'errLog.txt');\n\t\treturn;\n\t}\n\n\tif (servergame.match.rated) {\n\t\tconst errString = `Player reported pasting in a rated game. Reporter color: ${ourColor}. Number of moves played: ${servergame.basegame.moves.length}.\\nThe game: ${gameutility.getSimplifiedGameString(servergame)}`;\n\t\tlogEventsAndPrint(errString, 'errLog.txt');\n\t\treturn;\n\t}\n\n\t// Flag the game to not be logged\n\tservergame.match.positionPasted = true;\n\n\t// Also delete boardsim, disabling server-side move validation.\n\tdelete servergame.boardsim;\n\n\t// Persist the paste state to the database.\n\tliveGameValues.onPositionPasted(servergame);\n}\n\nexport { onPaste };\n"
  },
  {
    "path": "src/server/game/gamemanager/ratingabuse.ts",
    "content": "// src/server/game/gamemanager/ratingabuse.ts\n\n/**\n * This script can weight a user's level of suspiciousness for rating abuse,\n * in attempt to boost their own elo.\n *\n * This can include repeatedly losing on purpose on an alt account,\n * or playing illegal moves to abort games to avoid losing elo.\n *\n * Naviary is notified by email of any flagged users.\n */\n\nimport type { ServerGame } from './gameutility.js';\nimport type { GamesRecord } from '../../database/gamesManager.js';\nimport type { PlayerGamesRecord } from '../../database/playerGamesManager.js';\nimport type { RefreshTokenRecord } from '../../database/refreshTokenManager.js';\n\nimport timeutil from '../../../shared/util/timeutil.js';\nimport { VariantLeaderboards } from '../../../shared/chess/variants/validleaderboard.js';\n\nimport gameutility from './gameutility.js';\nimport { getMultipleGameData } from '../../database/gamesManager.js';\nimport { sendRatingAbuseEmail } from '../../controllers/emailController.js';\nimport { findRefreshTokensForUsers } from '../../database/refreshTokenManager.js';\nimport { logEvents, logEventsAndPrint } from '../../middleware/logEvents.js';\nimport { getMultipleMemberDataByCriteria } from '../../database/memberManager.js';\nimport {\n\tgetRecentNRatedGamesForUser,\n\tgetOpponentsOfUserFromGames,\n} from '../../database/playerGamesManager.js';\nimport {\n\taddEntryToRatingAbuseTable,\n\tisEntryInRatingAbuseTable,\n\tgetRatingAbuseData,\n\tupdateRatingAbuseColumns,\n} from '../../database/ratingAbuseManager.js';\n\n/**\n * Potential red flags (already implemented checks are marked with an X at the start of the line):\n *\n * (X) Low move counts (games ended quickly)\n * (X) Low game time durations with a high number of close together games, or high clock values at end (indicates no thinking)\n * (X) Opponents use the same IP address. OR The player has no active refresh tokens (logged out mid-game)\n * (X) Many games against always the same opponents\n * (X) Opponent accounts brand new\n *\n * Win streaks, especially against the same opponents\n * Rapid improvement over days/weeks that should take months, especially if account new\n * Low total rated loss count\n * Opponents have low total casual matches, and low total rated wins\n * Excessive resignation terminations\n * Cheat reports against them\n */\n\n// Constants -----------------------------------------------------------------------------\n\n/** How many games played to measure a player's rating abuse probability at once. */\nconst GAME_INTERVAL_TO_MEASURE = 5;\n\n/** Total suspicion score which is enough to mark a user as suspicious. */\nconst SUSPICION_TOTAL_WEIGHT_THRESHHOLD = 1.0;\n\n/** Buffer time for sending the next email. If a user is found suspicious several times in that interval, no email is sent. */\nconst SUSPICIOUS_USER_NOTIFICATION_BUFFER_MILLIS = 1000 * 60 * 60 * 24; // 24 hours\n\n/**\n * Two rated games started this close after each other have a nonzero suspicion score.\n *\n * Slightly higher than {@link SUSPICIOUS_TIME_DURATION_MILLIS} to account for time to accept a new invite.\n */\nconst TOO_CLOSE_GAMES_MILLIS = 1000 * 60 * 3.5; // 3.5 minutes\n\n/**\n * Games with fewer moves than this have a nonzero suspicion score.\n *\n * Average move count per game is 38 moves.\n */\nconst SUSPICIOUS_MOVE_COUNT = 25;\n\n/** Games lasting less than this time on the server have a nonzero suspicion score. */\nconst SUSPICIOUS_TIME_DURATION_MILLIS = 1000 * 60 * 3; // 3 minutes\n\n/** Opponents with a younger account age than this count as suspicious. */\nconst SUSPICIOUS_ACCOUNT_AGE_MILLIS = 1000 * 60 * 60 * 24 * 5; // 5 days\n\n// Types Definitions ---------------------------------------------------------------------\n\n/**\n * Relevant entries of a PlayerGamesRecord object,\n * which are used for the rating abuse calculation.\n */\ntype RatingAbuseRelevantPlayerGamesRecord = Pick<\n\tPlayerGamesRecord,\n\t'game_id' | 'score' | 'clock_at_end_millis' | 'elo_change_from_game'\n>;\n\n/**\n * Relevant entries of a GamesRecord object,\n * which are used for the rating abuse calculation.\n */\ntype RatingAbuseRelevantGamesRecord = Pick<\n\tGamesRecord,\n\t| 'game_id'\n\t| 'date'\n\t| 'base_time_seconds'\n\t| 'increment_seconds'\n\t| 'private'\n\t| 'termination'\n\t| 'move_count'\n\t| 'time_duration_millis'\n>;\n\n/** Object containing all relevant information about a specific game, which is used for the rating abuse calculation */\ntype RatingAbuseRelevantGameInfo = RatingAbuseRelevantPlayerGamesRecord &\n\tRatingAbuseRelevantGamesRecord;\n\n/** Relevant entries of a MemberRecord object, which are used for the rating abuse calculation */\ntype RatingAbuseRelevantMemberRecord = {\n\tusername: string;\n\tuser_id: number;\n\tjoined: string;\n};\n\n/** Object containing information about analysis of suspicion level of some characteristic */\ntype SuspicionLevelRecord = {\n\tcategory:\n\t\t| 'close_game_pairs'\n\t\t| 'move_count'\n\t\t| 'duration'\n\t\t| 'clock_at_end'\n\t\t| 'same_opponents'\n\t\t| 'ip_addresses'\n\t\t| 'opponent_account_age';\n\tweight: number;\n\tcomment?: string;\n};\n\n// Functions -----------------------------------------------------------------------------\n\n/**\n * Monitor suspicion levels for all players who played a particular game in a particular leaderboard\n */\nfunction measureRatingAbuseAfterGame(servergame: ServerGame): void {\n\t// Do not monitor suspicion levels, if game was unrated\n\tif (!servergame.match.rated) return;\n\t// Skip if the game was aborted (this also covers 0 moves),\n\t// the game will NOT have added an entry in the leaderboards table for the players!\n\tif (servergame.basegame.gameConclusion!.victor === undefined) return;\n\n\t// Do not monitor suspicion levels, if game belongs to no valid leaderboard_id\n\tconst leaderboard_id = VariantLeaderboards[servergame.match.variant];\n\tif (leaderboard_id === undefined) return;\n\n\tfor (const [playerStr, player] of Object.entries(servergame.match.playerData)) {\n\t\tif (!player.identifier.signedIn) {\n\t\t\tvoid logEventsAndPrint(\n\t\t\t\t`Unexpected: Player \"${playerStr}\" is not signed in. Game: ${gameutility.getSimplifiedGameString(servergame)}`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t\tcontinue;\n\t\t}\n\t\tconst user_id = player.identifier.user_id;\n\t\tconst username = player.identifier.username;\n\n\t\ttry {\n\t\t\tmeasurePlayerRatingAbuse(user_id, username, leaderboard_id);\n\t\t} catch (error: unknown) {\n\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\tvoid logEventsAndPrint(\n\t\t\t\t`Error running rating_abuse checks for user ID \"${user_id}\" on leaderboard ${leaderboard_id}: ${message}`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t}\n\t}\n}\n\n/**\n * Weights a specific user's probability of rating abuse on a specified leaderboard.\n * If it flags a user, it sends Naviary an email with data on them.\n */\nfunction measurePlayerRatingAbuse(user_id: number, username: string, leaderboard_id: number): void {\n\t// If player is not in rating_abuse table, add him to it\n\tif (!isEntryInRatingAbuseTable(user_id, leaderboard_id)) {\n\t\tconst result = addEntryToRatingAbuseTable(user_id, leaderboard_id);\n\t\tif (!result.success) {\n\t\t\tvoid logEventsAndPrint(\n\t\t\t\t`Failed to add user ${user_id} to rating_abuse table for leaderboard ${leaderboard_id} for reason: ${result.reason}`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t}\n\n\t// Access the player rating_abuse data\n\tconst rating_abuse_data = getRatingAbuseData(user_id, leaderboard_id, [\n\t\t'game_count_since_last_check',\n\t\t'last_alerted_at',\n\t]);\n\tif (rating_abuse_data === undefined) {\n\t\tvoid logEventsAndPrint(\n\t\t\t`Unable to read rating_abuse_data of user ${user_id} on leaderboard ${leaderboard_id} while making RatingAbuse check!`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn;\n\t}\n\t// Increment game_count_since_last_check by 1\n\tlet game_count_since_last_check = 1 + (rating_abuse_data.game_count_since_last_check || 0);\n\n\t// Early exit condition if the newly incremented game_count_since_last_check is still below the GAME_INTERVAL_TO_MEASURE threshhold\n\tif (game_count_since_last_check < GAME_INTERVAL_TO_MEASURE) {\n\t\tupdateRatingAbuseColumns(user_id, leaderboard_id, { game_count_since_last_check }); // update rating_abuse table with new value for game_count_since_last_check\n\t\treturn;\n\t}\n\n\t// Now we run the actual suspicion level check, thereby setting game_count_since_last_check to 0 from now on\n\tgame_count_since_last_check = 0;\n\tupdateRatingAbuseColumns(user_id, leaderboard_id, { game_count_since_last_check });\n\n\t// Retrieve the most recent ranked non-aborted games from the player_games table\n\tconst recentPlayerGamesEntries = getRecentNRatedGamesForUser(\n\t\tuser_id,\n\t\tleaderboard_id,\n\t\tGAME_INTERVAL_TO_MEASURE,\n\t\t['game_id', 'score', 'clock_at_end_millis', 'elo_change_from_game'],\n\t);\n\n\tconst netRatingChange = recentPlayerGamesEntries.reduce(\n\t\t(acc, g) => acc + (g.elo_change_from_game ?? 0),\n\t\t0,\n\t);\n\tconst game_id_list = recentPlayerGamesEntries.map((recent_game) => recent_game.game_id);\n\n\t// The player has lost elo the past GAME_INTERVAL_TO_MEASURE games. No cause for concern, early exit\n\tif (netRatingChange <= 0) {\n\t\tconst messageText = `Innocent: Ran suspicion check for user ${username} with user_id ${user_id} on leaderboard ${leaderboard_id}, but user net rating change ${netRatingChange} is not positive in the last ${GAME_INTERVAL_TO_MEASURE} games. Game IDs: ${JSON.stringify(game_id_list)}.`;\n\t\tvoid logEvents(messageText, 'ratingAbuseLog.txt');\n\t\treturn;\n\t}\n\n\t// Retrieve these same games also from the games table\n\tconst recentGamesEntries = getMultipleGameData(game_id_list, [\n\t\t'game_id',\n\t\t'date',\n\t\t'base_time_seconds',\n\t\t'increment_seconds',\n\t\t'private',\n\t\t'termination',\n\t\t'move_count',\n\t\t'time_duration_millis',\n\t])!;\n\tconst games_table_game_id_list = recentGamesEntries.map((recent_game) => recent_game.game_id);\n\n\t// Combine the information about the games into a single gameInfoList object\n\tconst gameInfoList: RatingAbuseRelevantGameInfo[] = [];\n\tfor (let i = 0; i < game_id_list.length; i++) {\n\t\tconst j = games_table_game_id_list.indexOf(game_id_list[i]!);\n\t\t// If the same game_id exists in both lists of retrieved database entries, add this game as a single object to gameInfoList\n\t\tif (j > -1) {\n\t\t\tgameInfoList.push({ ...recentPlayerGamesEntries[i]!, ...recentGamesEntries[j]! });\n\t\t} else {\n\t\t\tvoid logEventsAndPrint(\n\t\t\t\t`Found game_id ${game_id_list[i]!} in player_games table but not it games table, during rating abuse calculation`,\n\t\t\t\t'errLog.txt',\n\t\t\t);\n\t\t}\n\t}\n\t// console.log(gameInfoList);\n\n\t// Get a list of the user_ids of the previous opponents of the player\n\tconst opponentPlayerGamesEntries = getOpponentsOfUserFromGames(user_id, game_id_list, [\n\t\t'user_id',\n\t]);\n\tconst user_id_list = opponentPlayerGamesEntries.map((entry) => entry.user_id!);\n\tconst unique_user_id_list = [...new Set(user_id_list)];\n\n\t// Dictionary of frequencies of user_ids in user_id_list\n\tconst user_id_frequency: { [key: number]: number } = {};\n\tfor (const user_id of user_id_list) {\n\t\tuser_id_frequency[user_id] = (user_id_frequency[user_id] || 0) + 1;\n\t}\n\n\t// Get the refresh tokens of the user and all his opponents\n\tlet refreshTokenEntries: RefreshTokenRecord[];\n\ttry {\n\t\trefreshTokenEntries = findRefreshTokensForUsers([user_id, ...unique_user_id_list]);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tvoid logEventsAndPrint(\n\t\t\t`Error fetching refresh token entries for users \"${JSON.stringify([user_id, ...unique_user_id_list])}\": ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\trefreshTokenEntries = [];\n\t}\n\n\t// Extract the IP addresses of the user and his opponents from the refresh tokens\n\tconst user_ip_address_list: string[] = []; // ip_addresses of the user\n\tconst opponent_ip_address_lists: { [key: number]: string[] } = {}; // ip_addresses of his unique opponents\n\tfor (const refreshToken of refreshTokenEntries) {\n\t\tif (refreshToken.ip_address === null) continue;\n\n\t\t// If the refresh token belongs to the user, add his IP address to user_ip_address_list\n\t\tif (refreshToken.user_id === user_id) user_ip_address_list.push(refreshToken.ip_address);\n\t\t// Else, add the IP address to the opponent_ip_address_list\n\t\telse if (refreshToken.user_id in user_id_frequency) {\n\t\t\topponent_ip_address_lists[refreshToken.user_id] =\n\t\t\t\topponent_ip_address_lists[refreshToken.user_id] || []; // Initialize if undefined\n\t\t\topponent_ip_address_lists[refreshToken.user_id]!.push(refreshToken.ip_address);\n\t\t}\n\t}\n\n\t// Get relevant MemberRecords of the opponents from the members table\n\tlet opponentInfoList: RatingAbuseRelevantMemberRecord[] = [];\n\ttry {\n\t\topponentInfoList = getMultipleMemberDataByCriteria(\n\t\t\t['username', 'user_id', 'joined'],\n\t\t\t'user_id',\n\t\t\tunique_user_id_list,\n\t\t);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tvoid logEventsAndPrint(\n\t\t\t`Error fetching records for opponents during rating abuse calculation for user ${username} with user_id ${user_id}: ${message}`,\n\t\t\t'errLog.txt',\n\t\t);\n\t}\n\n\t// Handcrafted game suspicion checking ------------------------------------------\n\n\t/** An Object containg a suspicion level score for various monitored things */\n\tconst suspicion_level_record_list: SuspicionLevelRecord[] = [];\n\n\t// Run various checks and add entries to suspicion_level_record_list, if necessary\n\tcheckCloseGamePairs(gameInfoList, suspicion_level_record_list);\n\tcheckMoveCounts(gameInfoList, suspicion_level_record_list);\n\tcheckDurations(gameInfoList, suspicion_level_record_list);\n\tcheckClockAtEnd(gameInfoList, suspicion_level_record_list);\n\tcheckOpponentSameness(user_id_list, user_id_frequency, suspicion_level_record_list);\n\tcheckIPAddresses(\n\t\tuser_id_list,\n\t\tuser_id_frequency,\n\t\tuser_ip_address_list,\n\t\topponent_ip_address_lists,\n\t\tsuspicion_level_record_list,\n\t);\n\tcheckOpponentAccountAge(\n\t\tuser_id_list,\n\t\tuser_id_frequency,\n\t\topponentInfoList,\n\t\tsuspicion_level_record_list,\n\t);\n\n\t/** Sum of all suspicion weights in suspicion_level_record_list */\n\tconst suspicion_total_weight = suspicion_level_record_list\n\t\t.map((entry) => entry.weight)\n\t\t.reduce((acc, cur) => acc + cur, 0);\n\n\t// Player is suspicious and admin is notified if necessary\n\tif (suspicion_total_weight >= SUSPICION_TOTAL_WEIGHT_THRESHHOLD) {\n\t\tconst messageText = `\n>>>>>> GUILTY??? Suspicion total weight: ${suspicion_total_weight}.\nRan suspicion check for user ${username} with user_id ${user_id} on leaderboard ${leaderboard_id} with net rating change ${netRatingChange} in the last ${GAME_INTERVAL_TO_MEASURE} games, and user might be cheating!\nSuspicion level record: ${JSON.stringify(suspicion_level_record_list, undefined, 2)}.\nOpponent user_id_list: ${JSON.stringify(user_id_list)}.\nOpponentInfoList: ${JSON.stringify(opponentInfoList, undefined, 2)}.\nGame_id_list: ${JSON.stringify(game_id_list)}.\n\\nGameInfo list: ${JSON.stringify(gameInfoList, undefined, 2)}.\n\t\t`;\n\t\tconsole.log(\n\t\t\t`User ${username} is under suspicion of rating abuse (weight: ${suspicion_total_weight})! - Check ratingAbuseLog.txt for more details.`,\n\t\t);\n\t\tvoid logEvents('\\n' + messageText, 'ratingAbuseLog.txt');\n\n\t\t// If enough time has passed from the last alarm for that user, send an email about his rating abuse\n\t\tif (\n\t\t\trating_abuse_data.last_alerted_at === null ||\n\t\t\trating_abuse_data.last_alerted_at === undefined ||\n\t\t\tDate.now() - timeutil.sqliteToTimestamp(rating_abuse_data.last_alerted_at) >=\n\t\t\t\tSUSPICIOUS_USER_NOTIFICATION_BUFFER_MILLIS\n\t\t) {\n\t\t\tconst messageSubject = `Rating Abuse Warning: user ${username}, user_id ${user_id}`;\n\t\t\tvoid sendRatingAbuseEmail(messageSubject, messageText);\n\t\t\t// Update RatingAbuse table with last_alerted_at value\n\t\t\tconst last_alerted_at = timeutil.timestampToSqlite(Date.now());\n\t\t\tupdateRatingAbuseColumns(user_id, leaderboard_id, { last_alerted_at });\n\t\t}\n\t}\n\t// Player is not suspicious\n\telse {\n\t\tconst messageText =\n\t\t\t`Innocent? Suspicion total weight: ${suspicion_total_weight}. ` +\n\t\t\t`Ran suspicion check for user ${username} with user_id ${user_id} on leaderboard ${leaderboard_id} with net rating change ${netRatingChange} in the last ${GAME_INTERVAL_TO_MEASURE} games, and user seems innocent.` +\n\t\t\t`Suspicion level record: ${JSON.stringify(suspicion_level_record_list)}. ` +\n\t\t\t`Opponent user_id_list: ${JSON.stringify(user_id_list)}. ` +\n\t\t\t`OpponentInfoList: ${JSON.stringify(opponentInfoList)}. ` +\n\t\t\t`Game_id_list: ${JSON.stringify(game_id_list)}. ` +\n\t\t\t`GameInfo list: ${JSON.stringify(gameInfoList)}.`;\n\t\tvoid logEvents(messageText, 'ratingAbuseLog.txt');\n\t}\n}\n\n/**\n * Check if the game dates are too close in proximity to each other\n * If yes, append entry to suspicion_level_record_list.\n */\nfunction checkCloseGamePairs(\n\tgameInfoList: RatingAbuseRelevantGameInfo[],\n\tsuspicion_level_record_list: SuspicionLevelRecord[],\n): void {\n\tconst sorted_timestamp_list = gameInfoList\n\t\t.map((game_info) => timeutil.sqliteToTimestamp(game_info.date))\n\t\t.sort();\n\tconst timestamp_differences: number[] = [];\n\tfor (let i = 1; i < sorted_timestamp_list.length; i++) {\n\t\ttimestamp_differences.push(sorted_timestamp_list[i]! - sorted_timestamp_list[i - 1]!);\n\t}\n\tconst close_game_pairs_amount = timestamp_differences.filter(\n\t\t(diff) => diff < TOO_CLOSE_GAMES_MILLIS,\n\t).length;\n\tif (close_game_pairs_amount > 0) {\n\t\tsuspicion_level_record_list.push({\n\t\t\tcategory: 'close_game_pairs',\n\t\t\tweight: (close_game_pairs_amount / timestamp_differences.length) * 0.5, // rescale to [0, 0.5]\n\t\t\tcomment: `Amount: ${close_game_pairs_amount}`,\n\t\t});\n\t}\n}\n\n/**\n * Check if the move counts of the games in gameInfoList are too low.\n * If yes, append entry to suspicion_level_record_list.\n */\nfunction checkMoveCounts(\n\tgameInfoList: RatingAbuseRelevantGameInfo[],\n\tsuspicion_level_record_list: SuspicionLevelRecord[],\n): void {\n\tlet weight = 0;\n\tlet comment = '';\n\tfor (const gameInfo of gameInfoList) {\n\t\tif (!gameInfo.elo_change_from_game || gameInfo.elo_change_from_game < 0) continue; // Game is not suspicious if player lost elo from it\n\n\t\t// Game is suspicious if it contains too few moves\n\t\tif (gameInfo.move_count <= SUSPICIOUS_MOVE_COUNT) {\n\t\t\tconst fraction = Math.max(0, (gameInfo.move_count - 2) / (SUSPICIOUS_MOVE_COUNT - 2)); // fraction is in the interval [0, 1]\n\t\t\tweight += 1 - fraction;\n\t\t\tcomment += `Game ${gameInfo.game_id} lasted ${gameInfo.move_count} moves. `;\n\t\t}\n\t}\n\tif (weight > 0)\n\t\tsuspicion_level_record_list.push({\n\t\t\tcategory: 'move_count',\n\t\t\tweight: (weight / gameInfoList.length) * 0.5, // rescale to [0,0.5]\n\t\t\tcomment,\n\t\t});\n}\n\n/**\n * Check if the durations on the server of the games in gameInfoList are too low.\n * If yes, append entry to suspicion_level_record_list.\n */\nfunction checkDurations(\n\tgameInfoList: RatingAbuseRelevantGameInfo[],\n\tsuspicion_level_record_list: SuspicionLevelRecord[],\n): void {\n\tlet weight = 0;\n\tlet comment = '';\n\tfor (const gameInfo of gameInfoList) {\n\t\tif (!gameInfo.elo_change_from_game || gameInfo.elo_change_from_game < 0) continue; // Game is not suspicious if player lost elo from it\n\n\t\t// Game is suspicious if it lasted too briefly on the server\n\t\tif (\n\t\t\tgameInfo.time_duration_millis !== null &&\n\t\t\tgameInfo.time_duration_millis <= SUSPICIOUS_TIME_DURATION_MILLIS\n\t\t) {\n\t\t\tconst fraction = gameInfo.time_duration_millis / SUSPICIOUS_TIME_DURATION_MILLIS; // fraction is in the interval [0, 1]\n\t\t\tweight += 1 - fraction;\n\t\t\tcomment += `Game ${gameInfo.game_id} lasted ${Math.round(gameInfo.time_duration_millis / 1000)}s. `;\n\t\t}\n\t}\n\tif (weight > 0)\n\t\tsuspicion_level_record_list.push({\n\t\t\tcategory: 'duration',\n\t\t\tweight: (weight / gameInfoList.length) * 0.8, // rescale to [0,0.8]\n\t\t\tcomment,\n\t\t});\n}\n\n/**\n * Check if the clock at the end of the games in gameInfoList are too low.\n * If yes, append entry to suspicion_level_record_list.\n */\nfunction checkClockAtEnd(\n\tgameInfoList: RatingAbuseRelevantGameInfo[],\n\tsuspicion_level_record_list: SuspicionLevelRecord[],\n): void {\n\tlet weight = 0;\n\tlet comment = '';\n\tfor (const gameInfo of gameInfoList) {\n\t\tif (!gameInfo.elo_change_from_game || gameInfo.elo_change_from_game < 0) continue; // Game is not suspicious if player lost elo from it\n\n\t\t// Game is suspicious if the clock at the end is still similar to the start time\n\t\tif (\n\t\t\tgameInfo.clock_at_end_millis !== null &&\n\t\t\tgameInfo.base_time_seconds !== null &&\n\t\t\tgameInfo.increment_seconds !== null\n\t\t) {\n\t\t\tconst approximate_total_time_millis =\n\t\t\t\t1000 *\n\t\t\t\t(gameInfo.base_time_seconds +\n\t\t\t\t\t0.5 * gameInfo.increment_seconds * (gameInfo.move_count - 1));\n\t\t\tif (\n\t\t\t\tapproximate_total_time_millis > 0 &&\n\t\t\t\tgameInfo.clock_at_end_millis >= 0.8 * approximate_total_time_millis\n\t\t\t) {\n\t\t\t\tconst fraction = Math.min(\n\t\t\t\t\t1,\n\t\t\t\t\tgameInfo.clock_at_end_millis / approximate_total_time_millis,\n\t\t\t\t); // fraction is in the interval [0.8, 1]\n\t\t\t\tweight += 5 * fraction - 4; // rescale to [0,1]\n\t\t\t\tcomment += `At end of game ${gameInfo.game_id} with time control ${gameInfo.base_time_seconds / 60}m+${gameInfo.increment_seconds}s, player has ${(gameInfo.clock_at_end_millis / 60_000).toFixed(2)}m left. `;\n\t\t\t}\n\t\t}\n\t}\n\tif (weight > 0)\n\t\tsuspicion_level_record_list.push({\n\t\t\tcategory: 'clock_at_end',\n\t\t\tweight: (weight / gameInfoList.length) * 0.4, // rescale to [0, 0.4]\n\t\t\tcomment,\n\t\t});\n}\n\n/**\n * Check if the user is playing against the same opponents many times.\n * If yes, append entry to suspicion_level_record_list.\n */\nfunction checkOpponentSameness(\n\tuser_id_list: number[],\n\tuser_id_frequency: { [key: number]: number },\n\tsuspicion_level_record_list: SuspicionLevelRecord[],\n): void {\n\tif (user_id_list.length === 0) return;\n\n\tlet weight = 0;\n\tfor (const frequency of Object.values(user_id_frequency)) {\n\t\t// Player is suspicious if he played against the same opponent several times\n\t\tif (frequency > 1) weight += frequency ** 2;\n\t}\n\tif (weight > 0)\n\t\tsuspicion_level_record_list.push({\n\t\t\tcategory: 'same_opponents',\n\t\t\tweight: (weight / user_id_list.length ** 2) * 0.5, // rescale to [0, 0.5]\n\t\t});\n}\n\n/**\n * Check if the user is using the same IP address as his opponents.\n * If yes, append entry to suspicion_level_record_list.\n */\nfunction checkIPAddresses(\n\tuser_id_list: number[],\n\tuser_id_frequency: { [key: number]: number },\n\tuser_ip_address_list: string[],\n\topponent_ip_address_lists: { [key: number]: string[] },\n\tsuspicion_level_record_list: SuspicionLevelRecord[],\n): void {\n\t// Player logged out mid game\n\tif (user_ip_address_list.length === 0) {\n\t\tsuspicion_level_record_list.push({\n\t\t\tcategory: 'ip_addresses',\n\t\t\tweight: 0.5,\n\t\t\tcomment: 'Player logged out mid-game.',\n\t\t});\n\t\treturn;\n\t} else if (user_id_list.length === 0 || Object.keys(opponent_ip_address_lists).length === 0)\n\t\treturn;\n\n\tlet weight = 0;\n\tlet comment = 'Opponents using same IP address: ';\n\tfor (const user_id in opponent_ip_address_lists) {\n\t\t// Player is suspicious if he uses a same IP adress as an opponent\n\t\tconst common_ip_addresses = user_ip_address_list.filter((ip_address) =>\n\t\t\topponent_ip_address_lists[user_id]!.includes(ip_address),\n\t\t);\n\t\tif (common_ip_addresses.length > 0) {\n\t\t\tweight += user_id_frequency[user_id] ?? 0;\n\t\t\tcomment += `${user_id},`;\n\t\t}\n\t}\n\tif (weight > 0)\n\t\tsuspicion_level_record_list.push({\n\t\t\tcategory: 'ip_addresses',\n\t\t\tweight: (weight / user_id_list.length) * 0.5, // rescale to [0, 0.5]\n\t\t\tcomment,\n\t\t});\n}\n\n/**\n * Check if the user's opponents have newly created accounts\n * If yes, append entry to suspicion_level_record_list.\n */\nfunction checkOpponentAccountAge(\n\tuser_id_list: number[],\n\tuser_id_frequency: { [key: number]: number },\n\topponentInfoList: RatingAbuseRelevantMemberRecord[],\n\tsuspicion_level_record_list: SuspicionLevelRecord[],\n): void {\n\tif (user_id_list.length === 0) return;\n\n\tconst current_time_millis = Date.now();\n\tlet weight = 0;\n\tlet comment = 'Newly joined opponents: ';\n\tfor (const opponentInfo of opponentInfoList) {\n\t\t// Player is suspicious if his opponent's account is less than a week old\n\t\tconst account_age_millis = Math.max(\n\t\t\t0,\n\t\t\tcurrent_time_millis - timeutil.sqliteToTimestamp(opponentInfo.joined),\n\t\t);\n\t\tif (account_age_millis < SUSPICIOUS_ACCOUNT_AGE_MILLIS) {\n\t\t\tconst fraction = account_age_millis / SUSPICIOUS_ACCOUNT_AGE_MILLIS; // fraction is in the interval [0, 1]\n\t\t\tweight += (1 - fraction) * (user_id_frequency[opponentInfo.user_id] ?? 0);\n\t\t\tcomment += `${opponentInfo.user_id},`;\n\t\t}\n\t}\n\tif (weight > 0)\n\t\tsuspicion_level_record_list.push({\n\t\t\tcategory: 'opponent_account_age',\n\t\t\tweight: (weight / user_id_list.length) * 0.3, // rescale to [0, 0.3]\n\t\t\tcomment,\n\t\t});\n}\n\nexport default {\n\tmeasureRatingAbuseAfterGame,\n};\n"
  },
  {
    "path": "src/server/game/gamemanager/ratingcalculation.ts",
    "content": "// src/server/game/gamemanager/ratingcalculation.ts\n\n/**\n * Implementation of Glicko-1 algorithm for calculating rating changes arising from ranked games\n */\n\nimport timeutil from '../../../shared/util/timeutil.js';\nimport { PlayerGroup, type Player, players as p } from '../../../shared/chess/util/typeutil.js';\n\n// Default variables, shared across all leaderboards ------------------------------------------------------------------\n\n/** Default elo for a player not contained in a leaderboard. We use the same default across the leaderboards, to avoid confusion. */\nconst DEFAULT_LEADERBOARD_ELO = 1500.0;\n\n/** Minimum elo for a player on a leaderboard. */\nconst MINIMUM_LEADERBOARD_ELO = 400.0;\n\n/** Default rating deviation, used for Glicko-1 */\nconst DEFAULT_LEADERBOARD_RD = 350.0;\n\n/**\n * Minimum rating deviation, used for Glicko-1\n *\n * 50 => ~+-8 elo change per game played.\n * 50 DV can be reach by playing 7-8 games per day.\n *\n * See: https://discord.com/channels/1114425729569017918/1260310049889189908/1373014556254670970\n */\nconst MINIMUM_LEADERBOARD_RD = 50.0;\n\n/** Rating deviations above this are considered to be too uncertain and the user is excluded from leaderboards */\nconst UNCERTAIN_LEADERBOARD_RD = 220.0; // Requires 3 games to be placed on the leaderboard.\n\n/** Constant c, used for Glicko-1 */\nconst c = 70;\n\n/** Constant q, used for Glicko-1 */\nconst q = 0.00575646273;\n\n/** Duration of a glicko-1 rating period, in milliseconds */\nconst RATING_PERIOD_DURATION = 1000 * 60 * 60 * 24 * 15; // 15 days\n\n/** Frequency of automatic RD update in database, in milliseconds */\nconst RD_UPDATE_FREQUENCY = 1000 * 60 * 60 * 24; // 24 hours\n// const RD_UPDATE_FREQUENCY = 1000 * 30; // 30s for dev testing\n\n// Types -------------------------------------------------------------------------------\n\n/** Type containing all relevant rating calculation quantities for a specific player */\ntype PlayerRatingData = {\n\telo_at_game: number;\n\trating_deviation_at_game: number;\n\trd_last_update_date: string | null; // A date in string format, as used in the database. Can be null if no games played yet\n\telo_after_game?: number;\n\trating_deviation_after_game?: number;\n\telo_change_from_game?: number;\n};\n\n/** A dictionary type with Players as keys, containing PlayerRatingData for each player */\ntype RatingData = PlayerGroup<PlayerRatingData>;\n\n// Functions -------------------------------------------------------------------------------\n\n/**\n * Computes the effective rating deviation for the current rating period, as for Glicko-1 algorithm\n */\nfunction getTrueRD(rating_deviation: number, rd_last_update_date: string | null): number {\n\tif (rd_last_update_date === null) return rating_deviation;\n\telse {\n\t\tconst last_rated_game_timestamp = timeutil.sqliteToTimestamp(rd_last_update_date);\n\t\tconst current_timestamp = Date.now();\n\n\t\t// fraction of elapsed time over length of a standard rating period -> noninteger in general\n\t\tconst rating_periods_elapsed = Math.max(\n\t\t\t0,\n\t\t\t(current_timestamp - last_rated_game_timestamp) / RATING_PERIOD_DURATION,\n\t\t);\n\n\t\treturn Math.max(\n\t\t\tMINIMUM_LEADERBOARD_RD,\n\t\t\tMath.min(\n\t\t\t\tDEFAULT_LEADERBOARD_RD,\n\t\t\t\tMath.sqrt(rating_deviation ** 2 + rating_periods_elapsed * c ** 2),\n\t\t\t),\n\t\t);\n\t}\n}\n\n/** Function g of Glicko-1 algorithm */\nfunction g(RD: number): number {\n\treturn 1 / Math.sqrt(1 + (3 * q ** 2 * RD ** 2) / Math.PI ** 2);\n}\n\n/** Function E of Glicko-1 algorithm: expected outcome of game */\nfunction E(r: number, r_opp: number, RD_opp: number): number {\n\treturn 1 / (1 + 10 ** ((-g(RD_opp) * (r - r_opp)) / 400));\n}\n\n/** Function d^2 of Glicko-1 algorithm */\nfunction d_squared(r: number, r_opp: number, RD_opp: number): number {\n\tconst Es = E(r, r_opp, RD_opp);\n\treturn 1 / (q ** 2 * g(RD_opp) ** 2 * Es * (1 - Es));\n}\n\n/** Given a game outcome for a player, his rating r, his RD, and the opponent'S rating r_opp and RD_opp, compute his new rating with glicko-1 */\nfunction new_rating(\n\toutcome: 0 | 0.5 | 1,\n\tr: number,\n\tRD: number,\n\tr_opp: number,\n\tRD_opp: number,\n): number {\n\treturn Math.max(\n\t\tMINIMUM_LEADERBOARD_ELO,\n\t\t// prettier-ignore\n\t\tr + ( q / ( 1 / RD ** 2 + 1 / d_squared(r, r_opp, RD_opp) ) ) * g(RD_opp) * (outcome - E(r, r_opp, RD_opp)),\n\t);\n}\n\n/** Given a player's rating r, his RD, and the opponent'S rating r_opp and RD_opp, compute his new rating with glicko-1 */\nfunction new_RD(r: number, RD: number, r_opp: number, RD_opp: number): number {\n\treturn Math.max(\n\t\tMINIMUM_LEADERBOARD_RD,\n\t\t// p\n\t\t// prettier-ignore\n\t\tMath.sqrt(1 / (1 / RD ** 2 + 1 / d_squared(r, r_opp, RD_opp))),\n\t);\n}\n\n/**\n * Takes ratingdata object as an input, with entries: elo_at_game, rating_deviation_at_game and rd_last_update_date.\n * Computes rating data changes and returns ratingdata object by overwriting entries: elo_after_game, rating_deviation_after_game and elo_change_from_game.\n * MUTATING. Modifies original ratingdata object.\n */\nfunction computeRatingDataChanges(ratingdata: RatingData, victor: Player | null): RatingData {\n\t// Currently, only rating calculations for 2-player games with White vs Black are supported\n\tconst playerCount = Object.keys(ratingdata).length;\n\tif (playerCount !== 2) throw Error('Rating changes are only supported in two player games!');\n\tif (ratingdata[p.WHITE] === undefined || ratingdata[p.BLACK] === undefined)\n\t\tthrow Error(\"Missing White or Black's rating data!\");\n\n\tconst r1 = ratingdata[p.WHITE]!.elo_at_game;\n\tconst r2 = ratingdata[p.BLACK]!.elo_at_game;\n\tconst RD1 = getTrueRD(\n\t\tratingdata[p.WHITE]!.rating_deviation_at_game,\n\t\tratingdata[p.WHITE]!.rd_last_update_date,\n\t);\n\tconst RD2 = getTrueRD(\n\t\tratingdata[p.BLACK]!.rating_deviation_at_game,\n\t\tratingdata[p.BLACK]!.rd_last_update_date,\n\t);\n\tconst outcome_white = victor === p.WHITE ? 1 : victor === p.BLACK ? 0 : 0.5;\n\tconst outcome_black = victor === p.WHITE ? 0 : victor === p.BLACK ? 1 : 0.5;\n\n\tratingdata[p.WHITE]!.elo_after_game = new_rating(outcome_white, r1, RD1, r2, RD2);\n\tratingdata[p.WHITE]!.rating_deviation_after_game = new_RD(r1, RD1, r2, RD2);\n\tratingdata[p.WHITE]!.elo_change_from_game = ratingdata[p.WHITE]!.elo_after_game! - r1;\n\n\tratingdata[p.BLACK]!.elo_after_game = new_rating(outcome_black, r2, RD2, r1, RD1);\n\tratingdata[p.BLACK]!.rating_deviation_after_game = new_RD(r2, RD2, r1, RD1);\n\tratingdata[p.BLACK]!.elo_change_from_game = ratingdata[p.BLACK]!.elo_after_game! - r2;\n\n\treturn ratingdata;\n}\n\n// FOR TESTING ===================================================================\n\n/**\n * DISCUSSION of testing:\n * https://discord.com/channels/1114425729569017918/1260310049889189908/1373014556254670970\n */\n\n// type PlayerStats = {\n// \telo: number;\n// \trd: number;\n// \tlastUpdateDate: string | null; // Date string in SQLite format\n// }\n\n// // --- Simulation State ---\n// const player1CurrentStats: PlayerStats = {\n// \telo: DEFAULT_LEADERBOARD_ELO,\n// \trd: DEFAULT_LEADERBOARD_RD,\n// \tlastUpdateDate: null, // Initially null, set to date string after first game\n// };\n\n// const player2CurrentStats: PlayerStats = {\n// \telo: DEFAULT_LEADERBOARD_ELO,\n// \trd: DEFAULT_LEADERBOARD_RD,\n// \tlastUpdateDate: null,\n// };\n\n// let gameCounter = 0;\n// const SIMULATION_GAME_INTERVAL_MS = 250; // Simulate a game every 3 seconds\n\n// // --- Simulation Function ---\n// function runSingleGameSimulation() {\n// \tgameCounter++;\n// \tconsole.log(`\\n--- Simulating Game #${gameCounter} ---`);\n\n// \t// Prepare RatingData for the game about to be played\n// \tconst ratingDataForThisGame = {\n// \t\t[players.WHITE]: {\n// \t\t\telo_at_game: player1CurrentStats.elo,\n// \t\t\trating_deviation_at_game: player1CurrentStats.rd,\n// \t\t\trd_last_update_date: player1CurrentStats.lastUpdateDate,\n// \t\t},\n// \t\t[players.BLACK]: {\n// \t\t\telo_at_game: player2CurrentStats.elo,\n// \t\t\trating_deviation_at_game: player2CurrentStats.rd,\n// \t\t\trd_last_update_date: player2CurrentStats.lastUpdateDate,\n// \t\t},\n// \t};\n\n// \tconsole.log(`P1 (White) Current: ELO ${player1CurrentStats.elo.toFixed(2)}, RD ${player1CurrentStats.rd.toFixed(2)}, Last Update: ${player1CurrentStats.lastUpdateDate || 'Never'}`);\n// \tconsole.log(`P2 (Black) Current: ELO ${player2CurrentStats.elo.toFixed(2)}, RD ${player2CurrentStats.rd.toFixed(2)}, Last Update: ${player2CurrentStats.lastUpdateDate || 'Never'}`);\n\n// \t// RD values that will actually be used in calculation (after getTrueRD applies time decay)\n// \t// Note: getTrueRD is called internally by computeRatingDataChanges. We can also call it here for display.\n// \tconst rd1ForCalc = getTrueRD(ratingDataForThisGame[players.WHITE].rating_deviation_at_game, ratingDataForThisGame[players.WHITE].rd_last_update_date);\n// \tconst rd2ForCalc = getTrueRD(ratingDataForThisGame[players.BLACK].rating_deviation_at_game, ratingDataForThisGame[players.BLACK].rd_last_update_date);\n// \tconsole.log(`P1 RD for this game (after time decay): ${rd1ForCalc.toFixed(2)}`);\n// \tconsole.log(`P2 RD for this game (after time decay): ${rd2ForCalc.toFixed(2)}`);\n\n// \t// Simulate a game outcome (randomly)\n// \tconst randomOutcomeSeed = Math.random();\n// \tlet victorId;\n// \tlet outcomeDescription;\n\n// \tif (randomOutcomeSeed < 0.45) { // Player 1 (White) wins\n// \t\tvictorId = players.WHITE;\n// \t\toutcomeDescription = \"Player 1 (White) wins\";\n// \t} else if (randomOutcomeSeed < 0.9) { // Player 2 (Black) wins\n// \t\tvictorId = players.BLACK;\n// \t\toutcomeDescription = \"Player 2 (Black) wins\";\n// \t} else { // Draw\n// \t\tvictorId = null; // `computeRatingDataChanges` handles this as a draw\n// \t\toutcomeDescription = \"Draw\";\n// \t}\n// \tconsole.log(`Game Outcome: ${outcomeDescription}`);\n\n// \t// Calculate new ratings using Glicko-1\n// \tconst GlickoResults = computeRatingDataChanges(ratingDataForThisGame, victorId);\n\n// \t// Update player stats for the next simulated game\n// \t// 2 Days\n// \tconst timeSinceLastGame = 1000 * 60 * 60 * 24 * 30 * 1.5; // 6 weeks\n// \tconst gameTimestampString = timeutil.timestampToSqlite(Date.now() - timeSinceLastGame);\n\n// \tplayer1CurrentStats.elo = GlickoResults[players.WHITE]!.elo_after_game!;\n// \tplayer1CurrentStats.rd = GlickoResults[players.WHITE]!.rating_deviation_after_game!;\n// \tplayer1CurrentStats.lastUpdateDate = gameTimestampString;\n\n// \tplayer2CurrentStats.elo = GlickoResults[players.BLACK]!.elo_after_game!;\n// \tplayer2CurrentStats.rd = GlickoResults[players.BLACK]!.rating_deviation_after_game!;\n// \tplayer2CurrentStats.lastUpdateDate = gameTimestampString;\n\n// \t// Calculate RD changes\n// \tconst rd1Change = GlickoResults[players.WHITE]!.rating_deviation_after_game! - rd1ForCalc;\n// \tconst rd2Change = GlickoResults[players.BLACK]!.rating_deviation_after_game! - rd2ForCalc;\n\n// \t// Modified console.log lines\n// \tconsole.log(`P1 (White) New Stats: ELO ${player1CurrentStats.elo.toFixed(2)} (ELO Change: ${GlickoResults[players.WHITE]!.elo_change_from_game!.toFixed(2)}), RD ${player1CurrentStats.rd.toFixed(2)} (RD Change: ${rd1Change.toFixed(2)})`);\n// \tconsole.log(`P2 (Black) New Stats: ELO ${player2CurrentStats.elo.toFixed(2)} (ELO Change: ${GlickoResults[players.BLACK]!.elo_change_from_game!.toFixed(2)}), RD ${player2CurrentStats.rd.toFixed(2)} (RD Change: ${rd2Change.toFixed(2)})`);\n\n// \t// Demonstrate RD increase due to inactivity (illustrative)\n// \t// This happens because getTrueRD increases RD if time has passed.\n// \t// In our rapid simulation, this effect is small between games.\n// \t// Here we show what RD would be after a longer period.\n// \tif (gameCounter % 5 === 0 && typeof timeutil !== 'undefined') { // Show every 5 games\n// \t\tconst timeDeltaForDemo = 1000 * 60 * 60 * 24 * 30 * 2; // 2 months\n\n// \t\t// We need to simulate 'Date.now()' being in the future for getTrueRD.\n// \t\t// We can do this by preparing inputs for getTrueRD manually.\n// \t\tconst p1LastUpdateTimestamp = timeutil.sqliteToTimestamp(player1CurrentStats.lastUpdateDate);\n// \t\tconst futureTimestamp = p1LastUpdateTimestamp + timeDeltaForDemo; // Simulate time passed since last game\n\n// \t\t// Calculate what getTrueRD would be if 'current_timestamp' was 'futureTimestamp'\n// \t\tconst rating_periods_elapsed_demo = Math.max(0, (futureTimestamp - p1LastUpdateTimestamp) / RATING_PERIOD_DURATION);\n// \t\tconst p1_RD_if_inactive_demo = Math.max(MIMIMUM_LEADERBOARD_RD, Math.min(DEFAULT_LEADERBOARD_RD, Math.sqrt(player1CurrentStats.rd ** 2 + rating_periods_elapsed_demo * c ** 2)));\n\n// \t\tconst p1_RD_change = p1_RD_if_inactive_demo - player1CurrentStats.rd;\n\n// \t\tconsole.log(`\\nDEMO: If P1 (RD ${player1CurrentStats.rd.toFixed(2)}) is inactive for ${timeDeltaForDemo / (1000 * 60 * 60 * 24)} days, their RD would become ~${p1_RD_if_inactive_demo.toFixed(2)}. (Change: ${p1_RD_change.toFixed(2)})`);\n// \t}\n// }\n\n// // --- Start Simulation ---\n// console.log(\"--- Glicko-1 Rating Simulation Test ---\");\n// console.log(\"This test will simulate games between two players and update their ratings.\");\n// console.log(`A game is simulated every ${SIMULATION_GAME_INTERVAL_MS / 1000} seconds.`);\n// console.log(\"Initial P1 (White): ELO\", DEFAULT_LEADERBOARD_ELO, \"RD:\", DEFAULT_LEADERBOARD_RD);\n// console.log(\"Initial P2 (Black): ELO\", DEFAULT_LEADERBOARD_ELO, \"RD:\", DEFAULT_LEADERBOARD_RD);\n\n// // Run the first game immediately, then set interval\n// runSingleGameSimulation();\n// const simulationIntervalId = setInterval(runSingleGameSimulation, SIMULATION_GAME_INTERVAL_MS);\n\n// // --- Optional: Stop simulation after some time ---\n// const SIMULATION_DURATION_GAMES = 100; // Number of games to simulate before stopping\n// const SIMULATION_DURATION_MS = SIMULATION_GAME_INTERVAL_MS * SIMULATION_DURATION_GAMES + 100; // e.g., run for 10 games + buffer\n// setTimeout(() => {\n// \tif (simulationIntervalId) clearInterval(simulationIntervalId);\n// \tconsole.log(`\\n--- Simulation automatically stopped after ${gameCounter} games. ---`);\n// \tif (typeof timeutil !== 'undefined') {\n// \t\t// Final check of TrueRDs based on their last game time until \"now\"\n// \t\tconst p1FinalTrueRD = getTrueRD(player1CurrentStats.rd, player1CurrentStats.lastUpdateDate);\n// \t\tconst p2FinalTrueRD = getTrueRD(player2CurrentStats.rd, player2CurrentStats.lastUpdateDate);\n// \t\tconsole.log(`P1 Final State: ELO ${player1CurrentStats.elo.toFixed(2)}, Base RD ${player1CurrentStats.rd.toFixed(2)}, Current TrueRD ${p1FinalTrueRD.toFixed(2)}`);\n// \t\tconsole.log(`P2 Final State: ELO ${player2CurrentStats.elo.toFixed(2)}, Base RD ${player2CurrentStats.rd.toFixed(2)}, Current TrueRD ${p2FinalTrueRD.toFixed(2)}`);\n// \t}\n// }, SIMULATION_DURATION_MS);\n\n// ================================================================================\n\nexport {\n\tDEFAULT_LEADERBOARD_ELO,\n\tDEFAULT_LEADERBOARD_RD,\n\tUNCERTAIN_LEADERBOARD_RD,\n\tRD_UPDATE_FREQUENCY,\n\tgetTrueRD,\n\tcomputeRatingDataChanges,\n};\n\nexport type { RatingData };\n"
  },
  {
    "path": "src/server/game/gamemanager/resync.ts",
    "content": "// src/server/game/gamemanager/resync.ts\n\n/**\n * This script handles resyncing a client to a game when their\n * websocket closes unexpectedly, but they haven't left the page.\n *\n * This is SEPARATE from the re-joining game that happens when you\n * refresh the page. THAT needs more info sent to the client than this resync does,\n * which is only a websocket reopening.\n *\n * This needs to be its own script instead of in gamemanager because\n * both gamemanager and movesubmission depend on this, so we avoid circular dependancy.\n */\n\nimport type { ServerGame } from './gameutility.js';\n\nimport jsutil from '../../../shared/util/jsutil.js';\n\nimport gameutility from './gameutility.js';\nimport liveGameValues from './liveGameValues.js';\nimport { getGameByID } from './gamemanager.js';\nimport { getGameData } from '../../database/gamesManager.js';\nimport { logEventsAndPrint } from '../../middleware/logEvents.js';\nimport { sendSocketMessage } from '../../socket/sendSocketMessage.js';\nimport { cancelDisconnectTimer } from './afkdisconnect.js';\nimport socketUtility, { CustomWebSocket } from '../../socket/socketUtility.js';\n\n/**\n * Resyncs a client's websocket to a game. The client already\n * knows the game id and much other information. We only need to send\n * them the current move list, player timers, and game conclusion.\n * @param ws - Their websocket\n * @param gameID - The game id they requested to sync to. They SHOULD have provided this as a number, but they may tamper it.\n * @param replyToMessageID - If specified, the id of the incoming socket message this resync will be the reply to\n */\nfunction resyncToGame(ws: CustomWebSocket, gameID: any, replyToMessageID?: number): void {\n\tif (typeof gameID !== 'number') {\n\t\t// Tampered message\n\t\tconst log = `Socket sent 'resync', but gameID is in the wrong form! Received: (${jsutil.ensureJSONString(gameID)}) of type ${typeof gameID}. The socket: ${socketUtility.stringifySocketMetadata(ws)}`;\n\t\tlogEventsAndPrint(log, 'errLog.txt');\n\t\treturn;\n\t}\n\n\t// Make sure their pre-subbed game and game they requested to resync to match.\n\tconst preSubbedGameId = ws.metadata.subscriptions.game?.id;\n\tif (preSubbedGameId !== undefined && preSubbedGameId !== gameID) {\n\t\tlogEventsAndPrint(\n\t\t\t`Client tried to resync to game of id (${gameID}) when they are actually subbed to game of id (${preSubbedGameId})!!`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\treturn;\n\t}\n\n\t// 1. Check if the game is still live => Resync them\n\tconst game: ServerGame | undefined = getGameByID(gameID);\n\n\t// 2. Not live => Send game results from database\n\tif (!game) {\n\t\tsendClientLoggedGame(ws, gameID);\n\t\treturn;\n\t}\n\n\t// Verify\n\tconst colorPlayingAs =\n\t\tws.metadata.subscriptions.game?.color ??\n\t\tgameutility.doesSocketBelongToGame_ReturnColor(game.match, ws);\n\tif (!colorPlayingAs) {\n\t\tsendSocketMessage(ws, 'game', 'login'); // Unable to verify their socket belongs to this game (probably logged out)\n\t\treturn;\n\t}\n\n\tgameutility.resyncToGame(ws, game, colorPlayingAs, replyToMessageID);\n\n\tcancelDisconnectTimer(game.match, colorPlayingAs);\n\tliveGameValues.onPlayerReconnected(game, colorPlayingAs);\n}\n\n/** Sends a client a game from the database. */\nfunction sendClientLoggedGame(ws: CustomWebSocket, gameID: number): void {\n\tconst logged_game_info = getGameData(gameID, [\n\t\t'game_id',\n\t\t'rated',\n\t\t'private',\n\t\t'termination',\n\t\t'icn',\n\t]);\n\tif (!logged_game_info) {\n\t\t// This happens if the user requests a game that was aborted before\n\t\t// any moves were made, as those games are not stored in the database.\n\t\tsendSocketMessage(ws, 'game', 'nogame'); // IN THE FUTURE: The client could show a \"Game not found\" page\n\t\treturn;\n\t}\n\n\t// They should automatically know to unsub on their end, because of this message.\n\n\t// Send them the actual game info.\n\tsendSocketMessage(ws, 'game', 'logged-game-info', logged_game_info);\n\n\tconsole.log(`Sent client game from the database of id (${gameID})!`);\n}\n\nexport { resyncToGame };\n"
  },
  {
    "path": "src/server/game/invitesmanager/acceptinvite.ts",
    "content": "// src/server/game/invitesmanager/acceptinvite.ts\n\n/**\n * This script handles invite acceptance,\n * creating a new game if successful.\n */\n\nimport type { AuthMemberInfo } from '../../types.js';\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\nimport type { Player, PlayerGroup } from '../../../shared/chess/util/typeutil.js';\n\nimport * as z from 'zod';\n\nimport gameutility from '../gamemanager/gameutility.js';\nimport socketUtility from '../../socket/socketUtility.js';\nimport { createGame } from '../gamemanager/gamemanager.js';\nimport { memberInfoEq } from './inviteutility.js';\nimport { getTranslation } from '../../utility/translate.js';\nimport { isSocketInAnActiveGame } from '../gamemanager/activeplayers.js';\nimport { removeSocketFromInvitesSubs } from './invitessubscribers.js';\nimport { sendNotify, sendSocketMessage } from '../../socket/sendSocketMessage.js';\nimport { broadcastGameCountToInviteSubs } from '../gamemanager/gamecount.js';\nimport {\n\tgetInviteAndIndexByID,\n\tdeleteInviteByIndex,\n\tdeleteUsersExistingInvite,\n\tfindSocketFromOwner,\n\tonPublicInvitesChange,\n\tIDLengthOfInvites,\n} from './invitesmanager.js';\n\n/** The zod schema for validating the contents of the acceptinvite message. */\nconst acceptinviteschem = z.strictObject({\n\tid: z.string().length(IDLengthOfInvites),\n\tisPrivate: z.boolean(),\n});\n\ntype AcceptInviteMessage = z.infer<typeof acceptinviteschem>;\n\n/**\n * Attempts to accept an invite of given id.\n * @param ws - The socket performing this action\n * @param messageContents - The incoming socket message that SHOULD look like: `{ id, isPrivate }`\n * @param replyto - The ID of the incoming socket message. This is used for the `replyto` property on our response.\n */\nfunction acceptInvite(\n\tws: CustomWebSocket,\n\tmessageContents: AcceptInviteMessage,\n\treplyto?: number,\n): void {\n\t// { id, isPrivate }\n\tif (isSocketInAnActiveGame(ws))\n\t\treturn sendNotify(ws, 'server.javascript.ws-already_in_game', { replyto });\n\n\t// Does the invite still exist?\n\tconst inviteAndIndex = getInviteAndIndexByID(messageContents.id); // { invite, index }\n\tif (!inviteAndIndex)\n\t\treturn informThemGameAborted(ws, messageContents.isPrivate, messageContents.id, replyto);\n\n\tconst { invite, index } = inviteAndIndex;\n\n\tconst user = ws.metadata.memberInfo;\n\n\t// Make sure they are not accepting their own.\n\tif (memberInfoEq(user, invite.owner)) {\n\t\tsendSocketMessage(ws, 'general', 'printerror', 'Cannot accept your own invite!', replyto);\n\t\tconsole.error(\n\t\t\t`Player tried to accept their own invite! Socket: ${socketUtility.stringifySocketMetadata(ws)}`,\n\t\t);\n\t\treturn;\n\t}\n\n\t// Make sure it's legal for them to accept. (Not legal if they are a guest or unverified, and the invite is RATED)\n\tif (invite.rated === 'rated' && !(user.signedIn && ws.metadata.verified)) {\n\t\treturn sendSocketMessage(\n\t\t\tws,\n\t\t\t'general',\n\t\t\t'notify',\n\t\t\tgetTranslation(\n\t\t\t\t'server.javascript.ws-rated_invite_verification_needed',\n\t\t\t\tws.metadata.cookies?.i18next,\n\t\t\t),\n\t\t\treplyto,\n\t\t);\n\t}\n\n\t// Accept the invite!\n\n\tlet hadPublicInvite = false;\n\t// Delete the invite accepted.\n\tif (deleteInviteByIndex(ws, invite, index, { dontBroadcast: true })) hadPublicInvite = true;\n\t// Delete their existing invites\n\tif (deleteUsersExistingInvite(user, { broadCastNewInvites: false })) hadPublicInvite = true;\n\n\t// Start the game! Notify both players and tell them they've been subscribed to a game!\n\n\tconst player1Socket = findSocketFromOwner(invite.owner); // Could be undefined occasionally\n\tconst player2Socket = ws;\n\n\t// Assign each player a color based on their invite info. Add their socket just encase\n\tconst assignments: PlayerGroup<{ identifier: AuthMemberInfo; socket?: CustomWebSocket }> = {};\n\tlet invite_accepter: Player | undefined;\n\tfor (const [strcolor, identifier] of Object.entries(\n\t\tgameutility.assignWhiteBlackPlayersFromInvite(\n\t\t\tinvite.color,\n\t\t\tinvite.owner,\n\t\t\tws.metadata.memberInfo,\n\t\t),\n\t)) {\n\t\tconst player = Number(strcolor) as Player;\n\t\tconst is_invite_accepter = memberInfoEq(identifier, player2Socket.metadata.memberInfo);\n\t\tif (is_invite_accepter) invite_accepter = player;\n\t\tassignments[player] = {\n\t\t\tidentifier,\n\t\t\tsocket: is_invite_accepter ? player2Socket : player1Socket,\n\t\t};\n\t}\n\n\tif (invite_accepter === undefined)\n\t\tthrow Error(\"Invite accepter doesn't exist on accepted 2 player invite\");\n\n\tcreateGame(invite, assignments, invite_accepter, replyto);\n\n\t// Unsubscribe them both from the invites subscription list.\n\tif (player1Socket) removeSocketFromInvitesSubs(player1Socket); // Could be undefined occasionally\n\tremoveSocketFromInvitesSubs(player2Socket);\n\n\t// Broadcast the invites list change after creating the game,\n\t// because the new game ups the game count.\n\tif (hadPublicInvite)\n\t\tonPublicInvitesChange(); // Broadcast to all invites list subscribers!\n\telse broadcastGameCountToInviteSubs();\n}\n\n/**\n * Called when a player clicks to accept an invite that gets deleted right before.\n * This tells them the game was aborted, or that the code\n * was invalid, if they entered a private invite code.\n * @param replyto - The ID of the incoming socket message. This is used for the `replyto` property on our response.\n */\nfunction informThemGameAborted(\n\tws: CustomWebSocket,\n\tisPrivate: boolean,\n\tinviteID: string,\n\treplyto?: number,\n): void {\n\tconst errString = isPrivate\n\t\t? 'server.javascript.ws-invalid_code'\n\t\t: 'server.javascript.ws-game_aborted';\n\treturn sendNotify(ws, errString, { replyto });\n}\n\nexport { acceptInvite, acceptinviteschem };\n"
  },
  {
    "path": "src/server/game/invitesmanager/cancelinvite.ts",
    "content": "// src/server/game/invitesmanager/cancelinvite.ts\n\n/**\n * This script handles invite cancelation.\n */\n\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\n\nimport * as z from 'zod';\n\nimport socketUtility from '../../socket/socketUtility.js';\nimport { memberInfoEq } from './inviteutility.js';\nimport { sendSocketMessage } from '../../socket/sendSocketMessage.js';\nimport { getInviteAndIndexByID, deleteInviteByIndex, IDLengthOfInvites } from './invitesmanager.js';\n\n/** The zod schema for validating the contents of the cancelinvite message. */\nconst cancelinviteschem = z.string().length(IDLengthOfInvites);\n\n/** This is also the id of the invite to delete */\ntype CancelInviteMessage = z.infer<typeof cancelinviteschem>;\n\n/**\n * Cancels/deletes the specified invite.\n * @param ws - Their socket\n * @param messageContents - The incoming socket message that is the ID of the invite to be cancelled!\n * @param replyto - The ID of the incoming socket message. This is used for the `replyto` property on our response.\n */\nfunction cancelInvite(\n\tws: CustomWebSocket,\n\tmessageContents: CancelInviteMessage,\n\treplyto?: number,\n): void {\n\t// Value should be the ID of the invite to cancel!\n\tconst id = messageContents; // id of invite to delete\n\n\tconst inviteAndIndex = getInviteAndIndexByID(id); // { invite, index } | undefined\n\t// Already cancelled, they must have joined a game, OR CANCELLED on a different tab!\n\t// The client is expecting a response from us, even if empty, so it knows to unlock the create invite button again!\n\tif (!inviteAndIndex) return sendSocketMessage(ws, undefined, undefined, undefined, replyto);\n\n\tconst { invite, index } = inviteAndIndex;\n\n\t// Make sure they are the owner.\n\tif (!memberInfoEq(ws.metadata.memberInfo, invite.owner)) {\n\t\tconsole.error(\n\t\t\t`Player tried to delete an invite that wasn't theirs! Invite ID: ${id} Socket: ${socketUtility.stringifySocketMetadata(ws)}`,\n\t\t);\n\t\treturn sendSocketMessage(\n\t\t\tws,\n\t\t\t'general',\n\t\t\t'printerror',\n\t\t\t'You are forbidden to delete this invite.',\n\t\t\treplyto,\n\t\t);\n\t}\n\n\tdeleteInviteByIndex(ws, invite, index, { replyto });\n}\n\nexport { cancelInvite, cancelinviteschem };\n"
  },
  {
    "path": "src/server/game/invitesmanager/createinvite.ts",
    "content": "// src/server/game/invitesmanager/createinvite.ts\n\n/**\n * This script handles invite creation, making sure that the invites have valid properties.\n */\n\nimport type { Invite } from './inviteutility.js';\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\nimport type { Rating, ServerUsernameContainer } from '../../../shared/types.js';\n\nimport * as z from 'zod';\n\nimport uuid from '../../../shared/util/uuid.js';\nimport metadatautil from '../../../shared/chess/util/metadatautil.js';\nimport { variantCodes } from '../../../shared/chess/variants/variant.js';\nimport { players as p } from '../../../shared/chess/util/typeutil.js';\nimport {\n\tLeaderboards,\n\tVariantLeaderboards,\n} from '../../../shared/chess/variants/validleaderboard.js';\n\nimport timecontrol from '../timecontrol.js';\nimport { getTranslation } from '../../utility/translate.js';\nimport { isSocketInAnActiveGame } from '../gamemanager/activeplayers.js';\nimport { getEloOfPlayerInLeaderboard } from '../../database/leaderboardsManager.js';\nimport { sendNotify, sendSocketMessage } from '../../socket/sendSocketMessage.js';\nimport {\n\texistingInviteHasID,\n\tuserHasInvite,\n\taddInvite,\n\tIDLengthOfInvites,\n} from './invitesmanager.js';\n\n/** The zod schema for validating the contents of the createinvite message. */\nconst createinviteschem = z\n\t.strictObject({\n\t\tvariant: z.enum(variantCodes),\n\t\t// `${number}+${number}` | '-'\n\t\tclock: z\n\t\t\t.union([z.templateLiteral([z.number(), '+', z.number()]), z.literal('-')])\n\t\t\t.refine((c) => timecontrol.isValid(c), { error: 'Invalid clock value.' }),\n\t\tcolor: z.literal([p.WHITE, p.BLACK, null]),\n\t\tpublicity: z.enum(['public', 'private']),\n\t\trated: z.enum(['casual', 'rated']),\n\t\ttag: z.string().length(8),\n\t})\n\t.refine(\n\t\t(val) => {\n\t\t\t// Additional refinements for cross-property validation\n\t\t\tif (val.rated === 'rated') {\n\t\t\t\t// Rated game validation...\n\t\t\t\tif (!(val.variant in VariantLeaderboards)) return false; // Invalid variant for a rated game.\n\t\t\t\tif (val.clock === '-') return false; // Invalid clock for a rated game.\n\t\t\t\tif (val.color !== null && val.publicity !== 'private') return false; // Specific colors are only allowed if the rated game is also private.\n\t\t\t}\n\t\t\treturn true; // Casual games can have any properties.\n\t\t},\n\t\t{ error: 'Invalid invite parameters for a rated game.' },\n\t);\n\ntype CreateInviteMessage = z.infer<typeof createinviteschem>;\n\n/**\n * Creates a new invite from their websocket message.\n * @param ws - Their socket\n * @param messageContents - The incoming socket message that SHOULD contain the invite properties!\n * @param replyto - The incoming websocket message ID, to include in the reply\n */\nfunction createInvite(\n\tws: CustomWebSocket,\n\tmessageContents: CreateInviteMessage,\n\treplyto?: number,\n): void {\n\t// invite: { id, owner, variant, clock, color, rated, publicity }\n\tif (isSocketInAnActiveGame(ws))\n\t\treturn sendNotify(ws, 'server.javascript.ws-already_in_game', { replyto }); // Can't create invite because they are already in a game\n\n\t// Make sure they don't already have an existing invite\n\tif (userHasInvite(ws)) {\n\t\tsendSocketMessage(\n\t\t\tws,\n\t\t\t'general',\n\t\t\t'printerror',\n\t\t\t\"Can't create an invite when you have one already.\",\n\t\t\treplyto,\n\t\t);\n\t\tconsole.error(\"Player already has existing invite, can't create another!\");\n\t\treturn;\n\t}\n\n\tconst invite = getInviteFromWebsocketMessageContents(ws, messageContents, replyto);\n\tif (!invite) return; // Message contained invalid invite parameters. Error already sent to the client.\n\n\t// Invite has all legal parameters!\n\n\t// Check if user tries creating a rated game despite not being allowed to\n\tif (invite.rated === 'rated' && !(ws.metadata.memberInfo.signedIn && ws.metadata.verified)) {\n\t\tconst message = getTranslation(\n\t\t\t'server.javascript.ws-rated_invite_verification_needed',\n\t\t\tws.metadata.cookies?.i18next,\n\t\t);\n\t\treturn sendSocketMessage(ws, 'general', 'notify', message, replyto);\n\t}\n\n\t// Create the invite now ...\n\n\taddInvite(ws, invite, replyto);\n}\n\n/**\n * Makes sure the socket message is an object, and strips it of all non-variant related properties.\n * STILL DO EXPLOIT checks on the specific invite values after this!!\n * @param ws\n * @param messageContents - The incoming websocket message contents (separate from route and action)\n * @param replyto - The incoming websocket message ID, to include in the reply\n * @returns The Invite object, or void it the message contents were invalid.\n */\nfunction getInviteFromWebsocketMessageContents(\n\tws: CustomWebSocket,\n\tmessageContents: CreateInviteMessage,\n\treplyto?: number,\n): Invite | void {\n\t// Verify their invite contains the required properties...\n\n\t// Is it an object? (This may pass if it is an array, but arrays won't crash when accessing property names, so it doesn't matter. It will be rejected because it doesn't have the required properties.)\n\t// We have to separately check for null because JAVASCRIPT has a bug where  typeof null => 'object'\n\tif (typeof messageContents !== 'object' || messageContents === null)\n\t\treturn sendSocketMessage(\n\t\t\tws,\n\t\t\t'general',\n\t\t\t'printerror',\n\t\t\t'Cannot create invite when incoming socket message body is not an object!',\n\t\t\treplyto,\n\t\t);\n\n\t/**\n\t * What properties should the invite have from the incoming socket message?\n\t * variant\n\t * clock\n\t * color\n\t * rated\n\t * publicity\n\t * tag\n\t *\n\t * We further need to manually add the properties:\n\t * id\n\t * owner\n\t * usernamecontainer\n\t */\n\n\tlet id: string;\n\tdo {\n\t\tid = uuid.generateID_Base36(IDLengthOfInvites);\n\t} while (existingInviteHasID(id));\n\n\tconst owner = ws.metadata.memberInfo;\n\n\tlet rating: Rating | undefined;\n\tif (ws.metadata.memberInfo.signedIn) {\n\t\t// Fallback to the elo on the INFINITY leaderboard, if the variant does not have a leaderboard.\n\t\tconst leaderboardId = VariantLeaderboards[messageContents.variant] ?? Leaderboards.INFINITY;\n\t\trating = getEloOfPlayerInLeaderboard(ws.metadata.memberInfo.user_id, leaderboardId);\n\t}\n\n\tconst usernamecontainer: ServerUsernameContainer = {\n\t\ttype: owner.signedIn ? 'player' : 'guest',\n\t\tusername: owner.signedIn ? owner.username : metadatautil.GUEST_NAME_ICN_METADATA,\n\t\trating,\n\t};\n\n\treturn {\n\t\tid,\n\t\towner,\n\t\tusernamecontainer,\n\t\tvariant: messageContents.variant,\n\t\tclock: messageContents.clock,\n\t\trated: messageContents.rated,\n\t\tcolor: messageContents.color,\n\t\ttag: messageContents.tag,\n\t\tpublicity: messageContents.publicity,\n\t};\n}\n\nexport { createInvite, createinviteschem };\n"
  },
  {
    "path": "src/server/game/invitesmanager/invitesmanager.ts",
    "content": "// src/server/game/invitesmanager/invitesmanager.ts\n\n/**\n * This script manages our list of all active invites,\n * subscribes and unsubs sockets to and from the invites\n * subscription list,\n * and broadcasts changes out to the clients.\n */\n\nimport type { AuthMemberInfo } from '../../types.js';\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\nimport type { SafeInvite, Invite } from './inviteutility.js';\n\nimport jsutil from '../../../shared/util/jsutil.js';\n\nimport { sendSocketMessage } from '../../socket/sendSocketMessage.js';\nimport { getActiveGameCount } from '../gamemanager/gamecount.js';\nimport {\n\tisInvitePrivate,\n\tsafelyCopyInvite,\n\tisInvitePublic,\n\tmemberInfoEq,\n} from './inviteutility.js';\nimport {\n\tgetInviteSubscribers,\n\taddSocketToInvitesSubs,\n\tremoveSocketFromInvitesSubs,\n\tdoesUserHaveActiveConnection,\n} from './invitessubscribers.js';\n\n//-------------------------------------------------------------------------------------------\n\n/** Whether to log new invite creations/deletions to the console */\nconst printNewInviteCreationsAndDeletions = false;\n\n/** The number of digits generated invite IDs are. */\nconst IDLengthOfInvites = 5;\n\n/** The list of all active invites, including private ones. */\nconst invites: Invite[] = [];\n\n/**\n * Time to allow the client to reconnect after an UNEXPECTED (not purposeful)\n * socket closure before any invite of theirs is deleted!\n */\nconst cushionToDisconnectMillis = 5000; // 5 seconds\n\n/**\n * An object containing usernames for the keys, and setTimeout timer ID's for the values,\n * that represent the timers that are currently active to delete all a player's invites\n * since they've disconnected.\n */\nconst timersMember: Record<number, ReturnType<typeof setTimeout>> = {};\n/**\n * An object containing browser-ids for the keys, and setTimeout timer ID's for the values,\n * that represent the timers that are currently active to delete all a browser's invites\n * since they've disconnected.\n */\nconst timersBrowser: Record<string, ReturnType<typeof setTimeout>> = {};\n\n//-------------------------------------------------------------------------------------------\n\n/**\n * Gets the list of public invites with sensitive information REMOVED (such as browser-ids)\n * DOES NOT include private invites, not even your own, ADD THOSE SEPARATELY.\n */\nfunction getPublicInvitesListSafe(): SafeInvite[] {\n\tconst deepCopiedInvites: SafeInvite[] = [];\n\n\tfor (const invite of invites) {\n\t\tif (isInvitePrivate(invite)) continue; // Remove private invites\n\t\tdeepCopiedInvites.push(safelyCopyInvite(invite)); // Remove sensitive information\n\t}\n\n\treturn deepCopiedInvites;\n}\n\n/**\n * Adds any private invite that belongs to the socket to the provided invites list.\n * @param ws\n * @param copyOfInvitesList - A copy of the invites list, so we don't modify the original\n */\nfunction addMyPrivateInviteToList(\n\tws: CustomWebSocket,\n\tcopyOfInvitesList: SafeInvite[],\n): SafeInvite[] {\n\tfor (const invite of invites) {\n\t\tif (isInvitePublic(invite)) continue; // Next invite, this one isn't private\n\t\tif (!memberInfoEq(ws.metadata.memberInfo, invite.owner)) continue; // Doesn't belong to us\n\t\tconst inviteSafeCopy = safelyCopyInvite(invite); // Makes a deep copy and removes sensitive information\n\t\tcopyOfInvitesList.push(inviteSafeCopy);\n\t}\n\treturn copyOfInvitesList;\n}\n\n// When a PUBLIC invite is added or removed..\n\n/**\n * Call when a public invite is added or deleted.\n * @param ws - The websocket that trigerred this public invites change.\n * @param replyto - The ID of the incoming websocket message that triggered this method\n */\nfunction onPublicInvitesChange(ws?: CustomWebSocket, replyto?: number): void {\n\t// The message that this broadcast is the reply to\n\tbroadcastInvites(ws, replyto);\n}\n\n/**\n * Broadcasts the invites list out to all subbed clients.\n * @param ws - The websocket that trigerred this broadcast. Used to include the replyto id for ONLY THEIR message.\n * @param replyto - The ID of the incoming websocket message that triggered this broadcast\n */\nfunction broadcastInvites(ws?: CustomWebSocket, replyto?: number): void {\n\tconst newInvitesList = getPublicInvitesListSafe();\n\tconst currentGameCount = getActiveGameCount();\n\n\tconst subscribedClients = getInviteSubscribers() as Record<string, CustomWebSocket>;\n\tfor (const subbedSocket of Object.values(subscribedClients)) {\n\t\tconst newInvitesListCopy = jsutil.deepCopyObject(newInvitesList);\n\t\t// Only include the replyto code with the invite list if this socket is\n\t\t// THE SAME SOCKET as the one that triggered this broadcast.\n\t\tconst includedReplyTo = ws === subbedSocket ? replyto : undefined;\n\t\tsendClientInvitesList(subbedSocket, {\n\t\t\tinvitesList: newInvitesListCopy,\n\t\t\tcurrentGameCount,\n\t\t\treplyto: includedReplyTo,\n\t\t});\n\t}\n}\n\n/**\n * Sends the invites list to a specified socket, including any private invites the player owns,\n * and also sends the current active game count.\n * @param ws - The socket of the player to send the invites list to.\n * @param options.invitesList - The list of invites to send. Defaults to the public invites list if not provided. [getPublicInvitesListSafe()]\n * @param options.currentGameCount - The current active game count. Defaults to the current game count if not provided. [getActiveGameCount()]\n * @param options.replyto - The incoming websocket message ID, to include in the reply, if applicable.\n */\nfunction sendClientInvitesList(\n\tws: CustomWebSocket,\n\t{\n\t\tinvitesList = getPublicInvitesListSafe(),\n\t\tcurrentGameCount = getActiveGameCount(),\n\t\treplyto = undefined,\n\t}: { replyto?: number; invitesList?: SafeInvite[]; currentGameCount?: number } = {},\n): void {\n\tinvitesList = addMyPrivateInviteToList(ws, invitesList);\n\tconst message = { invitesList, currentGameCount };\n\tsendSocketMessage(ws, 'invites', 'inviteslist', message, replyto); // In order: socket, sub, action, value\n}\n\n/**\n * Adds a new invite to the list of active invites.\n * Typically called when an invite is created. Sends the new invites list to the socket.\n * @param ws - The socket of the player that created this invite. Used to send them the new invites list with their invite.\n * @param invite - The invite to sdd\n * @param replyto - The incoming websocket message ID, to include in the reply, if applicable\n */\nfunction addInvite(ws: CustomWebSocket, invite: Invite, replyto?: number): void {\n\tinvites.push(invite);\n\n\tif (isInvitePublic(invite)) onPublicInvitesChange(ws, replyto);\n\telse sendClientInvitesList(ws, { replyto }); // Send them the new list after their invite creation!\n\n\tif (printNewInviteCreationsAndDeletions) {\n\t\tif (isInvitePrivate(invite))\n\t\t\tconsole.log(`Created PRIVATE invite for user ${JSON.stringify(invite.owner)}`);\n\t\telse console.log(`Created invite for user ${JSON.stringify(invite.owner)}`);\n\t}\n}\n\n/**\n * Deletes an invite from the list of active invites.\n * Typically called when an invite is canceled. Sends the updated invites list to the socket.\n * @param ws - The socket of the player that canceled this invite. Used to send them the updated invites list.\n * @param invite - The invite object to cancel. Contains details about the invite and its owner.\n * @param index - The index of the invite in the invites array. This is found using {@link getInviteAndIndexByID}.\n * @param options.dontBroadcast - If true, prevents broadcasting the changes to all clients. [false]\n * @param options.replyto - The incoming websocket message ID, to include in the reply, if applicable.\n * @returns true if there was a public invite change\n */\nfunction deleteInviteByIndex(\n\tws: CustomWebSocket,\n\tinvite: Invite,\n\tindex: number,\n\t{\n\t\tdontBroadcast = false,\n\t\treplyto = undefined,\n\t}: { dontBroadcast?: boolean; replyto?: number } = {},\n): boolean {\n\tif (index > invites.length - 1) {\n\t\tconsole.error(\n\t\t\t`Cannot delete invite of index ${index} when the length of our invites list is ${invites.length}!`,\n\t\t);\n\t\treturn false; // No public invite change\n\t}\n\tinvites.splice(index, 1); // Delete the invite\n\n\tif (!dontBroadcast) {\n\t\tif (isInvitePublic(invite)) onPublicInvitesChange(ws, replyto);\n\t\telse sendClientInvitesList(ws, { replyto }); // Send them the new list after their invite cancellation!\n\t}\n\n\tif (printNewInviteCreationsAndDeletions)\n\t\tconsole.log(`Deleted invite for user ${JSON.stringify(invite.owner)}`);\n\n\treturn isInvitePublic(invite); // true if a public invite changed\n}\n\n/**\n * Returns true if the provided socket is the owner of any active invites.\n * If so, they aren't allowed to create more.\n */\nfunction userHasInvite(ws: CustomWebSocket): boolean {\n\tfor (const invite of invites)\n\t\tif (memberInfoEq(ws.metadata.memberInfo, invite.owner)) return true;\n\treturn false; // Player doesn't have an existing invite\n}\n\n/**\n * Tests if any active invite already has the ID provided.\n * This is used during generation of a unique invite id.\n * @returns true if the ID is already in use, false if it's available\n */\nfunction existingInviteHasID(id: string): boolean {\n\tfor (const invite of invites) if (invite.id === id) return true;\n\treturn false;\n}\n\n/**\n * Finds an index by ID, and returns an object: `{ invite, index }`, otherwise undefined.\n * @param id - The invite ID\n * @returns An object: `{ invite, index }`, or undefined if the invite wasn't found.\n */\nfunction getInviteAndIndexByID(id: string): { invite: Invite; index: number } | undefined {\n\tfor (let i = 0; i < invites.length; i++) {\n\t\tif (id === invites[i]!.id) return { invite: invites[i]!, index: i };\n\t}\n\treturn undefined;\n}\n\n//-------------------------------------------------------------------------------------------\n\n/**\n * Returns the first socket subscribed to the invites list that matches the member/browser property.\n * Typically called when you need to inform a player their invite was accepted.\n * @returns The websocket, if found, otherwise undefined.\n */\nfunction findSocketFromOwner(owner: AuthMemberInfo): CustomWebSocket | undefined {\n\t// { member/browser }\n\t// Iterate through all sockets, until you find one that matches the authentication of our invite owner\n\tconst subscribedClients = getInviteSubscribers(); // { id: ws }\n\tfor (const ws of Object.values(subscribedClients)) {\n\t\tif (memberInfoEq(owner, ws.metadata.memberInfo)) return ws;\n\t}\n\n\tconsole.log(\n\t\t`Unable to find a socket subbed to the invites list that belongs to ${JSON.stringify(owner)}!`,\n\t);\n\treturn undefined;\n}\n\n/**\n * Subscribes a socket to the invites subscription list,\n * sends them the list of active invites,\n * and cancels any active timers to delete their invites if\n * their socket was previously closed by a network interruption.\n */\nfunction subToInvitesList(ws: CustomWebSocket): void {\n\tif (ws.metadata.subscriptions.invites) return; // Already subscribed. Happens occasionally\n\n\taddSocketToInvitesSubs(ws);\n\tsendClientInvitesList(ws);\n\tcancelTimerToDeleteUsersInvitesFromNetworkInterruption(ws);\n}\n\n// Set closureNotByChoice to true if you don't immediately want to delete their invite, but say after 5 seconds.\nfunction unsubFromInvitesList(ws: CustomWebSocket, closureNotByChoice?: boolean): void {\n\t// data: { route, action, value, id }\n\tremoveSocketFromInvitesSubs(ws);\n\n\tconst owner = ws.metadata.memberInfo;\n\n\tif (!closureNotByChoice) return deleteUserInvitesIfNotConnected(owner); // Delete their existing invites\n\n\t// The closure WASN'T by choice! Set a 5s timer to give them time to reconnect before deleting their invite!\n\t// console.log(\"Setting a 5-second timer to delete a user's invites!\");\n\tconst timeout = setTimeout(deleteUserInvitesIfNotConnected, cushionToDisconnectMillis, owner);\n\tif (owner.signedIn) timersMember[owner.user_id] = timeout;\n\telse timersBrowser[owner.browser_id] = timeout;\n}\n\n/**\n * Cancels any running timers to delete a users invites from a network interruption.\n * @param ws - The socket of the new invite subscriber\n */\nfunction cancelTimerToDeleteUsersInvitesFromNetworkInterruption(ws: CustomWebSocket): void {\n\tif (ws.metadata.memberInfo.signedIn) {\n\t\tclearTimeout(timersMember[ws.metadata.memberInfo.user_id]);\n\t\tdelete timersMember[ws.metadata.memberInfo.user_id];\n\t} else if (ws.metadata) {\n\t\tclearTimeout(timersBrowser[ws.metadata.memberInfo.browser_id]);\n\t\tdelete timersBrowser[ws.metadata.memberInfo.browser_id];\n\t}\n}\n\n//-------------------------------------------------------------------------------------------\n\n/**\n * Deletes the invite associated with a specific member or browser ID,\n * but only if they don't have an active connection.\n * If the invite belongs to a signed-in member, checks username;\n * otherwise, it checks the browser ID.\n * If any public invite is deleted, it broadcasts the new invites list to all subscribers.\n * @param signedIn - Flag to specify if the invite is for a signed-in member (true) or for a browser ID (false)\n * @param identifier - The identifier of the member or browser (username for signed-in members, browser ID for non-signed-in users)\n */\nfunction deleteUserInvitesIfNotConnected(info: AuthMemberInfo): void {\n\t// Don't delete invite if there is an active connection\n\tconst hasActiveConnection = doesUserHaveActiveConnection(info);\n\tif (hasActiveConnection) {\n\t\t// console.log(`${signedIn ? `Member \"${identifier}\"` : `Browser \"${identifier}\"`} is still connected, not deleting invite.`);\n\t\treturn;\n\t}\n\n\t// Proceed with deleting the invite if not connected\n\tdeleteUsersExistingInvite(info);\n}\n\n/**\n * Deletes the invite associated with a specific member or browser ID.\n * If any public invite is deleted, it optionally broadcasts the new invites list to all subscribers.\n * @param info The info related to a user\n * @param options.broadCastNewInvites - Flag to specify whether to broadcast the new invites list after deleting (defaults to true). [true]\n * @returns Returns true if any public invite was deleted, otherwise false.\n */\nfunction deleteUsersExistingInvite(\n\tinfo: AuthMemberInfo,\n\t{ broadCastNewInvites = true } = {},\n): boolean {\n\tlet deletedPublicInvite = false;\n\tfor (let i = invites.length - 1; i >= 0; i--) {\n\t\tconst invite = invites[i]!;\n\t\tif (!memberInfoEq(info, invite.owner)) continue;\n\t\t// Match! Delete\n\t\tinvites.splice(i, 1); // Delete the invite\n\t\tif (isInvitePublic(invite)) deletedPublicInvite = true;\n\t\tif (printNewInviteCreationsAndDeletions)\n\t\t\tconsole.log(\n\t\t\t\t`${info.signedIn ? `Deleted member's invite. Username: ${info.username}` : `Deleted browser's invite. Browser: ${info.browser_id}`}`,\n\t\t\t);\n\t}\n\n\tif (deletedPublicInvite && broadCastNewInvites) onPublicInvitesChange(); // Broadcast the change if a public invite was deleted\n\treturn deletedPublicInvite;\n}\n\n//-------------------------------------------------------------------------------------------\n\nexport {\n\tsubToInvitesList,\n\tunsubFromInvitesList,\n\texistingInviteHasID,\n\tuserHasInvite,\n\taddInvite,\n\tdeleteInviteByIndex,\n\tgetInviteAndIndexByID,\n\tdeleteUsersExistingInvite,\n\tfindSocketFromOwner,\n\tonPublicInvitesChange,\n\tIDLengthOfInvites,\n};\n"
  },
  {
    "path": "src/server/game/invitesmanager/invitesrouter.ts",
    "content": "// src/server/game/invitesmanager/invitesrouter.ts\n\n/*\n * This script routes all incoming websocket messages\n * with the \"invites\" route to where they need to go.\n */\n\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\n\nimport * as z from 'zod';\n\nimport { createInvite, createinviteschem } from './createinvite.js';\nimport { cancelInvite, cancelinviteschem } from './cancelinvite.js';\nimport { acceptInvite, acceptinviteschem } from './acceptinvite.js';\n\nconst InvitesSchema = z.discriminatedUnion('action', [\n\tz.strictObject({ action: z.literal('createinvite'), value: createinviteschem }),\n\tz.strictObject({ action: z.literal('cancelinvite'), value: cancelinviteschem }),\n\tz.strictObject({ action: z.literal('acceptinvite'), value: acceptinviteschem }),\n]);\ntype InvitesMessage = z.infer<typeof InvitesSchema>;\n\n/**\n * Routes all incoming websocket messages related to invites.\n * @param ws\n * @param contents\n * @param id - The id of the incoming message. This should be included in our response as the `replyto` property.\n * @returns\n */\nfunction routeInvitesMessage(ws: CustomWebSocket, contents: InvitesMessage, id: number): void {\n\t// data: { route, action, value, id }\n\t// Route them according to their action\n\tswitch (contents.action) {\n\t\tcase 'createinvite':\n\t\t\tcreateInvite(ws, contents.value, id);\n\t\t\tbreak;\n\t\tcase 'cancelinvite':\n\t\t\tcancelInvite(ws, contents.value, id);\n\t\t\tbreak;\n\t\tcase 'acceptinvite':\n\t\t\tacceptInvite(ws, contents.value, id);\n\t\t\tbreak;\n\t\tdefault:\n\t\t\tconsole.error(\n\t\t\t\t// @ts-ignore\n\t\t\t\t`UNKNOWN web socket action received in invites route! \"${contents.action}\"`,\n\t\t\t);\n\t}\n}\n\nexport { routeInvitesMessage, InvitesSchema };\n\nexport type {};\n"
  },
  {
    "path": "src/server/game/invitesmanager/invitessubscribers.ts",
    "content": "// src/server/game/invitesmanager/invitessubscribers.ts\n\n/*\n * This script stores the list of websockets currently subscribed\n * to the invites list.\n *\n * On demand, it broadcasts stuff out to the players.\n */\n\nimport type { AuthMemberInfo } from '../../types.js';\nimport type { CustomWebSocket } from '../../socket/socketUtility.js';\n\nimport { memberInfoEq } from './inviteutility.js';\nimport { sendSocketMessage } from '../../socket/sendSocketMessage.js';\n\n/**\n * List of clients currently subscribed to invites list events, with their\n * socket id for the keys, and their socket for the value.\n */\nconst subscribedClients: Record<string, CustomWebSocket> = {}; // { id: ws }\n\nconst printSubscriberCount = false;\n\n/**\n * Returns the object containing all sockets currently subscribed to the invites list,\n * with their socket id for the keys, and their socket for the value.\n */\nfunction getInviteSubscribers(): typeof subscribedClients {\n\treturn subscribedClients;\n}\n\n/**\n * Broadcasts a message to all invites subscribers.\n * @param action - The action of the socket message (i.e. \"inviteslist\")\n * @param message - The message contents\n */\nfunction broadcastToAllInviteSubs(action: string, message: any): void {\n\tfor (const ws of Object.values(subscribedClients)) {\n\t\tsendSocketMessage(ws, 'invites', action, message); // In order: socket, sub, action, value\n\t}\n}\n\n/**\n * Adds a new socket to the invite subscriber list.\n */\nfunction addSocketToInvitesSubs(ws: CustomWebSocket): void {\n\tconst socketID = ws.metadata.id;\n\tif (subscribedClients[socketID])\n\t\treturn console.error('Cannot sub socket to invites list because they already are!');\n\n\tsubscribedClients[socketID] = ws;\n\tws.metadata.subscriptions.invites = true;\n\n\tif (printSubscriberCount)\n\t\tconsole.log(`Invites subscriber count: ${Object.keys(subscribedClients).length}`);\n}\n\n/**\n * Removes a socket from the invite subscriber list.\n * DOES NOT delete any of their existing invites! That should be done before.\n */\nfunction removeSocketFromInvitesSubs(ws: CustomWebSocket): void {\n\tif (!ws)\n\t\treturn console.error(\"Can't remove socket from invites subs list because it's undefined!\");\n\n\tconst socketID = ws.metadata.id;\n\tif (!subscribedClients[socketID]) return; // Cannot unsub socket from invites list because they aren't subbed.\n\n\tdelete subscribedClients[socketID];\n\tdelete ws.metadata.subscriptions.invites;\n\n\tif (printSubscriberCount)\n\t\tconsole.log(`Invites subscriber count: ${Object.keys(subscribedClients).length}`);\n}\n\n/**\n * Checks if a member or browser ID has at least one active connection.\n * @returns true if the member or browser ID has at least one active connection, false otherwise.\n */\nfunction doesUserHaveActiveConnection(info: AuthMemberInfo): boolean {\n\treturn Object.values(subscribedClients).some((ws) => {\n\t\treturn memberInfoEq(ws.metadata.memberInfo, info);\n\t});\n}\n\nexport {\n\tgetInviteSubscribers,\n\tbroadcastToAllInviteSubs,\n\taddSocketToInvitesSubs,\n\tremoveSocketFromInvitesSubs,\n\tdoesUserHaveActiveConnection,\n};\n"
  },
  {
    "path": "src/server/game/invitesmanager/inviteutility.ts",
    "content": "// src/server/game/invitesmanager/inviteutility.ts\n\n/*\n * This script stores utility methods for working\n * with single invites, not multiple\n */\n\nimport type { Player } from '../../../shared/chess/util/typeutil.js';\nimport type { VariantCode } from '../../../shared/chess/variants/variantdictionary.js';\nimport type { AuthMemberInfo } from '../../types.js';\nimport type { ServerUsernameContainer, TimeControl } from '../../../shared/types.js';\n\nimport jsutil from '../../../shared/util/jsutil.js';\n\n// Type Definitions\n\n/** A lobby game invite. */\ninterface Invite extends SafeInvite {\n\t/** Contains the identifier of the owner of the invite, whether a member or browser. */\n\towner: AuthMemberInfo;\n}\n\n/**\n * All properties of an invite that is safe to send to clients.\n * Doesn't contain sensitive information such as browser-id cookies.\n */\ninterface SafeInvite {\n\tid: string; // A unique identifier, containing lowercase letters a-z and numbers 0-9.\n\tusernamecontainer: ServerUsernameContainer; // The type of the owner (guest/player), their username, and elo if applicable.\n\ttag: string; // Used to verify if an invite is your own.\n\tvariant: VariantCode;\n\tclock: TimeControl;\n\tcolor: Player | null;\n\trated: 'casual' | 'rated';\n\tpublicity: 'public' | 'private';\n}\n\n//-------------------------------------------------------------------------------------------\n\n/**\n * Returns true if the invite is private\n */\nfunction isInvitePrivate(invite: Invite): boolean {\n\treturn invite.publicity === 'private';\n}\n\n/**\n * Returns true if the invite is public\n */\nfunction isInvitePublic(invite: Invite): boolean {\n\treturn invite.publicity === 'public';\n}\n\n/**\n * Removes sensitive data such as their browser-id.\n * Returns a deep copy of the original invite.\n */\nfunction makeInviteSafe(invite: Invite): SafeInvite {\n\treturn {\n\t\tid: invite.id,\n\t\tusernamecontainer: jsutil.deepCopyObject(invite.usernamecontainer),\n\t\ttag: invite.tag,\n\t\tvariant: invite.variant,\n\t\tclock: invite.clock,\n\t\tcolor: invite.color,\n\t\trated: invite.rated,\n\t\tpublicity: invite.publicity,\n\t};\n}\n\n/**\n * Makes a deep copy of provided invite, and\n * removes sensitive data such as their browser-id.\n */\nfunction safelyCopyInvite(invite: Invite): SafeInvite {\n\tconst inviteDeepCopy = jsutil.deepCopyObject(invite);\n\treturn makeInviteSafe(inviteDeepCopy);\n}\n\n/** Compares two MemberInfo objects to see if they are the same person or not. */\nfunction memberInfoEq(u1: AuthMemberInfo, u2: AuthMemberInfo): boolean {\n\tif (u1.signedIn) {\n\t\tif (!u2.signedIn) return false;\n\t\treturn u1.user_id === u2.user_id;\n\t} else if (u2.signedIn)\n\t\treturn false; // This ensures if they have the same browser-id, but mi2 is signed in, they are not equal.\n\telse return u1.browser_id === u2.browser_id;\n}\n\n//-------------------------------------------------------------------------------------------\n\nexport type { Invite, SafeInvite };\n\nexport { isInvitePrivate, isInvitePublic, safelyCopyInvite, memberInfoEq };\n"
  },
  {
    "path": "src/server/game/servermetadatautil.ts",
    "content": "// src/server/game/servermetadatautil.ts\n\n/**\n * Server-side helpers for building ICN game metadata.\n */\n\nimport type { VariantCode } from '../../shared/chess/variants/variantdictionary.js';\nimport type { MetaData, TimeControl } from '../../shared/types.js';\n\nimport uuid from '../../shared/util/uuid.js';\nimport variant from '../../shared/chess/variants/variant.js';\nimport timeutil from '../../shared/util/timeutil.js';\n\n// Types --------------------------------------------------------------------------\n\n/** Per-player inputs for {@link buildGameMetadata}. */\nexport interface PlayerMetaInput {\n\t/** Display name — the player's username, or {@link GUEST_NAME_ICN_METADATA} for unauthenticated players. */\n\tname: string;\n\t/** User ID, present only for signed-in players. */\n\tid?: number;\n\t/** Already-formatted elo string (e.g. `'1434'` or `'1500?'`), present only for signed-in players. */\n\telo?: string;\n}\n\n// Functions -----------------------------------------------------------------------\n\n/**\n * Builds a {@link MetaData} object from the common game properties.\n * Metadata is always in English.\n * @param rated - Whether the game is rated.\n * @param variantCode - The variant code (NOT the English translation).\n * @param clock - The time-control string.\n * @param utcTimestamp - The epoch-ms timestamp used for the `UTCDate`/`UTCTime` fields.\n * @param white - Identity information for the White player.\n * @param black - Identity information for the Black player.\n */\nfunction buildGameMetadata(\n\trated: boolean,\n\tvariantCode: VariantCode,\n\tclock: TimeControl,\n\tutcTimestamp: number,\n\twhite: PlayerMetaInput,\n\tblack: PlayerMetaInput,\n): MetaData {\n\tconst variantEnglishName = variant.getVariantName(variantCode);\n\tconst RatedOrCasual = rated ? 'Rated' : 'Casual';\n\tconst { UTCDate, UTCTime } = timeutil.convertTimestampToUTCDateUTCTime(utcTimestamp);\n\n\tconst gameMetadata: MetaData = {\n\t\tEvent: `${RatedOrCasual} ${variantEnglishName} infinite chess game`,\n\t\tSite: 'https://www.infinitechess.org/',\n\t\tRound: '-',\n\t\tVariant: variantEnglishName,\n\t\tWhite: white.name,\n\t\tBlack: black.name,\n\t\tTimeControl: clock,\n\t\tUTCDate,\n\t\tUTCTime,\n\t};\n\tif (white.id !== undefined) {\n\t\tgameMetadata.WhiteID = uuid.base10ToBase62(white.id);\n\t\tif (white.elo !== undefined) gameMetadata.WhiteElo = white.elo;\n\t}\n\tif (black.id !== undefined) {\n\t\tgameMetadata.BlackID = uuid.base10ToBase62(black.id);\n\t\tif (black.elo !== undefined) gameMetadata.BlackElo = black.elo;\n\t}\n\treturn gameMetadata;\n}\n\n// Exports -----------------------------------------------------------------------\n\nexport default {\n\tbuildGameMetadata,\n};\n"
  },
  {
    "path": "src/server/game/statlogger.ts",
    "content": "// src/server/game/statlogger.ts\n\nimport fs from 'fs';\nimport path from 'path';\nimport { fileURLToPath } from 'node:url';\n\nimport timeutil from '../../shared/util/timeutil.js';\n\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\nimport { readFile, writeFile } from '../utility/lockFile.js';\n\nimport 'dotenv/config'; // Imports all properties of process.env, if it exists\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\nimport type { ServerGame } from './gamemanager/gameutility.js';\n\nconst statsPath = path.resolve('database/stats.json');\n(function ensureStatsFileExists(): void {\n\tif (fs.existsSync(statsPath)) return; // Already exists\n\n\tconst content = JSON.stringify(\n\t\t{\n\t\t\tgamesPlayed: {\n\t\t\t\tbyDay: {},\n\t\t\t\tbyMonth: {},\n\t\t\t},\n\t\t\tmoveCount: {},\n\t\t},\n\t\tnull,\n\t\t2,\n\t);\n\n\tfs.mkdirSync(path.dirname(statsPath), { recursive: true });\n\tfs.writeFileSync(statsPath, content);\n\n\tconsole.log('Generated stats file');\n})();\n\nlet stats: {\n\tmoveCount: Record<string, number>;\n\tgamesPlayed: {\n\t\tbyDay: Record<string, number>;\n\t\tbyMonth: Record<string, Record<string, number>>;\n\t\tallTime: Record<string, number>;\n\t};\n};\ntry {\n\tstats = await readFile('database/stats.json');\n} catch (error: unknown) {\n\tif (process.env['VITEST']) {\n\t\tconsole.warn('Mocking stats.json for test environment');\n\t\tstats = {\n\t\t\tmoveCount: {},\n\t\t\tgamesPlayed: {\n\t\t\t\tbyDay: {},\n\t\t\t\tbyMonth: {},\n\t\t\t\tallTime: {},\n\t\t\t},\n\t\t};\n\t} else {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tthrow new Error('Unable to read stats.json on startup: ' + message);\n\t}\n}\n\n/**\n * Saves and increments the stats for the played variant\n * @param servergame - The game to log\n * @returns\n */\nfunction logGame({ basegame, match }: ServerGame): void {\n\t// Only log the game if at least 2 moves were played! (resignable)\n\t// Black-moves-first games are logged if at least 1 move is played!\n\tif (basegame.moves.length < 2) return;\n\n\t// What is the current month?\n\tconst month = timeutil.getCurrentMonth(); // 'yyyy-mm'\n\t// What is the current day?\n\tconst day = timeutil.getCurrentDay(); // 'yyyy-mm-dd'\n\t// What variant was played?\n\tconst variant = match.variant;\n\n\t// Now record the number of moves played\n\n\tconst plyCount = basegame.moves.length;\n\tif (stats.moveCount['all'] === undefined) stats.moveCount['all'] = 0;\n\tstats.moveCount['all'] += plyCount;\n\tif (stats.moveCount[variant] === undefined) stats.moveCount[variant] = 0;\n\tstats.moveCount[variant] += plyCount;\n\tif (stats.moveCount[month] === undefined) stats.moveCount[month] = 0;\n\tstats.moveCount[month] += plyCount;\n\n\t// Increment the games played today\n\tif (stats.gamesPlayed.byDay[day] === undefined) stats.gamesPlayed.byDay[day] = 1;\n\telse stats.gamesPlayed.byDay[day]++;\n\n\t// @ts-ignore\n\tincrementMonthsGamesPlayed(stats.gamesPlayed, 'allTime', variant);\n\tincrementMonthsGamesPlayed(stats.gamesPlayed.byMonth, month, variant);\n\n\t//----------------------------------------------------------\n\n\tvoid saveStats(); // Saves stats in the database.\n}\n\nfunction incrementMonthsGamesPlayed(\n\tparent: Record<string, Record<string, number>>,\n\tmonth: string,\n\tvariant: string,\n): void {\n\t// allTime / yyyy-mm=\n\t// Does this month's property exist yet?\n\tif (parent[month] === undefined) parent[month] = {};\n\n\t// Increment this month's all-variants by 1\n\tif (parent[month]['all'] === undefined) parent[month]['all'] = 1;\n\telse parent[month]['all']++;\n\n\t// Increment this month's this variant by 1\n\tif (parent[month][variant] === undefined) parent[month][variant] = 1;\n\telse parent[month][variant]++;\n}\n\n// Sometimes this causes a file-already-locked error if multiple games are deleted at once.\nasync function saveStats(): Promise<void> {\n\t// Async function\n\ttry {\n\t\tawait writeFile(path.join(__dirname, '..', '..', '..', 'database', 'stats.json'), stats);\n\t} catch (e) {\n\t\tconst errMsg =\n\t\t\t`Failed to lock/write stats.json after logging game! Didn't save the new stats, but it should still be accurate in memory.` +\n\t\t\t(e instanceof Error ? e.message : String(e));\n\t\tvoid logEventsAndPrint(errMsg, 'errLog.txt');\n\t}\n}\n\nexport default {\n\tlogGame,\n};\n"
  },
  {
    "path": "src/server/game/timecontrol.ts",
    "content": "// src/server/game/timecontrol.ts\n\n/**\n * Stores valid time controls for lobby invites.\n */\n\nimport type { TimeControl } from '../../shared/types.js';\n\n/** These are the allowed time controls in production. */\nconst validTimeControls = [\n\t'-',\n\t'60+2',\n\t'120+2',\n\t'180+2',\n\t'300+2',\n\t'480+3',\n\t'600+4',\n\t'600+6',\n\t'720+5',\n\t'900+6',\n\t'1200+8',\n\t'1500+10',\n\t'1800+15',\n\t'2400+20',\n];\n/** These are only allowed in development. */\nconst devTimeControls = ['15+2'];\n\n/** Whether the given time control is valid. */\nfunction isValid(time_control: TimeControl): boolean {\n\treturn (\n\t\tvalidTimeControls.includes(time_control) ||\n\t\t(process.env['NODE_ENV'] === 'development' && devTimeControls.includes(time_control))\n\t);\n}\n\nexport default {\n\tisValid,\n};\n"
  },
  {
    "path": "src/server/middleware/banned.ts",
    "content": "// src/server/middleware/banned.ts\n\n/**\n * BLACKLISTED EMAILS are now handled in the email_blacklist database table!\n */\n\nimport fs from 'fs';\nimport path from 'path';\n\nimport { readFile } from '../utility/lockFile.js';\n\nconst bannedPath = path.resolve('database/banned.json');\n\nensureBannedFileExists: {\n\tif (fs.existsSync(bannedPath)) break ensureBannedFileExists; // Already exists\n\n\tconst content = JSON.stringify(\n\t\t{\n\t\t\temails: {},\n\t\t\tIPs: {},\n\t\t\t'browser-ids': {},\n\t\t},\n\t\tnull,\n\t\t2,\n\t);\n\n\tfs.mkdirSync(path.dirname(bannedPath), { recursive: true });\n\tfs.writeFileSync(bannedPath, content);\n\n\tconsole.log('Generated banned file');\n}\n\nlet bannedJSON: {\n\tIPs: Record<string, any>;\n\temails: Record<string, any>;\n\t'browser-ids': Record<string, any>;\n};\ntry {\n\tbannedJSON = await readFile(bannedPath);\n} catch (error: unknown) {\n\tif (process.env['VITEST']) {\n\t\tconsole.warn('Mocking banned.json for test environment');\n\t\tbannedJSON = {\n\t\t\tIPs: {},\n\t\t\temails: {},\n\t\t\t'browser-ids': {},\n\t\t};\n\t} else {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tthrow new Error('Unable to read banned.json on startup: ' + message);\n\t}\n}\n// EMAIL BANS are now handled in the email_blacklist database table!\n// function isEmailBanned(email: string): boolean {\n// \tconst emailLowercase = email.toLowerCase();\n// \treturn bannedJSON.emails[emailLowercase] !== undefined;\n// }\n\nfunction isIPBanned(ip: string): boolean {\n\treturn bannedJSON.IPs[ip] !== undefined;\n}\n\nfunction isBrowserIDBanned(browserID: string): boolean {\n\treturn bannedJSON['browser-ids'][browserID] !== undefined;\n}\n\nexport { isIPBanned, isBrowserIDBanned };\n"
  },
  {
    "path": "src/server/middleware/errorHandler.ts",
    "content": "// src/server/middleware/errorHandler.ts\n\nimport type { Request, Response } from 'express';\n\nimport { logEventsAndPrint } from './logEvents.js';\nimport { getTranslationForReq } from '../utility/translate.js';\n\nfunction errorHandler(err: Error, req: Request, res: Response, _next: Function): void {\n\t// Catches errors from for example the body parser, which can throw if the body is too large.\n\t// This needs to be handled itself, as i18next was never defined.\n\tif ('status' in err) {\n\t\tconst status = (err as Error & { status: number }).status;\n\t\tif (status >= 400 && status < 500) {\n\t\t\tres.status(status).json({ error: err.message || 'Bad request' });\n\t\t\treturn;\n\t\t}\n\t}\n\n\ttry {\n\t\tconst errMessage = `${err.stack}`;\n\t\tlogEventsAndPrint(errMessage, 'errLog.txt');\n\n\t\t// This sends back to the browser the error, instead of the ENTIRE stack which is PRIVATE.\n\t\tconst messageForClient = getTranslationForReq('server.javascript.ws-server_error', req);\n\t\tres.status(500).send(messageForClient); // 500: Server error\n\t} catch (error: unknown) {\n\t\t// Last line of defense if an error occurs in the middleware error catcher\n\t\tconst errMessage = error instanceof Error ? error.stack : String(error);\n\t\tconsole.error('Critical error in errorHandler middleware:', errMessage);\n\t\tres.status(500).send('Critical server error.');\n\t}\n}\n\nexport default errorHandler;\n"
  },
  {
    "path": "src/server/middleware/logEvents.ts",
    "content": "// src/server/middleware/logEvents.ts\n\nimport type { IncomingMessage } from 'node:http';\nimport type { Request, Response } from 'express';\n\nimport fs from 'fs';\nimport path from 'path';\nimport { format } from 'date-fns';\nimport { v4 as uuid } from 'uuid';\nimport { promises as fsPromises } from 'fs';\n\nimport paths from '../config/paths.js';\nimport { getClientIP } from '../utility/IP.js';\nimport socketUtility, { CustomWebSocket } from '../socket/socketUtility.js';\n\nconst giveLoggedItemsUUID = false;\n\n/**\n * Logs the provided message by appending a line to the end of the specified log file.\n * @param message - The message to log.\n * @param logName - The name of the log file.\n */\nasync function logEvents(message: string, logName: string): Promise<void> {\n\tif (typeof message !== 'string')\n\t\treturn console.trace('Cannot log message when it is not a string.');\n\tif (!logName) return console.trace('Log name MUST be provided when logging an event!');\n\n\tconst dateTime = format(new Date(), 'yyyy/MM/dd  HH:mm:ss');\n\tconst logItem = giveLoggedItemsUUID\n\t\t? `${dateTime}   ${uuid()}   ${message}\\n` // With unique UUID\n\t\t: `${dateTime}   ${message}\\n`;\n\n\ttry {\n\t\tfs.mkdirSync(paths.LOGS_DIR, { recursive: true });\n\t\tawait fsPromises.appendFile(path.join(paths.LOGS_DIR, logName), logItem);\n\t} catch (err: unknown) {\n\t\tif (err instanceof Error) console.error(`Error logging event: ${err.message}`);\n\t\telse console.error('Error logging event:', err);\n\t}\n}\n\n/**\n * Logs the provided message by appending a line to the end of the specified log file,\n * and prints it to the console as an error.\n * @param message - The message to log.\n * @param logName - The name of the log file.\n */\nasync function logEventsAndPrint(message: string, logName: string): Promise<void> {\n\tif (logName === 'errLog.txt') console.error(message);\n\telse console.log(message); // Prevents non error logs from going to PM2's error logs.\n\n\tawait logEvents(message, logName);\n}\n\n/** Middleware that logs the incoming request, then calls `next()`. */\nfunction reqLogger(req: Request, res: Response, next: () => void): void {\n\tconst clientIP = getClientIP(req) || 'Unknown ip';\n\n\tconst origin = req.headers.origin || 'Unknown origin';\n\n\t// Redact sensitive tokens that appear in URL paths so they are never written to log files.\n\tconst sanitizedUrl = req.url\n\t\t.replace(/(\\/reset-password\\/)([^?#/]+)/, '$1[REDACTED]')\n\t\t.replace(/(\\/verify\\/[^/]+\\/)([^?#/]+)/, '$1[REDACTED]');\n\n\tlet logThis = `${origin}   ${clientIP}   ${req.method}   ${sanitizedUrl}   ${req.headers['user-agent']}`;\n\t// Delete passwords from incoming form data\n\tlet sensoredBody;\n\tif (JSON.stringify(req.body) !== '{}') {\n\t\t// Not an empty object\n\t\tsensoredBody = { ...req.body };\n\t\tdelete sensoredBody.password;\n\t\tdelete sensoredBody.username; // Since IP's are logged with each request, If you know a deleted account's username, it can be indirectly traced to their IP if we don't delete them here.\n\t\tdelete sensoredBody.email;\n\t\tlogThis += `\\n${JSON.stringify(sensoredBody)}`;\n\t}\n\n\tlogEvents(logThis, 'reqLog.txt');\n\n\tnext(); // Continue to next middleware\n}\n\n/**\n * Logs websocket connection upgrade requests into `wsInLog.txt`\n * @param req - The request object\n * @param ws - The websocket object\n */\nfunction logWebsocketStart(req: IncomingMessage, ws: CustomWebSocket): void {\n\tconst socketID = ws.metadata.id;\n\tconst stringifiedSocketMetadata = socketUtility.stringifySocketMetadata(ws);\n\tconst userAgent = req.headers['user-agent'];\n\t// const userAgent = ws.metadata.userAgent;\n\tconst logThis = `Opened socket of ID \"${socketID}\": ${stringifiedSocketMetadata}   User agent: ${userAgent}`;\n\tlogEvents(logThis, 'wsInLog.txt');\n}\n\n/**\n * Logs incoming websocket messages into `wsInLog.txt`\n * @param ws - The websocket object\n * @param messageData - The raw data of the incoming message, as a string\n */\nfunction logReqWebsocketIn(ws: CustomWebSocket, messageData: string): void {\n\tconst socketID = ws.metadata.id;\n\tconst logThis = `From socket of ID \"${socketID}\":   ${messageData}`;\n\tlogEvents(logThis, 'wsInLog.txt');\n}\n\n/**\n * Logs outgoing websocket messages into `wsOutLog.txt`\n * @param ws - The websocket object\n * @param messageData - The raw data of the outgoing message, as a string\n */\nfunction logReqWebsocketOut(ws: CustomWebSocket, messageData: string): void {\n\tconst socketID = ws.metadata.id;\n\tconst logThis = `To socket of ID \"${socketID}\":   ${messageData}`;\n\tlogEvents(logThis, 'wsOutLog.txt');\n}\n\nexport {\n\tlogEvents,\n\tlogEventsAndPrint,\n\treqLogger,\n\tlogWebsocketStart,\n\tlogReqWebsocketIn,\n\tlogReqWebsocketOut,\n};\n"
  },
  {
    "path": "src/server/middleware/middleware.ts",
    "content": "// src/server/middleware/middleware.ts\n\n/**\n * This module configures the middleware waterfall of our server\n */\n\nimport type { Express, Request, Response, NextFunction } from 'express';\n\nimport path from 'path';\nimport cors from 'cors';\nimport helmet from 'helmet';\nimport express from 'express';\nimport i18next from 'i18next';\nimport { handle } from 'i18next-http-middleware';\nimport cookieParser from 'cookie-parser';\nimport { fileURLToPath } from 'node:url';\n\nimport send404 from './send404.js';\nimport errorHandler from './errorHandler.js';\nimport { reqLogger } from './logEvents.js';\nimport { verifyJWT } from './verifyJWT.js';\nimport { rateLimit } from './rateLimit.js';\nimport EditorSavesAPI from '../api/EditorSavesAPI.js';\nimport secureRedirect from './secureRedirect.js';\nimport { rootRouter } from '../routes/root.js';\nimport { handleLogin } from '../controllers/loginController.js';\nimport { handleLogout } from '../controllers/logoutController.js';\nimport { verifyAccount } from '../controllers/verifyAccountController.js';\nimport { getMemberData } from '../api/MemberAPI.js';\nimport { removeAccount } from '../controllers/deleteAccountController.js';\nimport { processCommand } from '../api/AdminPanel.js';\nimport { getContributors } from '../api/GitHub.js';\nimport { handleSesWebhook } from '../controllers/awsWebhook.js';\nimport { accessTokenIssuer } from '../controllers/authenticationTokens/accessTokenIssuer.js';\nimport { getLeaderboardData } from '../api/LeaderboardAPI.js';\nimport { requestConfirmEmail } from '../controllers/emailController.js';\nimport { handlePrepareRestart } from '../controllers/deployController.js';\nimport { assignOrRenewBrowserID } from '../controllers/browserIDManager.js';\nimport { postPrefs, setPrefsCookie } from '../api/Prefs.js';\nimport { postCheckmateBeaten, setPracticeProgressCookie } from '../api/PracticeProgress.js';\nimport { getUnreadNewsCount, getUnreadNewsDatesEndpoint, markNewsAsRead } from '../api/NewsAPI.js';\nimport {\n\thandleForgotPasswordRequest,\n\thandleResetPassword,\n} from '../controllers/passwordResetController.js';\nimport {\n\tcheckEmailValidity,\n\tcheckUsernameAvailable,\n\tcreateNewMember,\n} from '../controllers/createAccountController.js';\nimport {\n\tcreateAccountLimiter,\n\tresendAccountVerificationLimiter,\n\tforgotPasswordLimiter,\n\teditorSaveLimiter,\n\teditorLoadLimiter,\n} from './rateLimiters.js';\n\n// Constants -------------------------------------------------------------------------\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\n// Functions -------------------------------------------------------------------------\n\n/**\n * Configures the Middleware Waterfall\n *\n * app.use adds the provided function to EVERY SINGLE router and incoming connection.\n * Each middleware function must call next() to go to the next middleware.\n * Connections that do not pass one middleware will not continue.\n *\n * @param app - The express application instance.\n */\nexport function configureMiddleware(app: Express): void {\n\t// Note: requests that are rate limited will not be logged, to mitigate slow-down during a DDOS.\n\tapp.use(rateLimit);\n\n\t// This allows us to retrieve json-received-data as a parameter/data!\n\t// The logger can't log the request body without this.\n\t// This also ensures all requests with content-type \"application/json\" have a body as an object, even if empty.\n\t// Increased to 2mb to support large editor position saves (ICN data up to 1MB)\n\tapp.use(express.json({ limit: '2mb' })); // Limit the size to avoid parsing excessively large objects. Beyond this should throw an error caught by our error handling middleware.\n\n\tapp.use(reqLogger); // Log the request\n\n\t// Security Headers & HTTPS Enforcement\n\tapp.use(secureRedirect); // Redirects http to secure https\n\tapp.use(\n\t\thelmet({\n\t\t\tcontentSecurityPolicy: {\n\t\t\t\tdirectives: {\n\t\t\t\t\tdefaultSrc: [\"'self'\"],\n\t\t\t\t\tscriptSrc: [\n\t\t\t\t\t\t\"'self'\",\n\t\t\t\t\t\t\"'unsafe-inline'\",\n\t\t\t\t\t\t\"'wasm-unsafe-eval'\",\n\t\t\t\t\t\t'https://static.cloudflareinsights.com',\n\t\t\t\t\t], // Allows inline scripts\n\t\t\t\t\tscriptSrcAttr: [\"'self'\", \"'unsafe-inline'\"], // Allows inline event handlers\n\t\t\t\t\tobjectSrc: [\"'none'\"],\n\t\t\t\t\tframeSrc: [\"'self'\", 'https://www.youtube.com'],\n\t\t\t\t\timgSrc: [\"'self'\", 'data:', 'https://avatars.githubusercontent.com', 'blob:'],\n\t\t\t\t},\n\t\t\t},\n\t\t}),\n\t);\n\n\t// Path Traversal Protection, and error protection from malformed URLs\n\tapp.use((req: Request, res: Response, next: NextFunction) => {\n\t\ttry {\n\t\t\tconst decoded = decodeURIComponent(req.url);\n\n\t\t\t// Check 1: Raw encoded patterns (before decoding)\n\t\t\tconst encodedPatterns = /(%2e%2e|%252e|%%32%65)/gi;\n\t\t\tif (encodedPatterns.test(req.url)) {\n\t\t\t\t// console.warn('Blocked traversal:', req.url);\n\t\t\t\t// console.warn('Decoded URL:', decoded);\n\t\t\t\tres.status(403).send('Forbidden');\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check 2: Decoded path segments\n\t\t\tconst segments = decoded.split(/[\\\\/]/);\n\t\t\tif (segments.includes('..')) {\n\t\t\t\t// Console warn both the decoded and the original URL\n\t\t\t\t// console.warn('Blocked traversal:', req.url);\n\t\t\t\t// console.warn('Decoded URL:', decoded);\n\t\t\t\tres.status(403).send('Forbidden');\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tnext();\n\t\t} catch (_err) {\n\t\t\t// console.warn('Blocked invalid URL encoding:', req.url);\n\t\t\tres.status(400).send('Invalid URL encoding');\n\t\t}\n\t});\n\n\t/** This sets req.i18n, and req.i18n.resolvedLanguage */\n\tapp.use(handle(i18next, { removeLngFromUrl: false }));\n\n\tapp.use(cors());\n\n\t// CUSTOM express.json() NEEDED because AWS SNS sends text/plain instead of application/json! But it is still parsable as JSON.\n\tconst awsParser = express.json({\n\t\tlimit: '50kb',\n\t\ttype: ['text/plain', 'application/json'],\n\t});\n\t// Webhook endpoint for AWS Simple Email Service (SES) to notify us of bounces and complaints\n\tapp.post('/webhooks/ses', awsParser, handleSesWebhook);\n\n\t/**\n\t * Allow processing urlencoded (FORM) data so that we can retrieve it as a parameter/variable.\n\t * (e.g. when the content-type header is 'application/x-www-form-urlencoded')\n\t */\n\tapp.use(express.urlencoded({ limit: '10kb', extended: false })); // Limit the size to avoid parsing excessively large objects\n\n\t// Sets the req.cookies property\n\tapp.use(cookieParser());\n\n\t// Serve public assets. (e.g. css, scripts, images, audio)\n\tapp.use(express.static(path.join(__dirname, '../../client'))); // Serve public assets\n\n\t// Every request beyond this point will not be for a resource like a script or image,\n\t// but it will be a request for an HTML or API\n\n\t// Directory required for the ACME (Automatic Certificate Management Environment) protocol used by Certbot to validate your domain ownership.\n\tapp.use(\n\t\t'/.well-known/acme-challenge',\n\t\texpress.static(path.join(__dirname, '../../../cert/.well-known/acme-challenge')),\n\t);\n\n\t// This sets the 'browser-id' cookie on every request for an HTML file\n\tapp.use(assignOrRenewBrowserID);\n\t// This sets the user 'preferences' cookie on every request for an HTML file\n\tapp.use(setPrefsCookie);\n\t// This sets the user 'checkmates_beaten' cookie on every request for an HTML file\n\tapp.use(setPracticeProgressCookie);\n\n\t// Provide a route\n\n\t// Root router\n\tapp.use('/', rootRouter); // Contains every html page.\n\n\t// Account router\n\tapp.post('/createaccount', createAccountLimiter, createNewMember); // \"/createaccount\" POST request\n\tapp.get('/createaccount/username/:username', checkUsernameAvailable);\n\tapp.get('/createaccount/email/:email', checkEmailValidity);\n\n\t// Member router\n\tapp.delete('/member/:member/delete', removeAccount);\n\n\tapp.post('/reset-password', handleResetPassword);\n\n\t// API --------------------------------------------------------------------\n\n\tapp.post('/auth', handleLogin); // Login fetch POST request\n\n\tapp.post('/setlanguage', (req: Request, res: Response) => {\n\t\t// Language cookie setter POST request\n\t\tres.cookie('i18next', req.i18n.resolvedLanguage);\n\t\tres.send(''); // Doesn't work without this for some reason\n\t});\n\n\tapp.get('/api/contributors', (_req: Request, res: Response) => {\n\t\tconst contributors = getContributors();\n\t\tres.send(JSON.stringify(contributors));\n\t});\n\n\t// Endpoint called by the GitHub Actions deploy workflow before pm2 reload\n\tapp.post('/api/prepare-restart', handlePrepareRestart);\n\n\t// Token Authenticator -------------------------------------------------------\n\n\t/**\n\t * Sets the req.memberInfo properties if they have an authorization\n\t * header (contains access token) or refresh cookie (contains refresh token).\n\t * Don't send unauthorized people private stuff without the proper role.\n\t *\n\t * PLACE AS LOW AS YOU CAN, BUT ABOVE ALL ROUTES THAT NEED AUTHENTICATION!!\n\t * This requires database requests.\n\t */\n\tapp.use(verifyJWT);\n\n\t// ROUTES THAT NEED AUTHENTICATION ------------------------------------------------------\n\n\tapp.post('/api/get-access-token', accessTokenIssuer);\n\n\tapp.post('/api/set-preferences', postPrefs);\n\n\tapp.post('/api/update-checkmatelist', postCheckmateBeaten);\n\n\t// News routes\n\tapp.get('/api/news/unread-count', getUnreadNewsCount);\n\tapp.get('/api/news/unread-dates', getUnreadNewsDatesEndpoint);\n\tapp.post('/api/news/mark-read', markNewsAsRead);\n\n\t// Editor saves routes\n\tapp.get('/api/editor-saves', EditorSavesAPI.getSavedPositions);\n\tapp.post('/api/editor-saves', editorSaveLimiter, EditorSavesAPI.savePosition);\n\tapp.get('/api/editor-saves/:position_name', editorLoadLimiter, EditorSavesAPI.getPosition);\n\tapp.delete('/api/editor-saves/:position_name', EditorSavesAPI.deletePosition);\n\n\tapp.get('/logout', handleLogout);\n\n\tapp.get('/command/:command', processCommand);\n\n\t// Member routes that do require authentication\n\tapp.get('/member/:member/data', getMemberData);\n\tapp.post('/member/:member/send-email', resendAccountVerificationLimiter, requestConfirmEmail);\n\tapp.get('/verify/:member/:code', verifyAccount);\n\n\t// Leaderboard router\n\tapp.get(\n\t\t'/leaderboard/top/:leaderboard_id/:start_rank/:n_players/:find_requester_rank',\n\t\tgetLeaderboardData,\n\t);\n\n\tapp.post('/forgot-password', forgotPasswordLimiter, handleForgotPasswordRequest);\n\n\t// Last Resort 404 and Error Handler ----------------------------------------------------\n\n\t// If we've reached this point, send our 404 page.\n\tapp.all('*', send404);\n\n\t// Custom error handling. Comes after 404.\n\tapp.use(errorHandler);\n}\n"
  },
  {
    "path": "src/server/middleware/rateLimit.ts",
    "content": "// src/server/middleware/rateLimit.ts\n\nimport type { IncomingMessage } from 'node:http';\nimport type { Request, Response, NextFunction } from 'express';\nimport type { CustomWebSocket } from '../socket/socketUtility.js';\n\nimport jsutil from '../../shared/util/jsutil.js';\n\nimport { isIPBanned } from './banned.js';\nimport { getClientIP } from '../utility/IP.js';\nimport { logEvents, logEventsAndPrint } from './logEvents.js';\n\nimport 'dotenv/config'; // Imports all properties of process.env, if it exists\n\n/**\n * Whether the server is running in development mode.\n * It will be hosted on a different port for local host,\n * and a few other minor adjustments.\n */\nconst DEV_BUILD = process.env['NODE_ENV'] === 'development';\n\n/** Whether we are currently rate limiting connections.\n * Only disable temporarily for development purposes. */\nconst ARE_RATE_LIMITING = !DEV_BUILD; // Set to false to temporarily get around it, during development.\nif (!DEV_BUILD && !ARE_RATE_LIMITING) {\n\tthrow new Error('ARE_RATE_LIMITING must be true in production!!');\n}\n\n// For rate limiting a client...\n\n/** The maximum number of requests/messages allowed per IP address, per minute. */\nconst maxRequestsPerMinute = process.env['NODE_ENV'] === 'development' ? 400 : 200; // Default: 400 / 200\nconst minuteInMillis = 60000;\n\n/**\n * Interval to clear out an agent's list of recent connection timestamps if they\n * are longer ago than {@link minuteInMillis}\n */\nconst rateToUpdateRecentConnections = 1000; // 1 Second\n\n/**\n * The object containing a combination of IP addresses and user agents for the key,\n * and for the value - an array of timestamps of their recent connections.\n * The key format will be `{ \"192.538.1.1|User-Agent-String\": [timestamp1, timestamp2, ...] }`\n */\nconst rateLimitHash: Record<string, number[]> = {};\n\n// For detecting if we're under a DDOS attack...\n\n/** Interval to check if we think we're experiencing a DDOS */\nconst requestWindowToToggleAttackModeMillis = 2000;\n/**\n * The number of requests we can receive in our {@link requestWindowToToggleAttackModeMillis}\n * before thinking there's a DDOS attack happening.\n */\nconst requestCapToToggleAttackMode = 200;\n\n/**\n * Whether we think we're currently experiencing a DDOS.\n * When true, in the future we can strictly limit what actions users can request/perform!\n *\n * Ideas:\n * 1. All htmls, or statically served file items, should only be served once per minute to each IP.\n * 2. Don't rate limit player's websocket messages who are currently in a game.\n * 3. Temporarily disallow account creation.\n */\nlet underAttackMode = false;\n\n/**\n * An ordered array of timestamps of recent connections,\n * up to {@link requestWindowToToggleAttackModeMillis} ago.\n * The length of this is how many total requests we have\n * received during the past {@link requestWindowToToggleAttackModeMillis}.\n */\nconst recentRequests: number[] = []; // List of times of recent connections\n\n/**\n * Generates a key for rate limiting based on the client's IP address and user agent.\n * @param IP - The IP address of the request or websocket connection.\n * @param userAgent - The user agent string from the request headers.\n * @returns The combined key in the format \"IP|User-Agent\" or null if IP cannot be determined\n */\nfunction getIpBrowserAgentKey(IP: string, userAgent: string): string {\n\t// Construct the key combining IP and user agent\n\treturn `${IP}|${userAgent}`;\n}\n\n/**\n * Middleware that counts this IP address's recent connections,\n * and rejects this request if they've sent too many.\n * @param req - The request object\n * @param res - The response object\n * @param next - The function to call, when finished, to continue the middleware waterfall.\n */\nfunction rateLimit(req: Request, res: Response, next: NextFunction): void {\n\tif (!ARE_RATE_LIMITING) return next(); // Not rate limiting\n\n\tcountRecentRequests();\n\n\tconst clientIP = getClientIP(req);\n\tif (!clientIP) {\n\t\tlogEvents(\n\t\t\t'Unable to identify client IP address when rate limiting!',\n\t\t\t'reqLogRateLimited.txt',\n\t\t);\n\t\tres.status(400).json({ message: 'Unable to identify client IP address' });\n\t\treturn;\n\t}\n\n\tif (isIPBanned(clientIP)) {\n\t\tconst logThis = `Banned IP ${clientIP} tried to connect! ${req.headers.origin}   ${clientIP}   ${req.method}   ${req.url}   ${req.headers['user-agent']}`;\n\t\tlogEvents(logThis, 'bannedIPLog.txt');\n\t\tres.status(403).json({ message: 'You are banned' });\n\t\treturn;\n\t}\n\n\tconst userAgent = req.headers['user-agent'];\n\tif (!userAgent) {\n\t\tlogEvents(\n\t\t\t`Unable to identify user agent for IP ${clientIP} when rate limiting!`,\n\t\t\t'reqLogRateLimited.txt',\n\t\t);\n\t\tres.status(400).json({ message: 'User agent is required' });\n\t\treturn;\n\t}\n\n\tconst userKey = getIpBrowserAgentKey(clientIP, userAgent);\n\n\t// Add the current timestamp to their list of recent connection timestamps.\n\tincrementClientConnectionCount(userKey);\n\n\tconst timestamps = rateLimitHash[userKey];\n\tif (timestamps && timestamps.length > maxRequestsPerMinute) {\n\t\t// Rate limit them (too many requests sent)\n\t\tlogEvents(\n\t\t\t`Agent ${userKey} has too many requests! Count: ${timestamps.length}`,\n\t\t\t'reqLogRateLimited.txt',\n\t\t);\n\t\tres.status(429).json({ message: 'Too Many Requests. Try again soon.' });\n\t\treturn;\n\t}\n\n\tnext(); // Continue the middleware waterfall\n}\n\n/**\n * Counts this IP address's recent connections,\n * and returns false if they've sent too many requests/messages.\n * @param req - The request object\n * @param ws - The websocket object\n * @returns false if they've sent too many requests/messages. THEY WILL HAVE ALREADY BEEN CLOSED\n */\nfunction rateLimitWebSocket(req: IncomingMessage, ws: CustomWebSocket): boolean {\n\tcountRecentRequests();\n\n\tconst userAgent = req.headers['user-agent'];\n\tif (!userAgent) {\n\t\tlogEvents(\n\t\t\t`Unable to identify user agent for websocket connection when rate limiting!`,\n\t\t\t'reqLogRateLimited.txt',\n\t\t);\n\t\tws.close(1008, 'User agent is required');\n\t\treturn false;\n\t}\n\n\tconst userKey = getIpBrowserAgentKey(ws.metadata.IP, userAgent);\n\n\t// Add the current timestamp to their list of recent connection timestamps.\n\tincrementClientConnectionCount(userKey);\n\n\tif (rateLimitHash[userKey]!.length > maxRequestsPerMinute) {\n\t\tlogEvents(\n\t\t\t`Agent ${userKey} has too many requests after! Count: ${rateLimitHash[userKey]!.length}`,\n\t\t\t'reqLogRateLimited.txt',\n\t\t);\n\t\tws.close(1009, 'Too Many Requests. Try again soon.');\n\t\treturn false;\n\t}\n\n\treturn true; // Connection allowed!\n}\n\n/**\n * Increment the provided user key's recent connection count by adding the current timestamp\n * to their list of recent connection timestamps.\n * Only call if we haven't already rejected them for too many requests.\n * @param userKey - The unique key combining IP address and user agent.\n */\nfunction incrementClientConnectionCount(userKey: string): void {\n\t// Initialize the array if it doesn't exist\n\tif (!rateLimitHash[userKey]) rateLimitHash[userKey] = [];\n\t// Add the current timestamp to the user's recent connection timestamp list\n\trateLimitHash[userKey]!.push(Date.now());\n}\n\n/**\n * Set an interval to periodically clear {@link rateLimitHash}\n * of IP addresses with no recent connections or outdated timestamps.\n */\nsetInterval(() => {\n\tconst currentTimeMillis = Date.now();\n\n\tfor (const [key, timestamps] of Object.entries(rateLimitHash)) {\n\t\tconst firstTimestamp = timestamps[0];\n\n\t\t// Check if there are no timestamps\n\t\tif (firstTimestamp === undefined) {\n\t\t\tconst logMessage =\n\t\t\t\t'Agent recent connection timestamp list was empty. This should never happen! It should have been deleted.';\n\t\t\tlogEventsAndPrint(logMessage, 'errLog.txt');\n\t\t\tdelete rateLimitHash[key];\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Check the first timestamp. If the first timestamp is within the valid window, skip processing\n\t\tif (currentTimeMillis - firstTimestamp <= minuteInMillis) continue;\n\n\t\t// If all timestamps are older, delete the key\n\t\tconst mostRecentTimestamp = timestamps.at(-1)!;\n\t\tif (currentTimeMillis - mostRecentTimestamp >= minuteInMillis) {\n\t\t\tdelete rateLimitHash[key];\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Use binary search to find the index to split at\n\t\tconst indexToSplitAt = jsutil.findIndexOfPointInOrganizedArray(\n\t\t\ttimestamps,\n\t\t\tcurrentTimeMillis - minuteInMillis,\n\t\t);\n\n\t\t// Remove all timestamps to the left of the found index\n\t\ttimestamps.splice(0, indexToSplitAt);\n\t\tif (timestamps.length === 0) delete rateLimitHash[key];\n\t}\n}, rateToUpdateRecentConnections);\n\n/**\n * Adds the current timestamp to {@link recentRequests}.\n * This should always be called with any request/message,\n * EVEN if they are rate limited.\n */\nfunction countRecentRequests(): void {\n\tconst currentTimeMillis = Date.now();\n\trecentRequests.push(currentTimeMillis);\n}\n\n/**\n * Set an interval to repeatedly strip {@link recentRequests}\n * of timestamps that are longer than {@link requestWindowToToggleAttackModeMillis} ago.\n * This uses binary search to quickly find the splice point, so that\n * we don't potentially have to check hundreds of timestamps.\n *\n * This also activates {@link underAttackMode} if it thinks we have had SO\n * many recent connections that it must be a DDOS attack.\n */\nsetInterval(() => {\n\t// Delete recent requests longer than 2 seconds ago\n\tconst twoSecondsAgo = Date.now() - requestWindowToToggleAttackModeMillis;\n\tconst indexToSplitAt = jsutil.findIndexOfPointInOrganizedArray(recentRequests, twoSecondsAgo);\n\trecentRequests.splice(0, indexToSplitAt + 1);\n\n\tif (recentRequests.length > requestCapToToggleAttackMode) {\n\t\tif (!underAttackMode) {\n\t\t\t// Toggle on\n\t\t\tunderAttackMode = true;\n\t\t\tlogAttackBegin();\n\t\t}\n\t} else if (underAttackMode) {\n\t\tunderAttackMode = false;\n\t\tlogAttackEnd();\n\t}\n}, requestWindowToToggleAttackModeMillis);\n\nfunction logAttackBegin(): void {\n\tconst logText = `Probable DDOS attack happening now. Initial recent request count: ${recentRequests.length}`;\n\tlogEventsAndPrint(logText, 'reqLogRateLimited.txt');\n\tlogEvents(logText, 'hackLog.txt');\n}\n\nfunction logAttackEnd(): void {\n\tconst logText = `DDOS attack has ended.`;\n\tlogEventsAndPrint(logText, 'reqLogRateLimited.txt');\n\tlogEvents(logText, 'hackLog.txt');\n}\n\nexport { rateLimit, rateLimitWebSocket };\n"
  },
  {
    "path": "src/server/middleware/rateLimiters.ts",
    "content": "// src/server/middleware/rateLimiters.ts\n\n/**\n * Stores rate limiting rules for various endpoints.\n */\n\nimport type { Request, Response } from 'express';\n\nimport rateLimit from 'express-rate-limit';\n\nimport { getTranslationForReq } from '../utility/translate.js';\n\n// Options -------------------------------------------------------------\n\n/** A handler that returns a generic rate-limiting message. */\nfunction generic_handler(req: Request, res: Response): Response {\n\treturn res.status(429).json({\n\t\tmessage: getTranslationForReq('rate-limiting.generic', req), // More detailed human readable\n\t\terror: getTranslationForReq('rate-limiting.error', req), // Shorter concise message\n\t});\n}\n\n/** Default options for all rate limiters. */\nconst default_options = {\n\tstandardHeaders: true, // Return rate limit info in the `RateLimit-*` headers\n\tlegacyHeaders: false, // Disable the outdated `X-RateLimit-*` headers\n\thandler: generic_handler,\n};\n\n// Limiters -------------------------------------------------------------\n\n/**\n * Account Creation Limiter\n * Rule: Max 6 account creations per day per IP\n */\nexport const createAccountLimiter = rateLimit({\n\twindowMs: 1000 * 60 * 60 * 24,\n\tmax: 6,\n\t...default_options,\n});\n\n/**\n * Resend Account Verification Email Limiter\n * Rule: Max 4 verification email resends per hour per IP\n */\nexport const resendAccountVerificationLimiter = rateLimit({\n\twindowMs: 1000 * 60 * 60,\n\tmax: 4,\n\t...default_options,\n});\n\n/**\n * Forgot Password Email Limiter\n * Rule: Max 8 password reset requests per 20 minutes per IP\n */\nexport const forgotPasswordLimiter = rateLimit({\n\twindowMs: 1000 * 60 * 20,\n\tmax: 8,\n\t...default_options,\n});\n\n/**\n * Editor Save Limiter\n * Rule: Max 10 position saves per 1 minute per IP\n */\nexport const editorSaveLimiter = rateLimit({\n\twindowMs: 1000 * 60,\n\tmax: 10,\n\tskip: () => process.env['NODE_ENV'] === 'test',\n\t...default_options,\n});\n\n/**\n * Editor Load Limiter\n * Rule: Max 20 position loads per 1 minute per IP\n */\nexport const editorLoadLimiter = rateLimit({\n\twindowMs: 1000 * 60,\n\tmax: 20,\n\tskip: () => process.env['NODE_ENV'] === 'test',\n\t...default_options,\n});\n"
  },
  {
    "path": "src/server/middleware/secureRedirect.ts",
    "content": "// src/server/middleware/secureRedirect.ts\n\nimport type { Request, Response, NextFunction } from 'express';\n\nimport 'dotenv/config'; // Imports all properties of process.env, if it exists\n\n/**\n * Middleware that redirects all http requests to https\n * @param req - The request object\n * @param res - The response object\n * @param next - The function to call, when finished, to continue the middleware waterfall.\n */\nconst secureRedirect = (req: Request, res: Response, next: NextFunction): void => {\n\t// 1-year is minimum remember time with preload parameter. Preload means google will always pre-tell clickers-of-your-site to connect via https.\n\tres.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');\n\n\tif (req.secure) return next();\n\n\t// Force redirect to https...\n\n\tconst httpsPort =\n\t\tprocess.env['NODE_ENV'] !== 'production'\n\t\t\t? ':' + (process.env['HTTPSPORT_LOCAL'] || '3443')\n\t\t\t: '';\n\tres.redirect(`https://${req.hostname}${httpsPort}${req.url}`);\n};\n\nexport default secureRedirect;\n"
  },
  {
    "path": "src/server/middleware/send404.ts",
    "content": "// src/server/middleware/send404.ts\n\nimport type { Request, Response } from 'express';\n\nimport path from 'path';\nimport { fileURLToPath } from 'node:url';\n\nimport { getLanguageToServe, getTranslationForReq } from '../utility/translate.js';\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\nfunction send404(req: Request, res: Response): void {\n\tres.status(404);\n\tif (req.accepts('html')) {\n\t\tres.sendFile(\n\t\t\tpath.join(\n\t\t\t\t__dirname,\n\t\t\t\t'../../../dist/client/views',\n\t\t\t\tgetLanguageToServe(req),\n\t\t\t\t'errors/404.html',\n\t\t\t),\n\t\t);\n\t} else if (req.accepts('json')) {\n\t\tres.json({ error: getTranslationForReq('server.javascript.ws-not_found', req) });\n\t} else {\n\t\tres.type('txt').send(getTranslationForReq('server.javascript.ws-not_found', req));\n\t}\n}\n\nexport default send404;\n"
  },
  {
    "path": "src/server/middleware/verifyJWT.ts",
    "content": "// src/server/middleware/verifyJWT.ts\n\n/*\n * This module reads incoming requests, searching for a\n * valid authorization header, or a valid refresh token cookie,\n * to verify their identity, and sets the `user` and `role`\n * properties of the request (or of the websocket metadata)\n * if they are logged in.\n */\n\nimport type { Request, Response, NextFunction } from 'express';\n\nimport { getClientIP } from '../utility/IP.js';\nimport { CustomWebSocket } from '../socket/socketUtility.js';\nimport { logEventsAndPrint } from './logEvents.js';\nimport { IdentifiedRequest, isRequestIdentified, ParsedCookies } from '../types.js';\nimport {\n\tfreshenSession,\n\trevokeSession,\n} from '../controllers/authenticationTokens/sessionManager.js';\nimport {\n\tisAccessTokenValid,\n\tisRefreshTokenValid,\n} from '../controllers/authenticationTokens/tokenValidator.js';\n\n/**\n * [HTTP] Reads the request's bearer token (from the authorization header)\n * OR the refresh cookie (contains refresh token),\n * sets req.memberInfo properties if it is valid (are signed in).\n * Further middleware can read these properties to not send\n * private information to unauthorized users.\\\n */\nfunction verifyJWT(req: Request, res: Response, next: NextFunction): void {\n\tconst cookies: ParsedCookies = req.cookies;\n\treq.memberInfo = { signedIn: false, browser_id: cookies['browser-id'] };\n\n\t// After this line, typescript then thinks the req is of the IdentifiedRequest type.\n\tif (!isRequestIdentified(req))\n\t\tthrow Error('Not all required IdentifiedRequest properties were set!');\n\n\tconst hasAccessToken = verifyAccessToken(req, res);\n\tif (!hasAccessToken) verifyRefreshToken(req, res);\n\n\tnext(); // Continue down the middleware waterfall\n}\n\n/**\n * [HTTP] Reads the request's bearer token (from the authorization header),\n * sets the connections `memberInfo` property if it is valid (are signed in).\n *\n * Returns whether they have a valid access token or not.\n */\nfunction verifyAccessToken(req: IdentifiedRequest, res: Response): boolean {\n\tconst authHeader = req.headers.authorization;\n\tif (!authHeader) return false; // No authentication header included\n\tif (!authHeader.startsWith('Bearer ')) return false; // Authentication header doesn't look correct\n\n\tconst accessToken = authHeader.split(' ')[1];\n\tif (!accessToken) return false; // Authentication header doesn't contain a token\n\n\tconst result = isAccessTokenValid(accessToken);\n\tif (!result.isValid) {\n\t\tlogEventsAndPrint(\n\t\t\t`Invalid access token, expired or tampered! \"${accessToken}\"`,\n\t\t\t'errLog.txt',\n\t\t);\n\t\t// Revoke their session now, in case they were manually logged out, and their client didn't know that.\n\t\t// The client should never use an expired token unless it's a bug.\n\t\trevokeSession(res);\n\t\treturn false;\n\t}\n\n\t// Token is valid and hasn't hit the 15m expiry\n\n\t// console.log('A valid access token was used! :D :D');\n\n\treq.memberInfo = { ...req.memberInfo, signedIn: true, ...result.payload }; // Username was our payload when we generated the access token\n\treturn true;\n}\n\n/**\n * [HTTP] Reads the request's refresh token cookie,\n * updates the connections `memberInfo` property if it is valid (are signed in).\n * Only call if they did not have a valid access token, as this performs database queries!\n */\nfunction verifyRefreshToken(req: IdentifiedRequest, res: Response): void {\n\tconst cookies: ParsedCookies = req.cookies;\n\tconst refreshToken = cookies.jwt;\n\tif (!refreshToken) return; // No refresh token present\n\n\tconst result = isRefreshTokenValid(refreshToken, getClientIP(req));\n\n\tif (!result.isValid) {\n\t\t// Token was expired or tampered, or manually invalidated.\n\t\tconsole.log(\n\t\t\t`Invalid refresh token: Expired, tampered, or account deleted! Reason: \"${result.reason}\"`,\n\t\t);\n\t\t// Revoke their session now, in case they were manually logged out, and their client didn't know that.\n\t\trevokeSession(res);\n\t\treturn;\n\t}\n\n\tconst payload = result.payload;\n\n\ttry {\n\t\t// Renew the session if it was issued more than a day ago.\n\t\tfreshenSession(\n\t\t\treq,\n\t\t\tres,\n\t\t\tpayload.user_id,\n\t\t\tpayload.username,\n\t\t\tpayload.roles,\n\t\t\tresult.tokenRecord,\n\t\t);\n\t} catch (error) {\n\t\tconst errMsg = error instanceof Error ? error.message : String(error);\n\t\tlogEventsAndPrint(`Error freshening session: ${errMsg}`, 'errLog.txt');\n\t}\n\n\t// Valid! Set their req.memberInfo property!\n\n\treq.memberInfo = { ...req.memberInfo, signedIn: true, ...result.payload }; // Username was our payload when we generated the access token\n}\n\n/**\n * [WebSocket] Reads the refresh cookie token,\n * Modifies ws.metadata.memberInfo if they are signed in\n * to add the user_id, username, and roles properties.\n * @param req\n * @param ws - The websocket object\n */\nfunction verifyJWTWebSocket(ws: CustomWebSocket): void {\n\tverifyRefreshToken_WebSocket(ws);\n}\n\n/**\n * [WebSocket] If they have a valid refresh token cookie (http-only), set's\n * the socket metadata's `user` property, ands returns true.\n * @param ws - The websocket object\n * @returns true if a valid token was found.\n */\nfunction verifyRefreshToken_WebSocket(ws: CustomWebSocket): void {\n\tconst cookies = ws.metadata.cookies;\n\n\tconst refreshToken = cookies.jwt;\n\tif (!refreshToken) return; // Not logged in, don't set their user property\n\n\t// { isValid (boolean), user_id, username, reason (string, if not valid) }\n\tconst ip = ws.metadata.IP;\n\tconst result = isRefreshTokenValid(refreshToken, ip); // True for refresh token\n\tif (!result.isValid) {\n\t\tconsole.log(\n\t\t\t`Invalid refresh token (websocket): Expired, tampered, or account deleted! Reason: \"${result.reason}\". Token: \"${refreshToken}\"`,\n\t\t);\n\t\treturn; // Token was expired or tampered\n\t}\n\n\tws.metadata.memberInfo = { ...ws.metadata.memberInfo, signedIn: true, ...result.payload };\n}\n\nexport { verifyJWT, verifyJWTWebSocket };\n"
  },
  {
    "path": "src/server/routes/root.ts",
    "content": "// src/server/routes/root.ts\n\nimport path from 'path';\nimport { fileURLToPath } from 'node:url';\nimport express, { Request, Response } from 'express';\n\nimport { getLanguageToServe } from '../utility/translate.js';\n\nconst router = express.Router();\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\nconst htmlDirectory = path.join(__dirname, '../../../dist/client/views');\n\n/**\n * Serves an HTML file based on the requested path and language.\n * @param filePath - The relative file path to serve.\n * @param localized - If the file is not localized to other languages.\n * @returns Express middleware handler.\n */\nconst serveFile =\n\t(filePath: string, localized: boolean = true) =>\n\t(req: Request, res: Response) => {\n\t\tconst language: string = localized ? getLanguageToServe(req) : '';\n\t\tconst file: string = path.join(htmlDirectory, language, filePath);\n\t\t/**\n\t\t * sendFile() will AUTOMATICALLY check if the file's Last-Modified\n\t\t * value is after the request's 'If-Modified-Since' header...\n\t\t *\n\t\t * If so, it will send 200 OK with the updated file content!\n\t\t *\n\t\t * Otherwise, it sends 304 Not Modified, signaling the client\n\t\t * to use their cached version for another duration of the\n\t\t * max-age property of the Cache-Control header we send!\n\t\t */\n\t\tres.sendFile(file);\n\t};\n\n// Regular pages\nrouter.get('^/$|/index(.html)?', serveFile('index.html'));\nrouter.get('/credits(.html)?', serveFile('credits.html'));\nrouter.get('/play(.html)?', serveFile('play.html'));\nrouter.get('/guide(.html)?', serveFile('guide.html'));\nrouter.get('/news(.html)?', serveFile('news.html'));\nrouter.get('/leaderboard(.html)?', serveFile('leaderboard.html'));\nrouter.get('/login(.html)?', serveFile('login.html'));\nrouter.get('/createaccount(.html)?', serveFile('createaccount.html'));\n\nrouter.get('/reset-password/:token', serveFile('resetpassword.html'));\n\nrouter.get('/termsofservice(.html)?', serveFile('termsofservice.html'));\nrouter.get('/member(.html)?/:member', serveFile('member.html'));\nrouter.get('/admin(.html)?', serveFile('admin.html'));\nrouter.get('/icnvalidator(.html)?', serveFile('icnvalidator.html', false));\n\n// Error pages\nrouter.get('/400(.html)?', serveFile('errors/400.html', true));\nrouter.get('/401(.html)?', serveFile('errors/401.html', true));\nrouter.get('/404(.html)?', serveFile('errors/404.html', true));\nrouter.get('/409(.html)?', serveFile('errors/409.html', true));\nrouter.get('/500(.html)?', serveFile('errors/500.html', true));\n\nexport { router as rootRouter };\n"
  },
  {
    "path": "src/server/server.ts",
    "content": "// src/server/server.ts\n\nimport { initDatabase } from './database/databaseTables.js';\nimport { initDevEnvironment } from './config/setupDev.js';\n\nimport 'dotenv/config'; // Imports all properties of process.env, if it exists\n\ninitDatabase();\n// Ensure our workspace is ready for the dev environment\ninitDevEnvironment();\n\n// Dependancy/built-in imports\nimport https from 'https';\n// Other imports\nimport app from './app.js';\nimport db from './database/database.js';\nimport socketServer from './socket/socketServer.js';\nimport { prepGamesForShutdown, restoreLiveGames } from './game/gamemanager/gamemanager.js';\nimport { getCertOptions } from './config/certOptions.js';\nimport { logServerStarted, logServerStopped } from './utility/startupLogger.js';\n\nconst httpsServer = https.createServer(getCertOptions(), app);\n\n// Restore live games from the database into memory before accepting new connections.\nrestoreLiveGames();\n\n// Start the server\nconst DEV_BUILD = process.env['NODE_ENV'] === 'development';\nconst HTTPPORT = DEV_BUILD ? process.env['HTTPPORT_LOCAL'] : process.env['HTTPPORT'];\nconst HTTPSPORT = DEV_BUILD ? process.env['HTTPSPORT_LOCAL'] : process.env['HTTPSPORT'];\napp.listen(HTTPPORT, () => console.log(`HTTP listening on port ${HTTPPORT}`));\nhttpsServer.listen(HTTPSPORT, () => {\n\tconsole.log(`HTTPS listening on port ${HTTPSPORT}`);\n\tlogServerStarted();\n});\n\n// WebSocket server\nsocketServer.start(httpsServer);\n\n// On closing...\n\nlet cleanupDone = false;\nprocess.on('SIGUSR2', () => handleCleanup('SIGUSR2')); // A file was saved (nodemon auto restarts)\nprocess.on('SIGINT', () => handleCleanup('SIGINT')); // Ctrl>C was pressed (force terminates nodemon)\nprocess.on('SIGTERM', () => handleCleanup('SIGTERM')); // PM2 graceful shutdown\nfunction handleCleanup(signal: string): void {\n\tif (cleanupDone) return; // Sometimes this is called twice\n\tcleanupDone = true;\n\t// console.log(`\\nReceived ${signal}. Cleaning up...`);\n\tconsole.log('Closing...');\n\n\tlogServerStopped(signal);\n\n\tprepGamesForShutdown();\n\n\tdb.close(); // Close the database when the server is shutting down.\n\n\tprocess.exit(0);\n}\n"
  },
  {
    "path": "src/server/socket/closeSocket.ts",
    "content": "// src/server/socket/closeSocket.ts\n\n/**\n * This script terminates websockets.\n */\n\nimport type { CustomWebSocket } from './socketUtility.js';\n\nimport wsutil from '../../shared/util/wsutil.js';\n\nimport { removeConnectionFromConnectionLists, unsubSocketFromAllSubs } from './socketManager.js';\n\n// Functions ---------------------------------------------------------------------------\n\nfunction onclose(ws: CustomWebSocket, code: number, reason: Buffer): void {\n\tconst reasonString = reason.toString();\n\n\t// Delete connection from object.\n\tremoveConnectionFromConnectionLists(ws, code, reasonString);\n\n\t// What if the code is 1000, and reason is \"Connection closed by client\"?\n\t// I then immediately want to delete their invite.\n\t// But what other reasons could it close... ?\n\t// Code 1006, Message \"\" is just a network failure.\n\n\t// True if client had no power over the closure,\n\t// DON'T COUNT this as a disconnection!\n\t// They would want to keep their invite, AND remain in their game!\n\tconst closureNotByChoice = wsutil.wasSocketClosureNotByTheirChoice(code, reasonString);\n\n\t// Unsubscribe them from all. NO LIST. It doesn't matter if they want to keep their invite or remain\n\t// connected to their game, without a websocket to send updates to, there's no point in any SUBSCRIPTION service!\n\t// Unsubbing them from their game will start their auto-resignation timer.\n\tunsubSocketFromAllSubs(ws, closureNotByChoice);\n\n\tcancelRenewConnectionTimer(ws);\n}\n\nfunction cancelRenewConnectionTimer(ws: CustomWebSocket): void {\n\tclearTimeout(ws.metadata.renewConnectionTimeoutID);\n\tws.metadata.renewConnectionTimeoutID = undefined;\n}\n\nexport { onclose };\n"
  },
  {
    "path": "src/server/socket/echoTracker.ts",
    "content": "// src/server/socket/echoTracker.ts\n\n/**\n * This script keeps track of the echos we are expecting from recent websocket-out messages.\n *\n * Typically, if we don't receive an echo within five seconds,\n * we think the connection was lost, so we terminate the websocket.\n */\n\n// Variables ---------------------------------------------------------------------------\n\n/**\n *\n * An object containing the timeout ID's for the timers that auto terminate\n * websockets if we never hear an echo back: `{ messageID: timeoutID }`\n */\nconst echoTimers: { [messageID: number]: NodeJS.Timeout | number } = {};\n\n/**\n * The time, after which we don't hear an expected echo from a websocket,\n * in which it be assumed disconnected, and auto terminated, in milliseconds.\n */\nconst timeToWaitForEchoMillis: number = 5000; // 5 seconds until we assume we've disconnected!\n\n// Functions ---------------------------------------------------------------------------\n\nfunction addTimeoutToEchoTimers(messageID: number, timeout: NodeJS.Timeout | number): void {\n\techoTimers[messageID] = timeout;\n}\n\n/**\n * Cancel the timer that will close the socket when we don't hear an expected echo from a sent socket message.\n * If there was no timer, this will return false, meaning it was an invalid echo.\n */\nfunction deleteEchoTimerForMessageID(messageIDEchoIsFor: number): boolean {\n\tconst timeout: NodeJS.Timeout | number | undefined = echoTimers[messageIDEchoIsFor];\n\tif (timeout === undefined) return false; // Invalid echo (message ID wasn't from any recently sent socket message)\n\n\tclearTimeout(timeout);\n\tdelete echoTimers[messageIDEchoIsFor];\n\n\treturn true; // Valid echo\n}\n\nexport { addTimeoutToEchoTimers, deleteEchoTimerForMessageID, timeToWaitForEchoMillis };\n"
  },
  {
    "path": "src/server/socket/generalrouter.ts",
    "content": "// src/server/socket/generalrouter.ts\n\n/**\n * This script handles the incoming general websocket message route.\n */\n\nimport type { CustomWebSocket } from './socketUtility.js';\n\nimport * as z from 'zod';\n\nimport { unsubClientFromGameBySocket } from '../game/gamemanager/gamemanager.js';\nimport { subToInvitesList, unsubFromInvitesList } from '../game/invitesmanager/invitesmanager.js';\n\nconst validUnsubs = ['invites', 'game'] as const;\n\ntype ValidUnsub = (typeof validUnsubs)[number];\n\nconst GeneralSchema = z.discriminatedUnion('action', [\n\tz.strictObject({ action: z.literal('sub'), value: z.literal(['invites']) }),\n\tz.strictObject({ action: z.literal('unsub'), value: z.literal(validUnsubs) }),\n]);\n\ntype GeneralMessage = z.infer<typeof GeneralSchema>;\n\n// Functions -------------------------------------------------------------------\n\n// Route for this incoming message is \"general\". What is their action?\nfunction routeGeneralMessage(ws: CustomWebSocket, message: GeneralMessage): void {\n\t// data: { route, action, value, id }\n\t// Route them according to their action\n\tswitch (message.action) {\n\t\tcase 'sub':\n\t\t\thandleSubbing(ws, message.value);\n\t\t\tbreak;\n\t\tcase 'unsub':\n\t\t\thandleUnsubbing(ws, message.value);\n\t\t\tbreak;\n\t\tdefault:\n\t\t\tconsole.error(\n\t\t\t\t// @ts-ignore\n\t\t\t\t`UNKNOWN web socket action received in general route! \"${message.action}\"`,\n\t\t\t);\n\t}\n}\n\n// Actions -------------------------------------------------------------------\n\nfunction handleSubbing(ws: CustomWebSocket, value: 'invites'): void {\n\t// What are they wanting to subscribe to for updates?\n\tswitch (value) {\n\t\tcase 'invites':\n\t\t\t// Subscribe them to the invites list\n\t\t\tsubToInvitesList(ws);\n\t\t\tbreak;\n\t\tdefault:\n\t\t\tconsole.error(`UNKNOWN subscription list to subscribe client to! \"${value}\"`);\n\t}\n}\n\n// Set closureNotByChoice to true if you don't immediately want to disconnect them, but say after 5 seconds\nfunction handleUnsubbing(ws: CustomWebSocket, key: ValidUnsub, closureNotByChoice?: boolean): void {\n\t// What are they wanting to unsubscribe from updates from?\n\tswitch (key) {\n\t\tcase 'invites':\n\t\t\t// Unsubscribe them from the invites list\n\t\t\tunsubFromInvitesList(ws, closureNotByChoice);\n\t\t\tbreak;\n\t\tcase 'game':\n\t\t\t// If the unsub is not by choice (network interruption instead of closing tab), then we give them\n\t\t\t// a 5 second cushion before starting an auto-resignation timer\n\t\t\tunsubClientFromGameBySocket(ws, { unsubNotByChoice: closureNotByChoice });\n\t\t\tbreak;\n\t\tdefault:\n\t\t\tconsole.error(`UNKNOWN subscription list to unsubscribe client from! \"${key}\"`);\n\t}\n}\n\n// Exports ------------------------------------------------------------\n\nexport { routeGeneralMessage, handleUnsubbing, GeneralSchema };\n"
  },
  {
    "path": "src/server/socket/openSocket.ts",
    "content": "// src/server/socket/openSocket.ts\n\n/**\n * This script handles socket upgrade connection requests, and creating new sockets.\n */\n\nimport type WebSocket from 'ws';\nimport type { IncomingMessage } from 'http';\nimport type { CustomWebSocket } from './socketUtility.js';\n\nimport { GAME_VERSION } from '../../shared/game_version.js';\n\nimport { onclose } from './closeSocket.js';\nimport socketUtility from './socketUtility.js';\nimport { onmessage } from './receiveSocketMessage.js';\nimport { executeSafely } from '../utility/errorGuard.js';\nimport { sendSocketMessage } from './sendSocketMessage.js';\nimport { verifyJWTWebSocket } from '../middleware/verifyJWT.js';\nimport { rateLimitWebSocket } from '../middleware/rateLimit.js';\nimport { getMemberDataByCriteria } from '../database/memberManager.js';\nimport { logEvents, logEventsAndPrint, logWebsocketStart } from '../middleware/logEvents.js';\nimport {\n\taddConnectionToConnectionLists,\n\tdoesClientHaveMaxSocketCount,\n\tdoesSessionHaveMaxSocketCount,\n\tgenerateUniqueIDForSocket,\n\tterminateAllIPSockets,\n} from './socketManager.js';\n\n// Variables ---------------------------------------------------------------------------\n\n// Functions ---------------------------------------------------------------------------\n\nfunction onConnectionRequest(socket: WebSocket, req: IncomingMessage): void {\n\tconst ws = closeIfInvalidAndAddMetadata(socket, req);\n\tif (ws === undefined) return; // We will have already closed the socket\n\n\t// Rate Limit Here\n\t// A false could either mean:\n\t// 1. Too many requests\n\t// 2. Message too big\n\t// In ALL these cases, we are terminating all the IPs sockets for now!\n\tif (!rateLimitWebSocket(req, ws)) {\n\t\t// Connection not allowed\n\t\treturn terminateAllIPSockets(ws.metadata.IP);\n\t}\n\n\t// Check if ip has too many connections\n\tif (doesClientHaveMaxSocketCount(ws.metadata.IP)) {\n\t\tconsole.log(`Client IP ${ws.metadata.IP} has too many sockets! Not connecting this one.`);\n\t\treturn ws.close(1009, 'Too Many Sockets');\n\t}\n\n\t// Initialize who they are. Member? Browser ID?...\n\tverifyJWTWebSocket(ws); // Modifies ws.metadata.memberInfo if they are signed in to add the user_id, username, and roles properties.\n\n\tif (\n\t\tws.metadata.memberInfo.signedIn &&\n\t\tdoesSessionHaveMaxSocketCount(ws.metadata.cookies.jwt!)\n\t) {\n\t\tconsole.log(\n\t\t\t`Member \"${ws.metadata.memberInfo.username}\" has too many sockets for this session! Not connecting this one.`,\n\t\t);\n\t\treturn ws.close(1009, 'Too Many Sockets');\n\t}\n\n\taddConnectionToConnectionLists(ws);\n\n\tlogWebsocketStart(req, ws); // Log the request\n\n\taddListenersToSocket(req, ws);\n\n\t// If user is signed in, use the database to correctly set the property ws.metadata.verified\n\tif (ws.metadata.memberInfo.signedIn) {\n\t\tconst record = getMemberDataByCriteria(\n\t\t\t['is_verified'],\n\t\t\t'user_id',\n\t\t\tws.metadata.memberInfo.user_id,\n\t\t);\n\t\t// Set the verified status. 1 means true.\n\t\tif (record?.is_verified === 1) ws.metadata.verified = true;\n\t}\n\n\t// Send the current game vesion, so they will know whether to refresh.\n\tsendSocketMessage(ws, 'general', 'gameversion', GAME_VERSION);\n}\n\nfunction closeIfInvalidAndAddMetadata(\n\tsocket: WebSocket,\n\treq: IncomingMessage,\n): CustomWebSocket | undefined {\n\t// Make sure the connection is secure https\n\tconst origin = req.headers.origin;\n\tif (origin === undefined || !origin.startsWith('https')) {\n\t\tconsole.error(\n\t\t\t`WebSocket connection request rejected. Reason: Not Secure. Origin: \"${origin}\"`,\n\t\t);\n\t\tsocket.close(1009, 'Not Secure');\n\t\treturn;\n\t}\n\n\t// Make sure the origin is our website\n\t// In DEV_BUILD, allow all origins.\n\tif (process.env['NODE_ENV'] !== 'development' && origin !== process.env['APP_BASE_URL']) {\n\t\tlogEvents(\n\t\t\t`WebSocket connection request rejected. Reason: Origin Error. \"Origin: ${origin}\"   Should be: \"${process.env['APP_BASE_URL']}\"`,\n\t\t\t'hackLog.txt',\n\t\t);\n\t\tsocket.close(1009, 'Origin Error');\n\t\treturn;\n\t}\n\n\tconst IP = socketUtility.getIPFromWebsocketUpgradeRequest(req);\n\tif (IP === undefined) {\n\t\tlogEvents('Unable to identify IP address from websocket connection!', 'hackLog.txt');\n\t\tsocket.close(1008, 'Unable to identify client IP address'); // Code 1008 is Policy Violation\n\t\treturn;\n\t}\n\n\tconst cookies = socketUtility.getCookiesFromWebsocket(req);\n\tif (cookies['browser-id'] === undefined) {\n\t\t// console.log(`Authentication needed for WebSocket connection request!!`);\n\t\tsocket.close(1008, 'Authentication needed'); // Code 1008 is Policy Violation\n\t\treturn;\n\t}\n\n\t// Initialize the metadata and cast to a custom websocket object\n\tconst ws = socket as CustomWebSocket; // Cast WebSocket to CustomWebSocket\n\n\tws.metadata = {\n\t\t// Parse cookies from the Upgrade http headers\n\t\tcookies,\n\t\tsubscriptions: {},\n\t\tuserAgent: req.headers['user-agent'],\n\t\tmemberInfo: { signedIn: false, browser_id: cookies['browser-id'] },\n\t\tverified: false,\n\t\tid: generateUniqueIDForSocket(), // Sets the ws.metadata.id property of the websocket\n\t\tIP,\n\t};\n\n\treturn ws;\n}\n\n/**\n * Adds the 'message', 'close', and 'error' event listeners to the socket\n */\nfunction addListenersToSocket(req: IncomingMessage, ws: CustomWebSocket): void {\n\tws.on('message', (message: Buffer<ArrayBufferLike>) => {\n\t\texecuteSafely(\n\t\t\t() => onmessage(req, ws, message),\n\t\t\t'Error caught within websocket on-message event:',\n\t\t);\n\t});\n\tws.on('close', (code, reason) => {\n\t\texecuteSafely(\n\t\t\t() => onclose(ws, code, reason),\n\t\t\t'Error caught within websocket on-close event:',\n\t\t);\n\t});\n\tws.on('error', (error) => {\n\t\texecuteSafely(() => onerror(ws, error), 'Error caught within websocket on-error event:');\n\t});\n}\n\nfunction onerror(ws: CustomWebSocket, error: Error): void {\n\tconst errText = `An error occurred in a websocket. The socket: ${socketUtility.stringifySocketMetadata(ws)}\\n${error.stack}`;\n\tlogEventsAndPrint(errText, 'errLog.txt');\n}\n\nexport { onConnectionRequest };\n"
  },
  {
    "path": "src/server/socket/receiveSocketMessage.ts",
    "content": "// src/server/socket/receiveSocketMessage.ts\n\n/**\n * This script receives incoming socket messages, rate limits them, logs them,\n * cancels their echo timer, sends an echo, then sends the message to our router.\n */\n\nimport type { IncomingMessage } from 'http';\nimport type { CustomWebSocket } from './socketUtility.js';\n\nimport * as z from 'zod';\n\nimport socketUtility from './socketUtility.js';\nimport { GameSchema } from '../game/gamemanager/gamerouter.js';\nimport { logZodError } from '../utility/zodlogger.js';\nimport { InvitesSchema } from '../game/invitesmanager/invitesrouter.js';\nimport { GeneralSchema } from './generalrouter.js';\nimport { rateLimitWebSocket } from '../middleware/rateLimit.js';\nimport { routeIncomingSocketMessage } from './socketRouter.js';\nimport { deleteEchoTimerForMessageID } from './echoTracker.js';\nimport { logEvents, logReqWebsocketIn } from '../middleware/logEvents.js';\nimport { rescheduleRenewConnection, sendSocketMessage } from './sendSocketMessage.js';\n\n// Types --------------------------------------------------------------------------------------\n\n/** The schema for validating all non-echo incoming websocket messages. */\nconst MasterSchema = z.discriminatedUnion('route', [\n\tz.strictObject({ id: z.int(), route: z.literal('general'), contents: GeneralSchema }),\n\tz.strictObject({ id: z.int(), route: z.literal('invites'), contents: InvitesSchema }),\n\tz.strictObject({ id: z.int(), route: z.literal('game'), contents: GameSchema }),\n]);\n/** Represents all possible types a non-echo incoming websocket message could be! */\nexport type WebsocketInMessage = z.infer<typeof MasterSchema>;\n\n/** This is the id of the message being replied to. */\nconst EchoSchema = z.strictObject({\n\t/** The route to forward the message to (e.g., \"general\", \"invites\", \"game\"). */\n\troute: z.literal('echo'),\n\t/** The contents of the message, for the router to read. */\n\tcontents: z.int(),\n});\n\n/** The schema for validating all incoming websocket messages, including echos. */\nconst MasterSchemaWithEchos = z.discriminatedUnion('route', [MasterSchema, EchoSchema]);\n\n// Constants ---------------------------------------------------------------------------\n\n/**\n * The maximum size of an incoming websocket message, in bytes.\n * Above this will be rejected, and an error sent to the client.\n *\n * DIRECTLY CONTROLS THE maximum distance players can move in online games!\n * 500 KB allows moves up to 1e100000 squares away, with some padding.\n * On mobile it would take 6 hours of zooming out at\n * MAXIMUM speed to reach that distance, without rest.\n * It would take WAYYYY longer on desktop!\n */\nconst maxWebsocketMessageSizeBytes = 500_000; // 500 KB\n\n// Functions ---------------------------------------------------------------------------\n\n/**\n * Callback function that is executed whenever we receive an incoming websocket message.\n * Sends an echo (unless this message itself **is** an echo), rate limits,\n * logs the message, then routes the message where it needs to go.\n */\nfunction onmessage(req: IncomingMessage, ws: CustomWebSocket, rawMessage: Buffer): void {\n\t// Test if the message is too big. People could DDOS this way\n\t// THIS MAY NOT WORK if the bytes get read before we reach this part of the code, it could still DDOS us before we reject them.\n\tif (Buffer.byteLength(rawMessage) > maxWebsocketMessageSizeBytes) {\n\t\tlogEvents(`Client sent too big a websocket message.`, 'reqLogRateLimited.txt');\n\t\tws.close(1009, 'Message Too Big');\n\t\treturn;\n\t}\n\n\tconst messageStr = rawMessage.toString('utf8');\n\n\tlet parsedUnvalidatedMessage: any;\n\ttry {\n\t\t// Parse the stringified JSON message.\n\t\t// Incoming message is in binary data, which can also be parsed into JSON\n\t\tparsedUnvalidatedMessage = JSON.parse(messageStr);\n\t} catch (error: unknown) {\n\t\tif (!rateLimitAndLogMessage(req, ws, messageStr)) return; // The socket will have already been closed.\n\t\tconst errText = `'Error parsing incoming message as JSON: ${JSON.stringify(error)}. Socket: ${socketUtility.stringifySocketMetadata(ws)}`;\n\t\tlogEvents(errText, 'hackLog.txt');\n\t\tsendSocketMessage(ws, 'general', 'printerror', `Invalid JSON format!`);\n\t\treturn;\n\t}\n\n\tconst zod_result = MasterSchemaWithEchos.safeParse(parsedUnvalidatedMessage);\n\tif (!zod_result.success) {\n\t\tsendSocketMessage(\n\t\t\tws,\n\t\t\t'general',\n\t\t\t'notify',\n\t\t\t'Your browser is running outdated code, please hard refresh the page!',\n\t\t);\n\t\tlogZodError(\n\t\t\tparsedUnvalidatedMessage,\n\t\t\tzod_result.error,\n\t\t\t'Received malformed websocket in-message.',\n\t\t);\n\t\treturn;\n\t}\n\n\t// Validation was a success! Message contains valid parameters.\n\n\tconst message = zod_result.data;\n\n\tif (message.route === 'echo') {\n\t\tconst incomingEcho: number = message.contents;\n\t\tconst validEcho = deleteEchoTimerForMessageID(incomingEcho); // Cancel timer to assume they've disconnected\n\t\tif (!validEcho) {\n\t\t\tif (!rateLimitAndLogMessage(req, ws, messageStr)) return; // The socket will have already been closed.\n\t\t\t// This occasionally happens when the echo arrives after timeToWaitForEchoMillis has elapsed,\n\t\t\t// the timeout has already fired, the socket was already closed, and the echo timer was already deleted.\n\t\t}\n\t\treturn;\n\t}\n\n\t// Not an echo...\n\n\tif (!rateLimitAndLogMessage(req, ws, messageStr)) return; // The socket will have already been closed.\n\n\t// Send our echo here! We always send an echo to every message except echos themselves.\n\tsendSocketMessage(ws, 'general', 'echo', message.id);\n\n\t// console.log('Received message: ' + rawMessage);\n\n\trescheduleRenewConnection(ws); // We know they are connected, so reset this\n\n\trouteIncomingSocketMessage(ws, message);\n}\n\n/**\n * Logs and rate limits on incoming socket message.\n * Returns true if the message is allowed, or false if the message\n * is being rate limited and the socket has already been closed.\n */\nfunction rateLimitAndLogMessage(\n\treq: IncomingMessage,\n\tws: CustomWebSocket,\n\trawMessage: string,\n): boolean {\n\tif (!rateLimitWebSocket(req, ws)) return false; // They are being rate limited, the socket will have already been closed.\n\tlogReqWebsocketIn(ws, rawMessage); // Only logged the message if it wasn't rate limited.\n\treturn true;\n}\n\nexport { onmessage };\n"
  },
  {
    "path": "src/server/socket/sendSocketMessage.ts",
    "content": "// src/server/socket/sendSocketMessage.ts\n\n/**\n * This script sends socket messages,\n * and regularly sends messages by itself to confirm the socket is still connected and responding (we will hear an echo).\n */\n\nimport type { TranslationKeys } from '../../types/translations.js';\n\nimport { WebSocket } from 'ws';\n\nimport uuid from '../../shared/util/uuid.js';\nimport jsutil from '../../shared/util/jsutil.js';\nimport wsutil from '../../shared/util/wsutil.js';\n\nimport socketUtility from './socketUtility.js';\nimport { getTranslation } from '../utility/translate.js';\nimport { logEventsAndPrint, logReqWebsocketOut } from '../middleware/logEvents.js';\nimport {\n\taddTimeoutToEchoTimers,\n\tdeleteEchoTimerForMessageID,\n\ttimeToWaitForEchoMillis,\n} from './echoTracker.js';\n\n// Types --------------------------------------------------------------------------------------\n\n/** Represents an outgoing WebSocket server message. */\ninterface WebsocketOutMessage {\n\t/** The route to forward the message to (e.g., \"general\", \"invites\", \"game\", \"echo\").\n\t * Undefined if it's a reply-only message. */\n\troute?: string;\n\t/** The message contents. For echo messages, this is the message ID being echoed.\n\t * For other messages, this is an object with action and value.\n\t * Absent for reply-only acknowledgement messages (route and action are both undefined). */\n\tcontents?: any;\n\t/** The ID of the message to echo, indicating the connection is still active.\n\t * Or undefined if this message itself is an echo. */\n\tid?: number;\n\t/** Optionally, we can include the id of the incoming message that this outgoing message is the reply to. */\n\treplyto?: number;\n}\n\nimport type { CustomWebSocket } from './socketUtility.js';\n\n// Variables ---------------------------------------------------------------------------\n\n/**\n * The amount of latency to add to websocket replies, in millis. ONLY USE IN DEV!!\n * I recommend 2 seconds of latency for testing slow networks.\n */\nconst simulatedWebsocketLatencyMillis = 0;\n// const simulatedWebsocketLatencyMillis = 1000; // 1 Second\n// const simulatedWebsocketLatencyMillis = 2000; // 2 Seconds\nif (process.env['NODE_ENV'] !== 'development' && simulatedWebsocketLatencyMillis !== 0) {\n\tthrow new Error('simulatedWebsocketLatencyMillis must be 0 in production!!');\n}\n\n// Sending Messages ---------------------------------------------------------------------------\n\n/**\n * Sends a message to this websocket's client.\n * @param ws - The websocket\n * @param route - What subscription/route this message should be forwarded to.\n * @param action - What type of action the client should take within the subscription route.\n * @param value - The contents of the message.\n * @param [replyto] If applicable, the id of the socket message this message is a reply to.\n * @param [options] - Additional options for sending the message.\n * @param [options.skipLatency=false] - If true, we send the message immediately, without waiting for simulated latency again.\n */\nfunction sendSocketMessage(\n\tws: CustomWebSocket,\n\troute: string | undefined,\n\taction: string | undefined,\n\tvalue?: any,\n\treplyto?: number,\n\t{ skipLatency }: { skipLatency?: boolean } = {},\n): void {\n\t// socket, invites, createinvite, inviteinfo, messageIDReplyingTo\n\t// If we're applying simulated latency delay, set a timer to send this message.\n\tif (simulatedWebsocketLatencyMillis !== 0 && !skipLatency) {\n\t\tsetTimeout(() => {\n\t\t\tsendSocketMessage(ws, route, action, value, replyto, { skipLatency: true });\n\t\t}, simulatedWebsocketLatencyMillis);\n\t\treturn;\n\t}\n\n\tif (ws.readyState === WebSocket.CLOSED) {\n\t\tconst errText = `Websocket is in a CLOSED state, can't send message. Action: ${action}. Value: ${jsutil.ensureJSONString(value)}\\nSocket: ${socketUtility.stringifySocketMetadata(ws)}`;\n\t\tlogEventsAndPrint(errText, 'errLog.txt');\n\t\treturn;\n\t}\n\n\tconst isEcho = action === 'echo';\n\t// Reply-only messages should have no empty \"contents\" field\n\tconst isReplyOnly = route === undefined;\n\tconst payload: WebsocketOutMessage = isEcho\n\t\t? {\n\t\t\t\troute: 'echo',\n\t\t\t\tcontents: value, // For echo, value contains the message ID\n\t\t\t\treplyto,\n\t\t\t}\n\t\t: isReplyOnly\n\t\t\t? {\n\t\t\t\t\tid: uuid.generateNumbID(10),\n\t\t\t\t\treplyto,\n\t\t\t\t}\n\t\t\t: {\n\t\t\t\t\troute,\n\t\t\t\t\tcontents: {\n\t\t\t\t\t\taction,\n\t\t\t\t\t\tvalue,\n\t\t\t\t\t},\n\t\t\t\t\tid: uuid.generateNumbID(10), // Only include an id (and accept an echo back) if this is NOT an echo itself!\n\t\t\t\t\treplyto,\n\t\t\t\t};\n\tconst stringifiedPayload = JSON.stringify(payload);\n\n\t// if (!isEcho) console.log(`Sending: ${stringifiedPayload}`);\n\n\tws.send(stringifiedPayload); // Send the message\n\tif (!isEcho) {\n\t\t// Not an echo\n\t\tlogReqWebsocketOut(ws, stringifiedPayload); // Log the sent message\n\n\t\t// Set a timer. At the end, if we have heard no echo, just assume they've disconnected, terminate the socket.\n\t\tconst timeout = setTimeout(() => {\n\t\t\tws.close(1014, 'No echo heard');\n\t\t\tdeleteEchoTimerForMessageID(payload.id!);\n\t\t}, timeToWaitForEchoMillis); // We pass in an arrow function so it doesn't lose scope of ws.\n\t\t//console.log(`Set timer of message id \"${id}\"`)\n\t\taddTimeoutToEchoTimers(payload.id!, timeout);\n\n\t\trescheduleRenewConnection(ws);\n\t}\n}\n\n/**\n * Sends a notification message to the client through the WebSocket connection, to be displayed on-screen.\n * @param ws - The WebSocket connection object.\n * @param translationCode - The code corresponding to the message that needs to be retrieved for language-specific translation. For example, `\"server.javascript.ws-already_in_game\"`.\n * @param [options] - An object containing additional options.\n * @param [options.replyto] - The ID of the incoming WebSocket message to which this message is replying.\n */\nfunction sendNotify(\n\tws: CustomWebSocket,\n\ttranslationCode: TranslationKeys,\n\t{ replyto }: { replyto?: number } = {},\n): void {\n\tconst i18next = ws.metadata.cookies.i18next;\n\tconst text = getTranslation(translationCode, i18next);\n\tsendSocketMessage(ws, 'general', 'notify', text, replyto);\n}\n\n/**\n * Sends a message to the client through the websocket, to be displayed on-screen as an ERROR.\n * @param ws - The socket\n * @param translationCode - The code of the message to retrieve the language-specific translation for. For example, `\"server.javascript.ws-already_in_game\"`\n */\nfunction sendNotifyError(ws: CustomWebSocket, translationCode: TranslationKeys): void {\n\tsendSocketMessage(\n\t\tws,\n\t\t'general',\n\t\t'notifyerror',\n\t\tgetTranslation(translationCode, ws.metadata.cookies.i18next),\n\t);\n}\n\n// Renewing Connection if we haven't sent a message in a while ----------------------------------------------------------\n\n/**\n * Reschedule the timer to send an empty message to the client\n * to verify they are still connected and responding.\n */\nfunction rescheduleRenewConnection(ws: CustomWebSocket): void {\n\tcancelRenewConnectionTimer(ws);\n\t// Only reset the timer if they have at least one subscription!\n\tif (Object.keys(ws.metadata.subscriptions).length === 0) return; // No subscriptions\n\n\tws.metadata.renewConnectionTimeoutID = setTimeout(\n\t\t() => renewConnection(ws),\n\t\twsutil.timeOfInactivityToRenewConnection,\n\t);\n}\n\nfunction cancelRenewConnectionTimer(ws: CustomWebSocket): void {\n\tclearTimeout(ws.metadata.renewConnectionTimeoutID);\n\tws.metadata.renewConnectionTimeoutID = undefined;\n}\n\n/**\n * Send an empty message to the client, expecting an echo\n * within five seconds to make sure they are still connected.\n */\nfunction renewConnection(ws: CustomWebSocket): void {\n\tsendSocketMessage(ws, 'general', 'renewconnection');\n}\n\nexport { sendSocketMessage, sendNotify, sendNotifyError, rescheduleRenewConnection };\n"
  },
  {
    "path": "src/server/socket/socketManager.ts",
    "content": "// src/server/socket/socketManager.ts\n\n/**\n * This script stores all open websockets organized by ID, IP, and session.\n *\n * This contains methods for terminating all websockets by given criteria,\n * Rate limiting the socket count per user,\n * And unsubbing a socket from subscriptions.\n */\n\nimport type { CustomWebSocket } from './socketUtility.js';\n\nimport uuid from '../../shared/util/uuid.js';\n\nimport { handleUnsubbing } from './generalrouter.js';\n\n// Variables ---------------------------------------------------------------------------\n\n/**\n * An object containing all active websocket connections, with their ID's for the keys: `{ 21: websocket }`\n */\nconst websocketConnections: { [id: string]: CustomWebSocket } = {}; // Object containing all active web socket connections, with their ID's for the KEY\n/**\n * An object with IP addresses for the keys, and arrays of their\n * socket id's they have open for the value: `{ \"83.28.68.253\": ['fighe26'] }`\n */\nconst connectedIPs: { [IP: string]: string[] } = {}; // Keys are the IP. Values are array lists containing all connection IDs they have going.\n/**\n * An object with refresh tokens for the keys, and arrays of their\n * socket id's they have open for the value: `{ uHrU85835...: ['fighe26'] }`\n */\nconst connectedSessions: { [username: string]: string[] } = {};\n\n/**\n * A mapping of user IDs to arrays of socket IDs representing their active WebSocket connections.\n */\nconst connectedMembers: { [user_id: string]: string[] } = {};\n\nconst maxSocketsAllowedPerIP = 10;\nconst maxSocketsAllowedPerSession = 5;\n\n/**\n * The maximum age a websocket connection will live before auto terminating, in milliseconds.\n * Users have to provide authentication whenever they open a new socket.\n */\nconst maxWebSocketAgeMillis = 1000 * 60 * 15; // 15 minutes.\n// const maxWebSocketAgeMillis = 1000 * 10; // 10 seconds for dev testing\n\n// Adding / Removing from the lists ---------------------------------------------------------------------------\n\nfunction addConnectionToConnectionLists(ws: CustomWebSocket): void {\n\twebsocketConnections[ws.metadata.id] = ws;\n\taddConnectionToList(connectedIPs, ws.metadata.IP, ws.metadata.id); // Add IP connection\n\tif (ws.metadata.cookies.jwt)\n\t\taddConnectionToList(connectedSessions, ws.metadata.cookies.jwt, ws.metadata.id); // Add session connection\n\tif (ws.metadata.memberInfo.signedIn)\n\t\taddConnectionToList(connectedMembers, ws.metadata.memberInfo.user_id, ws.metadata.id); // Add user connection\n\n\tstartTimerToExpireSocket(ws);\n\t// console.log(\n\t// \t`New WebSocket connection established. Socket count: ${Object.keys(websocketConnections).length}. Metadata: ${socketUtility.stringifySocketMetadata(ws)}`,\n\t// );\n}\n\n/**\n * Adds a socket ID to the specified collection under the provided key.\n * @param collection - The collection (e.g., connectedIPs, connectedSessions, etc.)\n * @param key - The key in the collection (e.g., IP, session ID, user ID)\n * @param id - The socket ID to add to the collection.\n */\nfunction addConnectionToList(\n\tcollection: { [key: string]: string[] },\n\tkey: number | string,\n\tid: string,\n): void {\n\tif (!collection[key]) collection[key] = []; // Initialize the array if it doesn't exist\n\tcollection[key].push(id); // Add the socket ID to the list\n}\n\nfunction startTimerToExpireSocket(ws: CustomWebSocket): void {\n\tws.metadata.clearafter = setTimeout(\n\t\t() => ws.close(1000, 'Connection expired'),\n\t\tmaxWebSocketAgeMillis,\n\t); // We pass in an arrow function so it doesn't lose scope of ws.\n}\n\n/**\n * Removes the given WebSocket connection from all tracking lists.\n * @param ws - The WebSocket connection to remove.\n * @param _code - The WebSocket closure code.\n * @param _reason - The reason for the WebSocket closure.\n */\nfunction removeConnectionFromConnectionLists(\n\tws: CustomWebSocket,\n\t_code: number,\n\t_reason: string,\n): void {\n\tdelete websocketConnections[ws.metadata.id];\n\tremoveConnectionFromList(connectedIPs, ws.metadata.IP, ws.metadata.id); // Remove IP connection\n\tif (ws.metadata.cookies.jwt)\n\t\tremoveConnectionFromList(connectedSessions, ws.metadata.cookies.jwt, ws.metadata.id); // Remove session connection\n\tif (ws.metadata.memberInfo.signedIn)\n\t\tremoveConnectionFromList(connectedMembers, ws.metadata.memberInfo.user_id, ws.metadata.id); // Remove member connection\n\n\tclearTimeout(ws.metadata.clearafter); // Cancel the timer to auto delete it at the end of its life\n\t// console.log(\n\t// \t`WebSocket connection has been closed. Code: ${_code}. Reason: ${_reason}. Socket count: ${Object.keys(websocketConnections).length}`,\n\t// );\n}\n\n/**\n * Removes a socket ID from the specified collection under the provided key.\n * @param collection - The collection (e.g., connectedIPs, connectedSessions, etc.)\n * @param key - The key in the collection (e.g., IP, session ID, user ID)\n * @param id - The socket ID to remove from the collection.\n */\nfunction removeConnectionFromList(\n\tcollection: { [key: string]: string[] },\n\tkey: string | number,\n\tid: string,\n): void {\n\tif (key === undefined || !collection[key]) return; // No key or collection doesn't exist\n\tconst index = collection[key].indexOf(id);\n\tif (index !== -1) {\n\t\tcollection[key].splice(index, 1); // Remove the socket ID from the list\n\t\t// Clean up if no connections left\n\t\tif (collection[key].length === 0) delete collection[key];\n\t}\n}\n\n// Terminating all sockets of criteria ---------------------------------------------------------------------------\n\nfunction terminateAllIPSockets(IP: string): void {\n\tconst connectionList = connectedIPs[IP];\n\tif (connectionList === undefined) return; // IP is defined, but they don't have any sockets to terminate!\n\tfor (const id of connectionList) {\n\t\t//console.log(`Terminating 1.. id ${id}`)\n\t\tconst ws = websocketConnections[id];\n\t\tws?.close(1009, 'Message Too Big');\n\t}\n\n\t// console.log(`Terminated all of IP ${IP}`)\n\t// console.log(connectedIPs) // This will still be full because they aren't actually spliced out of their list until the close() is complete!\n}\n\n/**\n * Closes all sockets a given member has open.\n * @param jwt - The member's session/refresh token.\n * @param closureCode - The code of the socket closure, sent to the client.\n * @param closureReason - The closure reason, sent to the client.\n */\nfunction closeAllSocketsOfSession(jwt: string, closureCode: number, closureReason: string): void {\n\tconnectedSessions[jwt]?.slice().forEach((socketID) => {\n\t\t// slice() makes a copy of it\n\t\tconst ws = websocketConnections[socketID];\n\t\tif (!ws) return;\n\t\tws.close(closureCode, closureReason);\n\t});\n}\n\n/**\n * Closes all sockets associated with a given user ID.\n * @param user_id - The unique ID of the user.\n * @param closureCode - The code for closing the socket, sent to the client.\n * @param closureReason - The reason for closure, sent to the client.\n */\nfunction closeAllSocketsOfMember(\n\tuser_id: number,\n\tclosureCode: number,\n\tclosureReason: string,\n): void {\n\tconst socketIDs = connectedMembers[user_id];\n\tif (!socketIDs) return; // This member doesn't have any connected sockets\n\n\tsocketIDs.slice().forEach((socketID) => {\n\t\t// slice() makes a copy of it\n\t\tconst ws = websocketConnections[socketID];\n\t\tif (!ws) return;\n\t\tws.close(closureCode, closureReason);\n\t});\n}\n\n/**\n * Sets the metadata.verified entry of all sockets of a given user to true.\n * @param user_id - The unique ID of the user.\n */\nfunction AddVerificationToAllSocketsOfMember(user_id: number): void {\n\tconst socketIDs = connectedMembers[user_id];\n\tif (!socketIDs) return; // This member doesn't have any connected sockets\n\n\tsocketIDs.slice().forEach((socketID) => {\n\t\t// slice() makes a copy of it\n\t\tconst ws = websocketConnections[socketID];\n\t\tif (!ws) return;\n\t\tws.metadata.verified = true;\n\t});\n}\n\n// Limiting the socket count per user ---------------------------------------------------------------------------\n\n/**\n * Returns true if the given IP has the maximum number of websockets opened.\n * @param IP - The IP address\n * @returns *true* if they have too many sockets.\n */\nfunction doesClientHaveMaxSocketCount(IP: string): boolean {\n\tif (connectedIPs[IP] === undefined) return false;\n\treturn connectedIPs[IP].length >= maxSocketsAllowedPerIP;\n}\n\n/**\n * Returns true if the given member has the maximum number of websockets opened.\n * @param jwt - The member's session/refresh token, if they are signed in.\n * @returns *true* if they have too many sockets.\n */\nfunction doesSessionHaveMaxSocketCount(jwt: string): boolean {\n\tif (connectedSessions[jwt] === undefined) return false;\n\treturn connectedSessions[jwt].length >= maxSocketsAllowedPerSession;\n}\n\n// Unsubbing ---------------------------------------------------------------------------\n\n// Set closureNotByChoice to true if you don't immediately want to disconnect them, but say after 5 seconds\nfunction unsubSocketFromAllSubs(ws: CustomWebSocket, closureNotByChoice: boolean): void {\n\tif (!ws.metadata.subscriptions) return; // No subscriptions\n\n\tconst subscriptions = ws.metadata.subscriptions;\n\tconst subscriptionsKeys = Object.keys(subscriptions) as Array<keyof typeof subscriptions>;\n\tfor (const key of subscriptionsKeys) handleUnsubbing(ws, key, closureNotByChoice);\n}\n\n// Miscellaneous ---------------------------------------------------------------------------\n\nfunction generateUniqueIDForSocket(): string {\n\treturn uuid.genUniqueID(4, websocketConnections);\n}\n\nexport {\n\taddConnectionToConnectionLists,\n\tremoveConnectionFromConnectionLists,\n\tterminateAllIPSockets,\n\tdoesClientHaveMaxSocketCount,\n\tdoesSessionHaveMaxSocketCount,\n\tgenerateUniqueIDForSocket,\n\tunsubSocketFromAllSubs,\n\tcloseAllSocketsOfSession,\n\tcloseAllSocketsOfMember,\n\tAddVerificationToAllSocketsOfMember,\n};\n"
  },
  {
    "path": "src/server/socket/socketRouter.ts",
    "content": "// src/server/socket/socketRouter.ts\n\n/**\n * This script receives routes incoming socket messages them where they need to go.\n *\n *\n * It also handles subbing to subscription lists.\n */\n\nimport type { CustomWebSocket } from './socketUtility.js';\nimport type { WebsocketInMessage } from './receiveSocketMessage.js';\n\nimport { routeGameMessage } from '../game/gamemanager/gamerouter.js';\nimport { routeGeneralMessage } from './generalrouter.js';\nimport { routeInvitesMessage } from '../game/invitesmanager/invitesrouter.js';\n\n// Functions ---------------------------------------------------------------------------\n\nfunction routeIncomingSocketMessage(ws: CustomWebSocket, message: WebsocketInMessage): void {\n\t// Route them to their specified location\n\tswitch (message.route) {\n\t\tcase 'general':\n\t\t\trouteGeneralMessage(ws, message.contents);\n\t\t\tbreak;\n\t\tcase 'invites':\n\t\t\trouteInvitesMessage(ws, message.contents, message.id);\n\t\t\tbreak;\n\t\tcase 'game':\n\t\t\trouteGameMessage(ws, message.contents, message.id);\n\t\t\tbreak;\n\t\tdefault:\n\t\t\t// @ts-ignore\n\t\t\tconsole.error(`UNKNOWN web socket route received! \"${message.route}\"`);\n\t}\n}\n\nexport { routeIncomingSocketMessage };\n"
  },
  {
    "path": "src/server/socket/socketServer.ts",
    "content": "// src/server/socket/socketServer.ts\n\nimport type { Server as HttpsServer } from 'https';\n\nimport WebSocket from 'ws';\nimport { IncomingMessage } from 'http';\nimport { WebSocketServer as Server } from 'ws';\n\nimport { executeSafely } from '../utility/errorGuard.js';\nimport { onConnectionRequest } from './openSocket.js';\n\nlet WebSocketServer: Server;\n\nfunction start(httpsServer: HttpsServer): void {\n\tWebSocketServer = new Server({ server: httpsServer }); // Create a WebSocket server instance\n\t// WebSocketServer.on('connection', onConnectionRequest); // Event handler for new WebSocket connections\n\tWebSocketServer.on('connection', (socket: WebSocket, req: IncomingMessage) => {\n\t\texecuteSafely(\n\t\t\t() => onConnectionRequest(socket, req),\n\t\t\t'Error caught within websocket on-connection request:',\n\t\t);\n\t}); // Event handler for new WebSocket connections\n}\n\nexport default {\n\tstart,\n};\n"
  },
  {
    "path": "src/server/socket/socketUtility.ts",
    "content": "// src/server/socket/socketUtility.ts\n\n// This script contains generalized methods for working with websocket objects.\n\nimport type WebSocket from 'ws';\nimport type { IncomingMessage } from 'http'; // Used for the socket upgrade http request TYPE\nimport type { Player } from '../../shared/chess/util/typeutil.js';\nimport type { AuthMemberInfo, ParsedCookies } from '../types.js';\n\nimport jsutil from '../../shared/util/jsutil.js';\n\n// Types --------------------------------------------------------------------------------------\n\n/** The socket object that contains all properties a normal socket has,\n * plus an additional `metadata` property that we define ourselves. */\ninterface CustomWebSocket extends WebSocket {\n\t/** Our custom-entered information about this websocket. */\n\tmetadata: {\n\t\t/** What subscription lists they are subscribed to. Possible: \"invites\" / \"game\" */\n\t\tsubscriptions: {\n\t\t\t/** Whether they are subscribed to the invites list. */\n\t\t\tinvites?: boolean;\n\t\t\t/** Will be defined if they are subscribed to, or in, a game. */\n\t\t\tgame?: {\n\t\t\t\t/** The id of the game they're in. */\n\t\t\t\tid: number;\n\t\t\t\t/** The color they are playing as. */\n\t\t\t\tcolor: Player;\n\t\t\t};\n\t\t};\n\t\t/** The parsed cookie object */\n\t\tcookies: ParsedCookies;\n\t\t/** The user-agent property of the original websocket upgrade's req.headers */\n\t\tuserAgent?: string;\n\t\tmemberInfo: AuthMemberInfo;\n\t\t/** The account verification status of the user */\n\t\tverified: boolean;\n\t\t/** The id of their websocket. */\n\t\tid: string;\n\t\t/** The socket's IP address. */\n\t\tIP: string;\n\t\t/** The timeout ID that can be used to cancel the timer that will\n\t\t * expire the socket connection. This is useful if it closes early. */\n\t\tclearafter?: NodeJS.Timeout;\n\t\t/** The timeout ID to cancel the timer that will send an empty\n\t\t * message to this socket just to verify they are alive and thinking. */\n\t\trenewConnectionTimeoutID?: NodeJS.Timeout;\n\t};\n}\n\n// Functions ---------------------------------------------------------------------------\n\n/**\n * Prints the websocket to the console, temporarily removing self-referencing first.\n * @param ws - The websocket\n */\nfunction printSocket(ws: CustomWebSocket): void {\n\tconsole.log(stringifySocketMetadata(ws));\n}\n\n/**\n * Simplifies the websocket's metadata and stringifies it.\n * @param ws - The websocket object\n * @returns The stringified simplified websocket metadata.\n */\nfunction stringifySocketMetadata(ws: CustomWebSocket): string {\n\t// Removes the recursion from the metadata, making it safe to stringify.\n\tconst simplifiedMetadata = getSimplifiedMetadata(ws);\n\treturn jsutil.ensureJSONString(simplifiedMetadata, 'Error while stringifying socket metadata:');\n}\n\n/**\n * Creates a new object with simplified metadata information from the websocket,\n * and removes recursion. This can be safely be JSON.stringified() afterward.\n * Excludes the stuff like the sendmessage() function and clearafter timer.\n *\n * BE CAREFUL not to modify the return object, for it will modify the original socket!\n * @param ws - The websocket object\n * @returns A new object containing simplified metadata.\n */\nfunction getSimplifiedMetadata(ws: CustomWebSocket): Partial<CustomWebSocket['metadata']> {\n\tconst metadata = ws.metadata;\n\t// Using Partial takes an existing type and makes all of its properties optional\n\tconst metadataCopy: Partial<typeof metadata> = {\n\t\tmemberInfo: jsutil.deepCopyObject(metadata.memberInfo),\n\t\tcookies: {\n\t\t\t'browser-id': ws.metadata.cookies['browser-id'],\n\t\t\ti18next: ws.metadata.cookies['i18next'],\n\t\t}, // Only copy these 2 cookies, NOT their refresh token!!!\n\t\tverified: metadata.verified,\n\t\tid: metadata.id,\n\t\tIP: metadata.IP,\n\t\tsubscriptions: jsutil.deepCopyObject(metadata.subscriptions),\n\t};\n\n\treturn metadataCopy;\n}\n\n/**\n * Parses cookies from the WebSocket upgrade request headers.\n * @param req - The WebSocket upgrade request object\n * @returns An object with cookie names as keys and their corresponding values\n */\nfunction getCookiesFromWebsocket(req: IncomingMessage): { [cookieName: string]: string } {\n\t// req.cookies is only defined from our cookie parser for regular requests,\n\t// NOT for websocket upgrade requests! We have to parse them manually!\n\n\tconst rawCookies = req.headers.cookie;\n\tconst cookies: { [cookieName: string]: string } = {};\n\n\tif (!rawCookies) return cookies;\n\n\tfor (const cookie of rawCookies.split(';')) {\n\t\tconst parts = cookie.split('=');\n\t\tif (parts.length < 2) continue; // Skip if no value part exists\n\n\t\tconst name = parts[0]!.trim();\n\t\tconst value = parts[1]!.trim();\n\n\t\tif (name && value) cookies[name] = value;\n\t}\n\n\treturn cookies;\n}\n\n/**\n * Reads the IP address attached to the incoming websocket connection request,\n * and sets the websocket metadata's `IP` property to that value, then returns that IP.\n * @param req - The request object.\n * @param ws - The websocket object.\n * @returns The IP address of the websocket connection, or `undefined` if not present.\n */\nfunction getIPFromWebsocketUpgradeRequest(req: IncomingMessage): string | undefined {\n\t// Check the headers for the forwarded IP (useful if behind a proxy like Cloudflare)\n\tconst clientIP = req.headers['x-forwarded-for'] || req.socket.remoteAddress; // ws._socket.remoteAddress\n\n\t// If we didn't get a string IP, return undefined\n\tif (typeof clientIP !== 'string') return undefined;\n\n\treturn clientIP;\n}\n\nexport default {\n\tprintSocket,\n\tstringifySocketMetadata,\n\tgetCookiesFromWebsocket,\n\tgetIPFromWebsocketUpgradeRequest,\n};\n\nexport type { CustomWebSocket };\n"
  },
  {
    "path": "src/server/types.ts",
    "content": "// src/server/types.ts\n\nimport type { Role } from './controllers/roles';\n\nimport { Request } from 'express';\n\n/**\n * A req object, but with their memberInfo defined. This may include\n * information about their signed-in status, or their browser-id cookie.\n * Point is we now have an identifier for them.\n */\ninterface IdentifiedRequest extends Request {\n\tmemberInfo: MemberInfo;\n}\n\n/**\n * Single source of truth for determining whether a req object has been\n * given all properties required for the {@link IdentifiedRequest} type.\n */\nfunction isRequestIdentified(req: Request): req is IdentifiedRequest {\n\treturn !!req.memberInfo;\n}\n\n/** Information to identify a specific user, logged in or not. */\ntype MemberInfo = SignedInMemberInfo | SignedOutMemberInfo;\n\ntype SignedInMemberInfo = {\n\tsignedIn: true;\n\tuser_id: number;\n\tusername: string;\n\troles: Role[] | null;\n\tbrowser_id?: string;\n};\n\ntype SignedOutMemberInfo = {\n\tsignedIn: false;\n\tbrowser_id?: string;\n};\n\n/**\n * @type {MemberInfo}, but the browser_id is guaranteed to be defined.\n * This means the user is fully authenticated, cause we only need one\n * identifier to identify them.\n */\ntype AuthMemberInfo = MemberInfo & { browser_id: string };\n\n/** All possible cookies we set on the client. */\ninterface ParsedCookies {\n\t/** The unique id of the browser. Almost always defined, but may not be on first connection, or if client's cookies are disabled. */\n\t'browser-id'?: string;\n\t/** Their preferred language. For example, 'en-US'. This is determined by their `i18next` cookie. */\n\ti18next?: string;\n\t/** Their refresh/session token, if they are signed in. Can be decoded to obtain their payload. */\n\tjwt?: string;\n\t/**\n\t * Information about the session for the user to read.\n\t * The server must NOT trust this information as it can be tampered!\n\t */\n\tmemberInfo?: string; // Stringified: { user_id: number, username: string, issued: number, expires: number }\n}\n\nexport { isRequestIdentified };\n\nexport type { IdentifiedRequest, MemberInfo, AuthMemberInfo, ParsedCookies };\n"
  },
  {
    "path": "src/server/utility/IP.ts",
    "content": "// src/server/utility/IP.ts\n\n/**\n * This module reads the IP address attached to incoming\n * requests and websocket connection requests.\n */\n\nimport type { Request } from 'express';\n\n/**\n * Reads the IP address attached to the incoming request.\n * It prioritizes the 'x-forwarded-for' header, which is commonly used by\n * reverse proxies and load balancers like Cloudflare to convey the original client IP.\n *\n * @param req - The Express request object.\n * @returns The IP address of the request as a string, or `undefined` if not present.\n */\nexport function getClientIP(req: Request): string | undefined {\n\tconst forwardedFor = req.headers['x-forwarded-for'];\n\n\tif (typeof forwardedFor === 'string') {\n\t\t// The 'x-forwarded-for' header can be a comma-separated list of IPs.\n\t\t// The first one is the original client IP.\n\t\treturn forwardedFor.split(',')[0]!.trim();\n\t}\n\n\t// Fallback to req.ip, which is derived from req.socket.remoteAddress\n\t// (and should match the first entry in 'x-forwarded-for' if behind a proxy)\n\treturn req.ip;\n}\n"
  },
  {
    "path": "src/server/utility/errorGuard.ts",
    "content": "// src/server/utility/errorGuard.ts\n\n/**\n * This module contains methods for safely executing functions,\n * catching any errors that may occur, logging them to the error log.\n */\n\nimport { logEventsAndPrint } from '../middleware/logEvents.js';\n\n/**\n * Executes a callback function with provided arguments and catches any errors that occur.\n * @param callback - The function to execute safely.\n * @param errorMessage - A custom error message to log if an error occurs.\n * @param args - Arguments to pass to the callback function.\n * @returns true if the callback executed without error.\n */\nfunction executeSafely(callback: () => void, errorMessage: string): boolean {\n\ttry {\n\t\tcallback();\n\t} catch (e) {\n\t\tconst stack = e instanceof Error ? e.stack : 'Exception is not of Error type!';\n\t\tconst errText = `${errorMessage}\\n${stack}`;\n\t\tlogEventsAndPrint(errText, 'errLog.txt');\n\t\treturn false; // Yes error\n\t}\n\treturn true; // No error\n}\n\n/**\n * A variant of {@link executeSafely} that works with an async function.\n *\n * Executes a callback function with provided arguments and catches any errors that occur.\n * @param callback - The function to execute safely.\n * @param errorMessage - A custom error message to log if an error occurs.\n * @param args - Arguments to pass to the callback function.\n * @returns true if the callback executed without error.\n */\nasync function executeSafely_async(\n\tcallback: () => Promise<void>,\n\terrorMessage: string,\n): Promise<boolean> {\n\ttry {\n\t\tawait callback();\n\t} catch (e) {\n\t\tconst stack = e instanceof Error ? e.stack : 'Exception is not of Error type!';\n\t\tconst errText = `${errorMessage}\\n${stack}`;\n\t\tawait logEventsAndPrint(errText, 'errLog.txt');\n\t\treturn false; // Yes error\n\t}\n\treturn true; // No error\n}\n\nexport { executeSafely, executeSafely_async };\n"
  },
  {
    "path": "src/server/utility/generateDependancyGraph.ts",
    "content": "// src/server/utility/generateDependancyGraph.ts\n\n/*\n * This script generates the dependency tree graph of the project.\n * To use it, enter the command:\n *\n * npm run generate-dependency-graph\n */\n\nimport madge, { MadgeInstance } from 'madge';\n\nconst pathOfFileToGenerateDependencyGraphFor: string = 'dist/server/server.js'; // Enable for the server-side code\n// const pathOfFileToGenerateDependencyGraphFor = 'dist/client/scripts/esm/game/main.js'; // Enable for the client-side code\nconst nameToGiveDependencyGraph: string = 'dependencyGraph.svg';\n\nmadge(pathOfFileToGenerateDependencyGraphFor)\n\t.then((res: MadgeInstance) => res.image(nameToGiveDependencyGraph))\n\t.then((writtenImagePath: string) => {\n\t\tconsole.log('Dependency graph image written to ' + writtenImagePath);\n\t});\n"
  },
  {
    "path": "src/server/utility/lockFile.ts",
    "content": "// src/server/utility/lockFile.ts\n\n/**\n * This module extends the 'fs' module with methods\n * that lock a file while it is being read/written.\n * If you try to read/write a file while it is locked,\n * you will get an error.\n *\n * This prevents data corruption when multiple code points\n * try to read/write the members file at the same time.\n */\n\nimport fs from 'fs/promises';\nimport lockfile from 'proper-lockfile';\n\n// Locks the file while reading, then immediately unlocks and returns the data.\n// MUST BE CALLED WITH 'await' or this returns a promise!\nasync function readFile<D>(path: string, buffer: BufferEncoding = 'utf-8'): Promise<D> {\n\tlet data: D | undefined;\n\tawait lockfile.lock(path).then(async (releaseFunc) => {\n\t\t// Do something while the file is locked\n\t\ttry {\n\t\t\tconst fileContents = await fs.readFile(path, buffer);\n\t\t\tdata = JSON.parse(fileContents);\n\t\t} finally {\n\t\t\t// Don't CATCH the error, but always release the lock, even if we encounter an error!\n\t\t\treleaseFunc(); // Unlocks file\n\t\t}\n\t});\n\treturn data!; // Guaranteed to be defined, since if it isn't, the function will have thrown anyway.\n}\n\n// Returns false when failed to lock/write file.\n// MUST BE CALLED WITH 'await' or this returns a promise!\nasync function writeFile(path: string, object: any): Promise<void> {\n\tawait lockfile.lock(path).then(async (release) => {\n\t\t// Do something while the file is locked\n\t\ttry {\n\t\t\tawait fs.writeFile(path, JSON.stringify(object, null, 1));\n\t\t} finally {\n\t\t\t// Don't CATCH the error, but always release the lock, even if we encounter an error!\n\t\t\trelease(); // Unlocks file\n\t\t}\n\t});\n}\n\nexport { readFile, writeFile };\n"
  },
  {
    "path": "src/server/utility/mailer.ts",
    "content": "// src/server/utility/mailer.ts\n\n/*\n * This module sets up the email transporter (AWS SES via nodemailer)\n * and exposes a low-level sendMail helper for dispatching prepared emails.\n */\n\nimport nodemailer from 'nodemailer';\nimport { fromEnv } from '@aws-sdk/credential-providers';\nimport { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2';\n\n/** Options for sending an email. */\nexport type SendMailOptions = {\n\tto: string;\n\tsubject: string;\n} & ({ html: string } | { text: string });\n\n// --- Module Setup ---\n\nconst AWS_REGION = process.env['AWS_REGION'];\nconst EMAIL_FROM_ADDRESS = process.env['EMAIL_FROM_ADDRESS'];\nconst AWS_ACCESS_KEY_ID = process.env['AWS_ACCESS_KEY_ID'];\nconst AWS_SECRET_ACCESS_KEY = process.env['AWS_SECRET_ACCESS_KEY'];\n\n/**\n * Who our sent emails will appear as if they're from.\n */\nconst FROM = EMAIL_FROM_ADDRESS;\n\n// Create SES client\nconst sesClient =\n\tAWS_REGION && EMAIL_FROM_ADDRESS && AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY\n\t\t? new SESv2Client({\n\t\t\t\tregion: AWS_REGION,\n\t\t\t\tcredentials: fromEnv(),\n\t\t\t})\n\t\t: null;\n\n// Create nodemailer transporter using SES\nconst transporter = sesClient\n\t? nodemailer.createTransport({\n\t\t\tSES: { sesClient, SendEmailCommand },\n\t\t} as nodemailer.TransportOptions)\n\t: null;\n\n// --- Functions ---\n\n/**\n * Sends a prepared email via the transporter.\n * Logs a message and returns false if env variables are not configured.\n * @param options - Email options including recipient, subject, and content (html or text)\n * @returns Whether the email was sent, which won't be the case if env variables aren't present.\n */\nasync function send(options: SendMailOptions): Promise<boolean> {\n\tif (!transporter) {\n\t\tconsole.log('Email environment variables not specified. Not sending email.');\n\t\treturn false;\n\t}\n\n\tawait transporter.sendMail({\n\t\tfrom: `\"Infinite Chess\" <${FROM}>`,\n\t\t...options,\n\t});\n\n\treturn true;\n}\n\n// --- Exports ---\n\nexport default { FROM, send };\n"
  },
  {
    "path": "src/server/utility/newsUtil.ts",
    "content": "// src/server/utility/newsUtil.ts\n\n/**\n * Utility functions for handling news posts and tracking read status.\n */\n\nimport fs from 'fs';\nimport path from 'path';\nimport { fileURLToPath } from 'url';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n/** The code of the language that is gauranteed to contain all current news posts. */\nconst language_code = 'en-US';\n\n/**\n * Gets the date of the latest news post by reading filenames from the news directory.\n * News posts are named with dates like \"2025-11-01.md\"\n * @returns The date string of the latest news post (e.g., '2025-11-01'), or null if no news posts exist\n */\nfunction getLatestNewsDate(): string | null {\n\tconst newsPath = path.join(__dirname, '../../../translation/news', language_code);\n\n\tif (!fs.existsSync(newsPath)) {\n\t\tconsole.error(`News directory ${language_code} not found`);\n\t\treturn null;\n\t}\n\n\tconst files = fs.readdirSync(newsPath);\n\tconst newsFiles = files.filter((file) => file.endsWith('.md'));\n\n\tif (newsFiles.length === 0) {\n\t\treturn null;\n\t}\n\n\t// Extract dates from filenames (format: YYYY-MM-DD.md)\n\tconst dates = newsFiles.map((file) => file.replace('.md', '')).sort();\n\n\t// Return the most recent date\n\tconst latestDate = dates[dates.length - 1];\n\treturn latestDate !== undefined ? latestDate : null;\n}\n\n/**\n * Gets all news post dates.\n * @returns Array of date strings sorted from oldest to newest\n */\nfunction getAllNewsDates(): string[] {\n\tconst newsPath = path.join(__dirname, '../../../translation/news', language_code);\n\n\tif (!fs.existsSync(newsPath)) {\n\t\treturn [];\n\t}\n\n\tconst files = fs.readdirSync(newsPath);\n\tconst newsFiles = files.filter((file) => file.endsWith('.md'));\n\n\t// Extract dates and sort\n\tconst dates = newsFiles.map((file) => file.replace('.md', '')).sort();\n\treturn dates;\n}\n\n/**\n * Counts the number of unread news posts for a user.\n * @param lastReadDate - The date of the last news post the user read (format: 'YYYY-MM-DD'), or null if never read\n * @returns The number of unread news posts\n */\nfunction countUnreadNews(lastReadDate: string | null): number {\n\tconst allDates = getAllNewsDates();\n\n\tif (allDates.length === 0) {\n\t\treturn 0;\n\t}\n\n\t// If user has never read news, all posts are unread\n\tif (!lastReadDate) {\n\t\treturn allDates.length;\n\t}\n\n\t// Count posts newer than the last read date\n\tconst unreadCount = allDates.filter((date) => date > lastReadDate).length;\n\treturn unreadCount;\n}\n\n/**\n * Gets the dates of unread news posts for a user.\n * @param lastReadDate - The date of the last news post the user read, or null if never read\n * @returns Array of unread news post dates\n */\nfunction getUnreadNewsDates(lastReadDate: string | null): string[] {\n\tconst allDates = getAllNewsDates();\n\n\tif (allDates.length === 0) {\n\t\treturn [];\n\t}\n\n\t// If user has never read news, all posts are unread\n\tif (!lastReadDate) {\n\t\treturn allDates;\n\t}\n\n\t// Return posts newer than the last read date\n\treturn allDates.filter((date) => date > lastReadDate);\n}\n\nexport { getLatestNewsDate, countUnreadNews, getUnreadNewsDates };\n"
  },
  {
    "path": "src/server/utility/startupLogger.ts",
    "content": "// src/server/utility/startupLogger.ts\n\n/**\n * This module logs server startup and shutdown events to a log file.\n */\n\nimport fs from 'fs';\nimport path from 'path';\nimport { format } from 'date-fns';\n\nimport paths from '../config/paths.js';\n\n// Helpers -------------------------------------------------------------------------------\n\n/** Writes a log entry to logs/startupLog.txt with the provided message and a timestamp. */\nfunction writeStartupLog(message: string): void {\n\tconst timestamp = format(new Date(), 'yyyy-MM-dd HH:mm:ss');\n\tconst line = `${timestamp} | ${message}. PID: ${process.pid}\\n`;\n\ttry {\n\t\tfs.mkdirSync(paths.LOGS_DIR, { recursive: true });\n\t\tfs.appendFileSync(path.join(paths.LOGS_DIR, 'startupLog.txt'), line);\n\t} catch (err) {\n\t\tconsole.error('Failed to write to startupLog.txt:', err);\n\t}\n}\n\n// API -----------------------------------------------------------------------------------\n\n/** Logs a server startup entry to logs/startupLog.txt. */\nfunction logServerStarted(): void {\n\twriteStartupLog('🟢 Server started');\n}\n\n/**\n * Logs a server shutdown entry to logs/startupLog.txt.\n * Uses synchronous I/O so the write completes before process.exit().\n * @param signal - The signal that triggered the shutdown (e.g. 'SIGTERM').\n */\nfunction logServerStopped(signal: string): void {\n\twriteStartupLog(`🔴 Server stopped (${signal})`);\n}\n\n// Exports -------------------------------------------------------------------------------\n\nexport { logServerStarted, logServerStopped };\n"
  },
  {
    "path": "src/server/utility/translate.ts",
    "content": "// src/server/utility/translate.ts\n\n/**\n * Retrieves the translation for the code and language specified.\n */\n\nimport type { Request } from 'express';\nimport type { TranslationKeys } from '../../types/translations.js';\n\nimport i18next from 'i18next';\n\n// Constnats -----------------------------------------------------------------\n\nconst DEFAULT_LANGUAGE = 'en-US';\n\n// Functions -----------------------------------------------------------------\n\n/**\n * Determines the language to be used for serving an HTML file to a request.\n * The language is determined in the following order of precedence:\n * 1. The 'lng' query parameter, which can be different than the others.\n * 2. The 'i18next' cookie, which can also be different than the others.\n * 3. The value of req.i18n.resolvedLanguage (typical of users' first-connection to the site),\n * which is ALWAYS defined! This is determined by several different factors,\n * but i18next also takes into account the 'Accept-Language' header for this property.\n * 4. A default language, if none of the above are supported.\n *\n * The selected language is validated against supported languages,\n * using a default language if none are supported.\n * @param req - The Express request object.\n * @returns The language to be used.\n */\nfunction getLanguageToServe(req: Request): string {\n\tconst cookies = req.cookies;\n\n\tconst supportedLngs = i18next.options.supportedLngs;\n\tif (!(supportedLngs instanceof Array)) {\n\t\tthrow new Error('i18next.options.supportedLngs was not set');\n\t}\n\n\tlet language = req.query['lng'] || cookies['i18next'] || req.i18n.resolvedLanguage;\n\tif (!supportedLngs.includes(language)) language = cookies['i18next']; // Query param language not supported\n\tif (!supportedLngs.includes(language)) language = req.i18n.resolvedLanguage; // Cookie language not supported\n\tif (!supportedLngs.includes(language)) language = DEFAULT_LANGUAGE; // Resolved language from i18next not supported\n\treturn language;\n}\n\n/**\n * Retrieves the translation for a given key and language.\n * @param key - The translation key to look up. For example, `\"play.javascript.termination.checkmate\"`\n * @param language - The language code for the translation. Default: `\"en-US\"`\n * @param options - Additional i18next options (e.g., returnObjects for array translations)\n * @returns The translated string or object.\n */\nfunction getTranslation(key: TranslationKeys, language: string = DEFAULT_LANGUAGE): string {\n\tconst options = { lng: language };\n\treturn i18next.t(key, options);\n}\n\n/**\n * Retrieves the translation for a given key and req. It reads the req's cookies for its preferred language.\n * @param key - The translation key to look up. For example, `\"play.javascript.termination.checkmate\"`\n * @param req - The request object\n * @returns The translated string.\n */\nfunction getTranslationForReq(key: TranslationKeys, req: Request): string {\n\tconst language = getLanguageToServe(req);\n\treturn getTranslation(key, language);\n}\n\n// Exports -------------------------------------------------------------------\n\nexport { DEFAULT_LANGUAGE, getLanguageToServe, getTranslation, getTranslationForReq };\n"
  },
  {
    "path": "src/server/utility/urlUtils.ts",
    "content": "// src/server/utility/urlUtils.ts\n\nimport 'dotenv/config'; // Imports all properties of process.env, if it exists\n\n/**\n * Gets the base URL for the application, respecting the environment.\n * @returns The full base URL for the current environment.\n */\nexport function getAppBaseUrl(): string {\n\tif (process.env['NODE_ENV'] !== 'production') {\n\t\t// In development, construct the localhost URL\n\t\treturn `https://localhost:${process.env['HTTPSPORT_LOCAL']}`;\n\t} else {\n\t\t// In production, use the base URL from the environment variables\n\t\treturn process.env['APP_BASE_URL']!;\n\t}\n}\n"
  },
  {
    "path": "src/server/utility/zodlogger.ts",
    "content": "// src/server/utility/zodlogger.ts\n\nimport * as z from 'zod';\n\nimport { logEvents, logEventsAndPrint } from '../middleware/logEvents.js';\n\n/**\n * A consistent way of logging all malformed incoming messages,\n * whether websocket message, API request, etc.\n * Puts all details in `zodLog.txt`, and a one-liner notifier in `errLog.txt` and in the console.\n * @param json - The pre-parsed JSON message that was malformed.\n * @param zodError - The ZodError from the zod result during validation.\n * @param contextMessage - Brief description of where this error occurred. e.g. \"Received malformed websocket in-message.\"\n */\nexport function logZodError(json: any, zodError: z.ZodError, contextMessage: string): void {\n\tconst treeifiedErrors = JSON.stringify(z.treeifyError(zodError), null, 2);\n\tconst logText = `${contextMessage} - Message contents:\n${JSON.stringify(json, null, 2)}\n\nZod treeified errors:\n${treeifiedErrors}\n\n===================================================================\n\n\t`;\n\tlogEvents(logText, 'zodLog.txt');\n\tlogEventsAndPrint(`${contextMessage} - Check zodLog.txt for more details.`, 'errLog.txt');\n}\n"
  },
  {
    "path": "src/shared/chess/logic/boardchanges.ts",
    "content": "// src/shared/chess/logic/boardchanges.ts\n\n/**\n * This script both contructs the changes list of a Move, and executes them\n * when requested, modifying the piece lists according to what moved\n * or was captured, forward or backward.\n *\n * The change functions here do NOT modify the mesh or animate anything,\n * however, graphicalchanges.ts may rely on these changes present to\n * know how to change the mesh, or what to animate.\n */\n\nimport jsutil from '../../util/jsutil.js';\nimport typeutil from '../util/typeutil.js';\nimport boardutil from '../util/boardutil.js';\nimport organizedpieces from './organizedpieces.js';\nimport coordutil, { CoordsKey } from '../util/coordutil.js';\n\n// Variables -------------------------------------------------------------------------\n\n/** All Change actions that cannot be undone to return to the same board position later in the game, unless in the future it's possible to add pieces mid-game. */\nconst oneWayActions: string[] = ['capture', 'delete'];\n\n// Type Definitions-------------------------------------------------------------------------\n\nimport type { MoveFull } from './movepiece.js';\nimport type { Coords } from '../util/coordutil.js';\nimport type { Piece } from '../util/boardutil.js';\nimport type { FullGame } from './gamefile.js';\n\n/**\n * Generic type to describe any changes to the board\n */\ntype Change = {\n\t/** Whether this change affects the main piece moved.\n\t * This would be true if the change was for moving the king during castling, but false for moving the rook. */\n\tmain: boolean;\n\t/** The main piece affected by the move. If this is a move/capture action, it's the piece moved. If it's an add/delete action, it's the piece added/deleted. */\n\tpiece: Piece;\n} & (\n\t| {\n\t\t\t/** The type of action this change performs. */\n\t\t\taction: 'add' | 'delete';\n\t  }\n\t| {\n\t\t\taction: 'move';\n\t\t\tendCoords: Coords;\n\t\t\tpath?: Coords[];\n\t  }\n\t| {\n\t\t\taction: 'capture';\n\t\t\t/**\n\t\t\t * This is used by animations to tell when this piece was captured.\n\t\t\t * 0 based. 1 means the piece was captured at the 2nd path point.\n\t\t\t * `-1` implies the end of the path the piece moved along\n\t\t\t */\n\t\t\torder: number;\n\t  }\n);\n\n/**\n * A generic function that takes the changes list of a move, and modifies either\n * the piece lists to reflect that move, or modifies the mesh of the pieces,\n * depending on the function, BUT NOT BOTH.\n */\ntype genericChangeFunc<T> = (_actiondata: T, _change: Change) => void;\n\n/**\n * An actionlist is a dictionary links actions to functions.\n * The function uses the change data for operations. Eg animation, updating mesh, logic\n * It won't always include every action.\n * If an action is looked up and there isn't a function for it, it's change is ignored\n */\ninterface ActionList<F extends CallableFunction> {\n\t[actionName: string]: F;\n}\n\n/**\n * A change application is used for applying the changelist of a move in both directions.\n */\ninterface ChangeApplication<F extends CallableFunction> {\n\tforward: ActionList<F>;\n\tbackward: ActionList<F>;\n}\n\n/**\n * An object mapping move changes to a function that performs the piece list changes for that action.\n */\nconst changeFuncs: ChangeApplication<genericChangeFunc<FullGame>> = {\n\tforward: {\n\t\tadd: addPiece,\n\t\tdelete: deletePiece,\n\t\tmove: movePiece,\n\t\tcapture: deletePiece,\n\t},\n\tbackward: {\n\t\tdelete: addPiece,\n\t\tadd: deletePiece,\n\t\tmove: returnPiece,\n\t\tcapture: addPiece,\n\t},\n};\n\n// Adding changes to a Move ----------------------------------------------------------------------------------------\n\n/**\n * Queues a move with catpure\n * Need to differentiate this from move so animations can work and so that royal capture can be recognised\n * @param changes\n * @param piece The piece captured.\n * @param main Whether this change is affecting the main piece moved, not a secondary piece.\n * @param order This is used by animations to tell when this piece was captured. `-1` implies the end of the path the piece moved along\n */\nfunction queueCapture(\n\tchanges: Array<Change>,\n\tmain: boolean,\n\tpiece: Piece,\n\torder: number = -1,\n): Change[] {\n\tconst change: Change = { action: 'capture', main, piece, order };\n\tchanges.push(change);\n\treturn changes;\n}\n\n/**\n * Queues the addition of a piece to the board\n * @param changes\n * @param piece the piece to add\n * the pieces index is optional and will get assigned one if none is present\n */\nfunction queueAddPiece(changes: Array<Change>, piece: Piece): Change[] {\n\tchanges.push({ action: 'add', main: false, piece }); // It's impossible for an 'add' change to affect the main piece moved, because before this move this piece didn't exist.\n\treturn changes;\n}\n\n/**\n * Queues the removal of a piece by adding that Change to the Changes list.\n * @param changes - The running list of Changes for the move.\n * @param piece - The piece this change affects\n * @param main - Whether this change is affecting the main piece moved, not a secondary piece.\n */\nfunction queueDeletePiece(changes: Array<Change>, main: boolean, piece: Piece): Change[] {\n\tchanges.push({ action: 'delete', main, piece });\n\treturn changes;\n}\n\n/**\n * Moves a piece without capture\n * @param changes\n * @param piece The piece moved. Its coords are used as starting coords\n * @param main - Whether this change is affecting the main piece moved, not a secondary piece.\n * @param endCoords\n */\nfunction queueMovePiece(\n\tchanges: Array<Change>,\n\tmain: boolean,\n\tpiece: Piece,\n\tendCoords: Coords,\n\tpath?: Coords[],\n): Change[] {\n\tconst change: Change = { action: 'move', main, piece, endCoords };\n\tif (path !== undefined) change.path = path;\n\tchanges.push(change);\n\treturn changes;\n}\n\n// Executing changes of a Move ----------------------------------------------------------------------------------------\n\n/**\n * Applies the board changes of a move either forward or backward,\n * either modifying the piece lists, or modifying the mesh,\n * depending on what changeFuncs are passed in.\n */\nfunction runChanges<T>(\n\tactiondata: T,\n\tchanges: Change[],\n\tchangeFuncs: ChangeApplication<genericChangeFunc<T>>,\n\tforward: boolean = true,\n): void {\n\tconst funcs = forward ? changeFuncs.forward : changeFuncs.backward;\n\tapplyChanges(actiondata, changes, funcs, forward);\n}\n\n/**\n * Applies the logical board changes of a change list in the provided order, modifying the piece lists.\n * @param actiondata the data to apply the changes to\n * @param changes the changes to apply\n * @param funcs the object contain change funcs\n * @param forward whether to apply changes in forward order (true) or reverse order (false)\n */\nfunction applyChanges<T>(\n\tactiondata: T,\n\tchanges: Array<Change>,\n\tfuncs: ActionList<genericChangeFunc<T>>,\n\tforward: boolean,\n): void {\n\tif (forward) {\n\t\t// Iterate forwards through the changes array\n\t\tfor (const change of changes) {\n\t\t\tif (!(change.action in funcs))\n\t\t\t\tthrow Error(\n\t\t\t\t\t`Missing change function for likely-invalid change action \"${change.action}\"!`,\n\t\t\t\t);\n\t\t\tfuncs[change.action]!(actiondata, change);\n\t\t}\n\t} else {\n\t\t// Iterate backwards through the changes array so the move's changes are reverted in the correct order\n\t\tfor (let i = changes.length - 1; i >= 0; i--) {\n\t\t\tconst change = changes[i]!;\n\t\t\tif (!(change.action in funcs))\n\t\t\t\tthrow Error(\n\t\t\t\t\t`Missing change function for likely-invalid change action \"${change.action}\"!`,\n\t\t\t\t);\n\t\t\tfuncs[change.action]!(actiondata, change);\n\t\t}\n\t}\n}\n\n// Standard Chagne Functions --------------------------------------------------------------------------------------\n\n/**\n * Most basic add-a-piece method. Adds it the gamefile's piece list,\n * organizes the piece in the organized lists\n */\nfunction addPiece({ boardsim, basegame }: FullGame, change: Change): void {\n\t// desiredIndex optional\n\tconst pieces = boardsim.pieces;\n\tconst typedata = pieces.typeRanges.get(change.piece.type);\n\tif (typedata === undefined)\n\t\tthrow Error(\n\t\t\t`Type: \"${typeutil.debugType(change.piece.type)}\" is not expected to be in the game`,\n\t\t);\n\tlet idx: number;\n\tif (change.piece.index === -1) {\n\t\t// Does not have an index yet, assign it one from undefined list\n\t\tif (typedata.undefineds.length === 0) {\n\t\t\tif (\n\t\t\t\torganizedpieces.getTypeUndefinedsBehavior(\n\t\t\t\t\tchange.piece.type,\n\t\t\t\t\tboardsim.editor,\n\t\t\t\t\tbasegame.gameRules.promotionsAllowed,\n\t\t\t\t) === 0\n\t\t\t)\n\t\t\t\tthrow Error(\n\t\t\t\t\t`Type: ${typeutil.debugType(change.piece.type)} is not expected to be added after initial position!`,\n\t\t\t\t);\n\t\t\torganizedpieces.regenerateLists(\n\t\t\t\tboardsim.pieces,\n\t\t\t\tboardsim.editor,\n\t\t\t\tbasegame.gameRules.promotionsAllowed,\n\t\t\t);\n\t\t}\n\n\t\tidx = typedata.undefineds.shift()!;\n\t\tchange.piece.index = boardutil.getRelativeIdx(pieces, idx);\n\t} else {\n\t\tidx = boardutil.getAbsoluteIdx(pieces, change.piece); // Remove the relative-ness to the start of its type range\n\t\tconst { found, index } = jsutil.binarySearch(typedata.undefineds, idx);\n\t\tif (!found)\n\t\t\tthrow Error(\n\t\t\t\t`Newly added piece ${JSON.stringify(change.piece)} attemped to overwrite an occupied index`,\n\t\t\t);\n\t\ttypedata.undefineds.splice(index, 1);\n\t}\n\tpieces.XPositions[idx] = change.piece.coords[0];\n\tpieces.YPositions[idx] = change.piece.coords[1];\n\t// Don't need to set it's type, because it's spot in the type range already has its type.\n\n\torganizedpieces.registerPieceInSpace(idx, pieces);\n}\n\n/**\n * Most basic delete-a-piece method. Deletes it from the gamefile's piece list,\n * from the organized lists.\n */\nfunction deletePiece({ boardsim }: FullGame, change: Change): void {\n\tconst pieces = boardsim.pieces;\n\tconst typedata = pieces.typeRanges.get(change.piece.type);\n\n\tif (typedata === undefined)\n\t\tthrow Error(\n\t\t\t`Type: \"${typeutil.debugType(change.piece.type)}\" is not expected to be in the game`,\n\t\t);\n\tif (change.piece.index === -1) throw Error('Piece has not been allocated in organizedPieces');\n\n\tconst idx = boardutil.getAbsoluteIdx(pieces, change.piece); // Remove the relative-ness to the start of its type range\n\n\torganizedpieces.removePieceFromSpace(idx, pieces);\n\tjsutil.addElementToOrganizedArray(typedata.undefineds, idx);\n\n\t// Set the undefined piece's coordinates to [0,0] to keep things tidy.\n\tpieces.XPositions[idx] = 0n;\n\tpieces.YPositions[idx] = 0n;\n\t// Don't need to delete its type because every spot in a type range is expected to have the same type.\n}\n\n/**\n * Most basic move-a-piece method. Adjusts its coordinates in the gamefile's piece lists,\n * reorganizes the piece in the organized lists, and updates its mesh data.\n *\n * If the move is a capture, then use capturePiece() instead, so that we can animate it.\n * @param gamefile - The gamefile\n * @param change - the move data\n */\nfunction movePiece({ boardsim }: FullGame, change: Change): void {\n\tif (change.action !== 'move')\n\t\tthrow new Error(`movePiece called with a non-move change: ${change.action}`);\n\n\tconst pieces = boardsim.pieces;\n\tconst idx = boardutil.getAbsoluteIdx(pieces, change.piece); // Remove the relative-ness to the start of its type range\n\n\torganizedpieces.removePieceFromSpace(idx, pieces);\n\tpieces.XPositions[idx] = change.endCoords[0];\n\tpieces.YPositions[idx] = change.endCoords[1];\n\torganizedpieces.registerPieceInSpace(idx, pieces);\n}\n\n/**\n * Reverses `movePiece`\n */\nfunction returnPiece({ boardsim }: FullGame, change: Change): void {\n\tif (change.action !== 'move')\n\t\tthrow new Error(`returnPiece called with a non-move change: ${change.action}`);\n\n\tconst pieces = boardsim.pieces;\n\tconst range = pieces.typeRanges.get(change.piece.type)!;\n\tconst idx = change.piece.index + range.start;\n\n\torganizedpieces.removePieceFromSpace(idx, pieces);\n\n\tpieces.XPositions[idx] = change.piece.coords[0];\n\tpieces.YPositions[idx] = change.piece.coords[1];\n\n\torganizedpieces.registerPieceInSpace(idx, pieces);\n}\n\n// Other Change Functions -----------------------------------------------------------------------------------\n\n/**\n * This modifies only a Position Map<CoordsKey, number> where number is the type of piece.\n * It does NOT modify a gamefile or its organized pieces.\n * This also only works applying a move's changes FORWARD.\n *\n * This is intended for updating a simplified board state, one that is used in gamecompressor.GameToPosition\n */\nfunction runChanges_Position(position: Map<CoordsKey, number>, changes: Change[]): void {\n\tfor (const change of changes) {\n\t\tconst startCoordsKey = coordutil.getKeyFromCoords(change.piece.coords);\n\t\tswitch (change.action) {\n\t\t\tcase 'move':\n\t\t\t\tposition.delete(startCoordsKey);\n\t\t\t\tposition.set(coordutil.getKeyFromCoords(change.endCoords), change.piece.type);\n\t\t\t\tbreak;\n\t\t\tcase 'capture':\n\t\t\t\tposition.delete(startCoordsKey);\n\t\t\t\tbreak;\n\t\t\tcase 'add':\n\t\t\t\tposition.set(startCoordsKey, change.piece.type);\n\t\t\t\tbreak;\n\t\t\tcase 'delete':\n\t\t\t\tposition.delete(startCoordsKey);\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\t// @ts-ignore\n\t\t\t\tthrow Error(`Unknown change action: ${change.action}`);\n\t\t}\n\t}\n}\n\n// Helper Functions ----------------------------------------------------------------------------------------\n\n/**\n * Gets every captured piece in changes\n */\nfunction getCapturedPieceTypes(move: MoveFull): Set<number> {\n\tconst pieceTypes: Set<number> = new Set();\n\tmove.changes.forEach((change) => {\n\t\tif (change.action === 'capture') pieceTypes.add(change.piece.type);\n\t});\n\treturn pieceTypes;\n}\n\n/**\n * Returns true if any piece was captured by the move, whether directly or by special actions.\n */\nfunction wasACapture(move: MoveFull): boolean {\n\t// Safety net if we ever accidentally call this method too soon.\n\t// There will never be a valid move with zero changes, that's just absurd.\n\tif (move.changes.length === 0)\n\t\tthrow Error(\"Move doesn't have it's changes calculated yet, do that before this.\");\n\treturn move.changes.some((change) => change.action === 'capture');\n}\n\n// Exports ----------------------------------------------------------------------------------------\n\nexport type { genericChangeFunc, ChangeApplication, Change };\n\nexport default {\n\tchangeFuncs,\n\tqueueCapture,\n\tqueueAddPiece,\n\tqueueDeletePiece,\n\tqueueMovePiece,\n\trunChanges,\n\trunChanges_Position,\n\n\tgetCapturedPieceTypes,\n\twasACapture,\n\toneWayActions,\n\tapplyChanges,\n};\n"
  },
  {
    "path": "src/shared/chess/logic/checkdetection.ts",
    "content": "// src/shared/chess/logic/checkdetection.ts\n\n/**\n * This script is used to detect check,\n * or detect if a specific square is being attacked by any\n * other piece, be it individual, special, or sliding move.\n */\n\nimport type { Player } from '../util/typeutil.js';\nimport type { CheckInfo } from './state.js';\nimport type { CoordsTagged } from './movepiece.js';\nimport type { Board, FullGame } from './gamefile.js';\nimport type { Coords, CoordsKey } from '../util/coordutil.js';\n\nimport typeutil from '../util/typeutil.js';\nimport boardutil from '../util/boardutil.js';\nimport coordutil from '../util/coordutil.js';\nimport legalmoves from './legalmoves.js';\nimport organizedpieces from './organizedpieces.js';\nimport { players as p } from '../util/typeutil.js';\nimport vectors, { Vec2 } from '../../util/math/vectors.js';\n\n// Functions ----------------------------------------------------------------\n\n/**\n * Tests if the provided player color is in check in the current position of the gamefile.\n * @param gamefile - The gamefile\n * @param color - The player color to test if any of their royals are in check in the current position.\n * @param trackChecks - If true, the results object will contain a list of check pairs for the player's royals. This is useful for calculating blocking moves that may resolve the check. Should be true if we're using checkmate, and left out if we're using royal capture, to save compute.\n * @returns An object containing information such as whether the given color is in check in the current position, which royals are in check, and if applicable, the check pairs (each checked royal with its attacker).\n */\nfunction detectCheck(\n\tgamefile: FullGame,\n\tcolor: Player,\n\ttrackChecks?: boolean,\n): { check: boolean; royalsInCheck: Coords[]; checks?: CheckInfo[] } {\n\t// Coordinates of ALL royals of this color!\n\tconst royalCoords: Coords[] = boardutil.getRoyalCoordsOfColor(gamefile.boardsim.pieces, color);\n\t// Array of coordinates of royal pieces that are in check\n\tconst royalsInCheck: Coords[] = [];\n\tconst checks: CheckInfo[] | undefined = trackChecks ? [] : undefined;\n\n\troyalCoords.forEach((thisRoyalCoord) => {\n\t\tif (isSquareBeingAttacked(gamefile, thisRoyalCoord, color, checks)) {\n\t\t\troyalsInCheck.push(thisRoyalCoord);\n\t\t}\n\t});\n\n\treturn {\n\t\tcheck: royalsInCheck.length > 0,\n\t\troyalsInCheck,\n\t\tchecks,\n\t};\n}\n\n/**\n * Checks if an opponent player color is attacking a specific square.\n * @param gamefile\n * @param coord - The square of which to check if an opponent player color is attacking.\n * @param colorOfFriendly - The color of the friendly player. All other player colors will be tested to see if they attack the square.\n * @param [checks] If provided, any opponent attacking the square will be appended to this array as a CheckInfo pair. If it is not provided, we may exit early as soon as one attacker is discovered.\n */\nfunction isSquareBeingAttacked(\n\tgamefile: FullGame,\n\tcoord: Coords,\n\tcolorOfFriendly: Player,\n\tchecks?: CheckInfo[],\n): boolean {\n\tlet atleast1Attacker = false;\n\n\t// How do we find out if this square is attacked?\n\n\t// 1. We check every square within a 3 block radius to see if there's any attacking pieces.\n\n\tif (doesVicinityAttackSquare(gamefile.boardsim, coord, colorOfFriendly, checks)) {\n\t\tif (checks)\n\t\t\tatleast1Attacker = true; // ARE keeping track of checks, continue checking if there are more attacking the same square...\n\t\telse return true; // Not keeping track of checks, exit early\n\t}\n\t// What about specials (e.g. pawns, roses...)? Could they capture us?\n\tif (doesSpecialAttackSquare(gamefile, coord, colorOfFriendly, checks)) {\n\t\tif (checks)\n\t\t\tatleast1Attacker = true; // ARE keeping track of checks, continue checking if there are more attacking the same square...\n\t\telse return true; // Not keeping track of checks, exit early\n\t}\n\n\t// 2. We check every orthogonal and diagonal to see if there's any attacking pieces.\n\tif (doesSlideAttackSquare(gamefile, coord, colorOfFriendly, checks)) {\n\t\tif (checks)\n\t\t\tatleast1Attacker = true; // ARE keeping track of checks, continue checking if there are more attacking the same square...\n\t\telse return true; // Not keeping track of checks, exit early\n\t}\n\n\treturn atleast1Attacker; // Being attacked if true\n}\n\n/**\n * Checks to see if any opponent jumper within the immediate vicinity of the coordinates can attack them with an individual move (discounting special movers).\n * @param boardsim\n * @param square - The square to check if any opponent jumpers are attacking.\n * @param friendlyColor - The friendly player color\n * @param [checks] If provided, any opponent jumper attacking the square will be appended to this array as a CheckInfo. If it is not provided, we may exit early as soon as one jumper attacker is discovered.\n * @returns true if the square is being attacked by at least one opponent jumper with an individual move (discounting special movers).\n */\nfunction doesVicinityAttackSquare(\n\tboardsim: Board,\n\tsquare: Coords,\n\tfriendlyColor: Player,\n\tchecks?: CheckInfo[],\n): boolean {\n\tfor (const [coordsKey, thisVicinity] of Object.entries(boardsim.vicinity)) {\n\t\tconst thisSquare = coordutil.getCoordsFromKey(coordsKey as CoordsKey); // [1,2], [2,1], ...\n\t\t// Subtract the offset of our square\n\t\tconst actualSquare: Coords = [square[0] - thisSquare[0], square[1] - thisSquare[1]];\n\n\t\t// Fetch the piece type currently on that square\n\t\tconst typeOnSquare = boardutil.getTypeFromCoords(boardsim.pieces, actualSquare);\n\t\tif (typeOnSquare === undefined) continue; // Nothing there to capture us\n\t\t// Is it the same color?\n\t\tconst [trimmedTypeOnSquare, typeOnSquareColor] = typeutil.splitType(typeOnSquare);\n\t\tif (friendlyColor === typeOnSquareColor) continue; // A friendly can't capture us\n\t\tif (typeOnSquareColor === p.NEUTRAL) continue; // Neutrals can't capture us either (GARGOYLE ALERT)\n\n\t\t// Is that a match with any piece type on this vicinity square?\n\t\tif ((thisVicinity as number[]).includes(trimmedTypeOnSquare)) {\n\t\t\tchecks?.push({ royal: square, attacker: actualSquare, slidingCheck: false });\n\t\t\treturn true; // There'll never be more than 1 short-range/jumping checks! UNLESS it's multiplayer, but multiplayer won't use checkmate anyway so checks won't be specified\n\t\t}\n\t}\n\n\treturn false; // No jumper attacks the square\n}\n\n/**\n * Checks to see if any piece within the immediate vicinity of the coordinates can attack them with via a special individual move (e.g. pawns, roses...)\n * @param {gamefile} gamefile\n * @param square - The square to check if any opponent jumpers are attacking.\n * @param friendlyColor - The friendly player color\n * @param [checks] If provided, any opponent jumper attacking the square will be appended to this array as a CheckInfo. If it is not provided, we may exit early as soon as one jumper attacker is discovered.\n * @returns true if the square is being attacked by at least one piece via a special individual move.\n */\nfunction doesSpecialAttackSquare(\n\tgamefile: FullGame,\n\tsquare: CoordsTagged,\n\tfriendlyColor: Player,\n\tchecks?: CheckInfo[],\n): boolean {\n\tconst { boardsim } = gamefile;\n\tfor (const [coordsKey, thisVicinity] of Object.entries(boardsim.specialVicinity)) {\n\t\tconst thisSquare = coordutil.getCoordsFromKey(coordsKey as CoordsKey); // [1,2], [2,1], ...\n\t\t// Subtract the offset of our square\n\t\tconst actualSquare: Coords = [square[0] - thisSquare[0], square[1] - thisSquare[1]];\n\n\t\t// Fetch the piece type currently on that square\n\t\tconst typeOnSquare = boardutil.getTypeFromCoords(boardsim.pieces, actualSquare);\n\t\tif (typeOnSquare === undefined) continue; // Nothing there to capture us\n\t\t// Is it the same color?\n\t\tconst [trimmedTypeOnSquare, typeOnSquareColor] = typeutil.splitType(typeOnSquare);\n\t\tif (friendlyColor === typeOnSquareColor) continue; // A friendly can't capture us\n\t\tif (typeOnSquareColor === p.NEUTRAL) continue; // Neutrals can't capture us either (GARGOYLE ALERT)\n\n\t\t// Is that a match with any piece type on this vicinity square?\n\t\tif ((thisVicinity as number[]).includes(trimmedTypeOnSquare)) {\n\t\t\t// This square can POTENTIALLY be captured via special move...\n\t\t\t// Calculate that special piece's legal moves to see if it ACTUALLY can capture on that square\n\t\t\tconst pieceOnSquare = boardutil.getPieceFromCoords(boardsim.pieces, actualSquare)!;\n\n\t\t\tconst moveset = legalmoves.getPieceMoveset(boardsim, pieceOnSquare.type);\n\t\t\tconst specialPiecesLegalMoves = legalmoves.getEmptyLegalMoves(moveset);\n\t\t\tlegalmoves.appendSpecialMoves(\n\t\t\t\tgamefile,\n\t\t\t\tpieceOnSquare,\n\t\t\t\tmoveset,\n\t\t\t\tspecialPiecesLegalMoves,\n\t\t\t\tfalse,\n\t\t\t);\n\t\t\t// console.log(\"Calculated special pieces legal moves:\");\n\t\t\t// console.log(jsutil.deepCopyObject(specialPiecesLegalMoves));\n\n\t\t\tif (\n\t\t\t\t!legalmoves.checkIfMoveLegal(\n\t\t\t\t\tgamefile,\n\t\t\t\t\tspecialPiecesLegalMoves,\n\t\t\t\t\tactualSquare,\n\t\t\t\t\tsquare,\n\t\t\t\t\tfriendlyColor,\n\t\t\t\t)\n\t\t\t)\n\t\t\t\tcontinue; // This special piece can't make the capture THIS time... oof\n\n\t\t\t// console.log(\"SPECIAL PIECE CAN MAKE THE CAPTURE!!!!\");\n\n\t\t\tif (checks) {\n\t\t\t\t/**\n\t\t\t\t * If the `path` special flag is present (which it would be for Roses),\n\t\t\t\t * attach that to the CheckInfo, so that checkresolver can test if any\n\t\t\t\t * legal moves can block the path to stop this check.\n\t\t\t\t */\n\t\t\t\tconst checkInfo: CheckInfo = {\n\t\t\t\t\troyal: square,\n\t\t\t\t\tattacker: actualSquare,\n\t\t\t\t\tslidingCheck: false,\n\t\t\t\t};\n\t\t\t\tif (square.path !== undefined) checkInfo.path = square.path;\n\t\t\t\tchecks.push(checkInfo);\n\t\t\t}\n\t\t\treturn true; // There'll never be more than 1 short-range/jumping checks! UNLESS it's multiplayer, but multiplayer won't use checkmate anyway so checks won't be specified\n\t\t}\n\t}\n\n\treturn false; // No special mover attacks the square\n}\n\n/**\n * Calculates if any sliding piece can attack the specified square.\n * @param boardsim\n * @param square - The square to check if any opponent sliders are attacking.\n * @param friendlyColor - The friendly player color\n * @param [checks] If provided, any opponent slider attacking the square will be appended to this array as a CheckInfo. If it is not provided, we may exit early as soon as one slider attacker is discovered.\n * @returns true if the square is being attacked by at least one opponent slider.\n */\nfunction doesSlideAttackSquare(\n\tgamefile: FullGame,\n\tsquare: Coords,\n\tfriendlyColor: Player,\n\tchecks?: CheckInfo[],\n): boolean {\n\tlet atleast1Attacker = false;\n\n\tfor (const [directionkey, lineSet] of gamefile.boardsim.pieces.lines) {\n\t\t// [dx,dy]\n\t\tconst direction = coordutil.getCoordsFromKey(directionkey);\n\t\tconst key = organizedpieces.getKeyFromLine(direction, square);\n\t\tif (\n\t\t\tdoesLineAttackSquare(\n\t\t\t\tgamefile,\n\t\t\t\tlineSet.get(key),\n\t\t\t\tdirection,\n\t\t\t\tsquare,\n\t\t\t\tfriendlyColor,\n\t\t\t\tchecks,\n\t\t\t)\n\t\t) {\n\t\t\tif (!checks) return true; // Not keeping track of checks, exit early\n\t\t\tatleast1Attacker = true;\n\t\t}\n\t}\n\n\treturn atleast1Attacker;\n}\n\n/**\n * Tests if a piece on the specified organized line can capture on the specified square via a sliding move.\n * REQUIRES the square be on the line!!!\n * @param boardsim\n * @param line - The organized line of pieces\n * @param direction - The step of the line: [dx,dy]\n * @param coords - The coordinates of the square to test if any piece on the line can slide to. MUST be on the line!!!\n * @param color - The player color of friendlies. Friendlies can't capture us.\n * @param [checks] - If provided, any opponent slider attacking the square will be appended to this array as a CheckInfo. If it is not provided, we may exit early as soon as one slider attacker is discovered.\n * @returns true if the square is under threat\n */\nfunction doesLineAttackSquare(\n\tgamefile: FullGame,\n\tline: number[] | undefined,\n\tdirection: Vec2,\n\tcoords: Coords,\n\tcolor: Player,\n\tchecks?: CheckInfo[],\n): boolean {\n\tif (!line) return false; // This line doesn't exist, then obviously no pieces can attack our square\n\n\tconst directionKey = vectors.getKeyFromVec2(direction); // 'dx,dy'\n\tlet atleast1Attacker = false;\n\n\t// Iterate through every piece on the line, and test if they can attack our square\n\tfor (const thisPieceIdx of line) {\n\t\t// { coords, type }\n\t\tconst thisPiece = boardutil.getPieceFromIdx(gamefile.boardsim.pieces, thisPieceIdx)!;\n\t\tconst thisPieceColor = typeutil.getColorFromType(thisPiece.type);\n\t\tif (color === thisPieceColor) continue; // Same team, can't capture us, CONTINUE to next piece!\n\t\tif (thisPieceColor === p.NEUTRAL) continue; // Neutrals can't move, that means they can't make captures, right?\n\n\t\tconst thisPieceMoveset = legalmoves.getPieceMoveset(gamefile.boardsim, thisPiece.type);\n\n\t\tif (!thisPieceMoveset.sliding) continue; // Piece has no sliding movesets.\n\t\tconst moveset = thisPieceMoveset.sliding[directionKey];\n\t\tif (!moveset) continue; // Piece can't slide in the direction our line is going\n\t\tconst blockingFunc = legalmoves.getBlockingFuncFromPieceMoveset(thisPieceMoveset);\n\t\tconst thisPieceLegalSlide = legalmoves.slide_CalcLegalLimit(\n\t\t\tgamefile.basegame.gameRules.worldBorder,\n\t\t\tblockingFunc,\n\t\t\tgamefile.boardsim.pieces,\n\t\t\tline,\n\t\t\tdirection,\n\t\t\tmoveset,\n\t\t\tthisPiece.coords,\n\t\t\tthisPieceColor,\n\t\t\tfalse,\n\t\t);\n\t\tif (!thisPieceLegalSlide) continue; // This piece can't move in the direction of this line, NEXT piece!\n\n\t\tconst ignoreFunc = legalmoves.getIgnoreFuncFromPieceMoveset(thisPieceMoveset);\n\t\t// prettier-ignore\n\t\tif (!legalmoves.doesSlidingMovesetContainSquare(thisPieceLegalSlide, direction, thisPiece.coords, coords, ignoreFunc))\n\t\t\tcontinue; // This piece can't slide so far as to reach us, NEXT piece!\n\n\t\t// This piece is attacking this square!\n\n\t\tif (!checks) {\n\t\t\treturn true; // Checks array isn't being tracked, just insta-return to save compute not finding other checks!\n\t\t} else {\n\t\t\tchecks.push({\n\t\t\t\troyal: coords,\n\t\t\t\tattacker: thisPiece.coords,\n\t\t\t\tslidingCheck: true,\n\t\t\t\tcolinear: thisPieceMoveset.colinear,\n\t\t\t});\n\t\t}\n\t\tatleast1Attacker = true;\n\t}\n\n\treturn atleast1Attacker;\n}\n\n// Exports ----------------------------------------------------------------\n\nexport default {\n\tdetectCheck,\n\tdoesLineAttackSquare,\n};\n\nexport type {};\n"
  },
  {
    "path": "src/shared/chess/logic/checkmate.ts",
    "content": "// src/shared/chess/logic/checkmate.ts\n\n/**\n * This script contains our checkmate algorithm.\n */\n\nimport type { FullGame } from './gamefile.js';\nimport type { GameConclusion } from '../util/winconutil.js';\n\nimport typeutil from '../util/typeutil.js';\nimport moveutil from '../util/moveutil.js';\nimport boardutil from '../util/boardutil.js';\nimport legalmoves from './legalmoves.js';\nimport { rawTypes } from '../util/typeutil.js';\nimport gamefileutility from '../util/gamefileutility.js';\n\n/** The maximum number of pieces in-game to still use the checkmate algorithm. Above this uses \"royalcapture\". */\nconst pieceCountToDisableCheckmate = 50_000;\n\n/** The maximum number of royal pieces in-game to still use the checkmate algorithm. Above this uses \"royalcapture\". */\nconst royalCountToDisableCheckmate = 6;\n\n/**\n * Calculates if the provided gamefile is over by checkmate or stalemate\n * @param gamefile - The gamefile to detect if it's in checkmate\n * @returns The color of the player who won by checkmate.\n * `{ victor: 1, condition: 'checkmate' }`, `{ victor: 2, condition: 'checkmate' }`,\n * or `{ victor: 0, condition: 'stalemate' }`. Or *undefined* if the game isn't over.\n */\nfunction detectCheckmateOrStalemate(gamefile: FullGame): GameConclusion | undefined {\n\tconst { basegame, boardsim } = gamefile;\n\n\t// The game will be over when the player has zero legal moves remaining, lose or draw.\n\t// Iterate through every piece, calculating its legal moves. The first legal move we find, we\n\t// know the game is not over yet...\n\n\tfor (const rType of Object.values(rawTypes)) {\n\t\tconst thisType = typeutil.buildType(rType, basegame.whosTurn);\n\t\tconst thesePieces = boardsim.pieces.typeRanges.get(thisType);\n\t\tif (!thesePieces) continue; // The game doesn't have this type of piece\n\t\tfor (let idx = thesePieces.start; idx < thesePieces.end; idx++) {\n\t\t\tconst thisPiece = boardutil.getPieceFromIdx(boardsim.pieces, idx);\n\t\t\tif (!thisPiece) continue; // Piece undefined. We leave in deleted pieces so others retain their index!\n\t\t\tconst moves = legalmoves.calculateAll(gamefile, thisPiece);\n\t\t\tif (legalmoves.hasAtleast1Move(moves, gamefile, thisPiece)) return undefined; // Not checkmate\n\t\t}\n\t}\n\n\t// We made it through every single piece without finding a single move.\n\t// So is this draw or checkmate? Depends on whether the current state is check!\n\t// Also make sure that checkmate can't happen if the winCondition is NOT checkmate!\n\tconst usingCheckmate = gamefileutility.isOpponentUsingWinCondition(\n\t\tbasegame,\n\t\tbasegame.whosTurn,\n\t\t'checkmate',\n\t);\n\tif (gamefileutility.isCurrentViewedPositionInCheck(boardsim) && usingCheckmate) {\n\t\tconst colorThatWon = moveutil.getColorThatPlayedMoveIndex(\n\t\t\tbasegame,\n\t\t\tboardsim.moves.length - 1,\n\t\t);\n\t\treturn { victor: colorThatWon, condition: 'checkmate' };\n\t} else return { victor: null, condition: 'stalemate' };\n}\n\nexport { pieceCountToDisableCheckmate, royalCountToDisableCheckmate, detectCheckmateOrStalemate };\n"
  },
  {
    "path": "src/shared/chess/logic/checkresolver.ts",
    "content": "// src/shared/chess/logic/checkresolver.ts\n\n/**\n * This script contains methods that reduce the legal moves of a piece\n * to only the ones that don't leave the player in check.\n *\n * This could be not dodging/blocking/capturing an existing check,\n * or pinned pieces opening a discovered.\n */\n\nimport type { Piece } from '../util/boardutil.js';\nimport type { Coords } from '../util/coordutil.js';\nimport type { Player } from '../util/typeutil.js';\nimport type { FullGame } from './gamefile.js';\nimport type { CheckInfo } from './state.js';\nimport type { LegalMoves } from './legalmoves.js';\nimport type { Vec2, Vec2Key } from '../../util/math/vectors.js';\nimport type { CoordsTagged, MoveTagged, MoveSpecialTags } from './movepiece.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport jsutil from '../../util/jsutil.js';\nimport bimath from '../../util/math/bimath.js';\nimport vectors from '../../util/math/vectors.js';\nimport typeutil from '../util/typeutil.js';\nimport moveutil from '../util/moveutil.js';\nimport geometry from '../../util/math/geometry.js';\nimport bdcoords from '../util/bdcoords.js';\nimport boardutil from '../util/boardutil.js';\nimport coordutil from '../util/coordutil.js';\nimport movepiece from './movepiece.js';\nimport legalmoves from './legalmoves.js';\nimport boardchanges from './boardchanges.js';\nimport specialdetect from './specialdetect.js';\nimport checkdetection from './checkdetection.js';\nimport gamefileutility from '../util/gamefileutility.js';\nimport { players as p } from '../util/typeutil.js';\nimport bounds, { BoundingBox } from '../../util/math/bounds.js';\n\n// Functions ------------------------------------------------------------------------------\n\n/**\n * Deletes individual and sliding moves from the provided LegalMoves object that,\n * if they were to be played, would result in that player being in check.\n * These moves are illegal if your opponent has the 'checkmate' win condition.\n *\n * This could be pinned pieces opening a discovered,\n * or not dodging/blocking/capturing an existing check.\n *\n * If only a finite number of squares of a slide are legal, the whole slide is\n * still deleted, and those finite number of squares added as new individual moves.\n * @param gamefile\n * @param moves - The LegalMoves object\n * @param pieceSelected - The piece of which the legalMoves were calculated for\n * @param color - The color of the player owning the piece\n */\nfunction removeCheckInvalidMoves(\n\tgamefile: FullGame,\n\tpieceSelected: Piece,\n\tmoves: LegalMoves,\n): void {\n\tconst color = typeutil.getColorFromType(pieceSelected.type);\n\tif (color === p.NEUTRAL) return; // Neutral pieces can't be in check\n\tif (!gamefileutility.isOpponentUsingWinCondition(gamefile.basegame, color, 'checkmate')) return;\n\tif (boardutil.getRoyalCoordsOfColor(gamefile.boardsim.pieces, color).length === 0) return; // No royals -> zero checks possible, ever.\n\n\t// There's a couple type of moves that put you in check:\n\n\t// 1. Sliding moves. Possible they can open a discovered check, or fail to address an existing check.\n\t// Check these FIRST because in situations where we are in existing check, additional individual moves may be added, which are then simulated below to see if they're legal.\n\tremoveCheckInvalidMoves_Sliding(gamefile, moves, pieceSelected, color);\n\n\t// 2. Individual moves. We can iterate through these and use detectCheck() to test them.\n\tremoveCheckInvalidMoves_Individual(gamefile, moves.individual, pieceSelected, color);\n\n\t// console.log(\"Legal moves after removing check invalid:\");\n\t// console.log(moves);\n}\n\n/**\n * Deletes moves from the provided legal individual moves list that,\n * if they were to be played, would result in that player being in check.\n *\n * This could be pinned pieces opening a discovered,\n * or not dodging/blocking/capturing an existing check.\n * @param gamefile\n * @param individualMoves - The precalculated legal individual (jumping) moves for a piece.\n * @param piece - The piece of which the legal individual moves are for.\n * @param color - The color of the player the piece belongs to.\n */\nfunction removeCheckInvalidMoves_Individual(\n\tgamefile: FullGame,\n\tindividualMoves: CoordsTagged[],\n\tpiece: Piece,\n\tcolor: Player,\n): void {\n\t// [ [x,y], [x,y] ]\n\t// Simulate the move, then check the game state for check\n\tfor (let i = individualMoves.length - 1; i >= 0; i--) {\n\t\t// Iterate backwards so we don't run into issues as we delete indices while iterating\n\t\tconst thisMove: CoordsTagged = individualMoves[i]!; // [x,y]\n\t\tif (isMoveCheckInvalid(gamefile, piece, thisMove, color)) individualMoves.splice(i, 1); // Remove the move\n\t}\n}\n\n/**\n * Deletes sliding moves from the provided legal moves object that are illegal (i.e. they result in check).\n * This can happen if they don't address an existing check, OR they open a discovered attack on your king.\n *\n * If finitely many moves of a slide protect against check, the slide is still deleted, and each\n * one is added to the legal individual moves.\n * @param gamefile\n * @param moves - The precalculated legalMoves object for a piece.\n * @param piece - The piece of which the running legal moves are for.\n * @param color - The color of the player the piece belongs to.\n */\nfunction removeCheckInvalidMoves_Sliding(\n\tgamefile: FullGame,\n\tmoves: LegalMoves,\n\tpiece: Piece,\n\tcolor: Player,\n): void {\n\tif (Object.keys(moves.sliding).length === 0) return; // No sliding moves to being with.\n\n\tconst rawType = typeutil.getRawType(piece.type);\n\tconst isRoyal = typeutil.royals.includes(rawType);\n\n\t// There are 3 ways a sliding move can put you in check...\n\n\t// 1. The piece making the sliding move IS A ROYAL itself (royalqueen) and it moves into check.\n\tif (isRoyal) moves.brute = true; // Flag the sliding moves to brute force check each move to see if it results in check, disallowing it if so.\n\n\t// 2. By not blocking, dodging, or capturing the attacker of an already-existing check.\n\taddressChecks(gamefile, moves, piece.coords, isRoyal);\n\n\t// 3. By opening a new discovered attack on one of our royals.\n\taddressPins(gamefile, moves, piece, color, isRoyal);\n}\n\n/**\n * Collapses all sliding moves that don't have a chance at addressing\n * the checks, replacing them with individual moves to be simulated later.\n * @param gamefile - The gamefile\n * @param moves - The legal moves object of which to delete moves that don't address check.\n * @param selectedPieceCoords - The coordinates of the piece we're calculating the legal moves for.\n * @param color - The color of friendlies\n * @param isRoyal - Whether the provided legal moves are for a royal piece.\n */\nfunction addressChecks(\n\tgamefile: FullGame,\n\tmoves: LegalMoves,\n\tselectedPieceCoords: Coords,\n\tisRoyal: boolean,\n): void {\n\tconst { boardsim } = gamefile;\n\tconst checks = boardsim.state.local.checks;\n\tif (checks.length === 0) return; // Nothing in check\n\tif (Object.keys(moves.sliding).length === 0) return; // No sliding moves to collapse into more individuals that address the existing checks.\n\n\t// Does this piece have a sliding moveset that will either...\n\n\t// 1. Capture the checking piece\n\n\t// Collect which attackers are currently reachable by slide, BEFORE any slide modifications.\n\t// We'll add them as individual moves afterward.\n\tconst attackersCaptureableBySlide: CoordsTagged[] = [];\n\tfor (const c of checks) {\n\t\tif (legalmoves.doSlideRangesContainSquare(moves, selectedPieceCoords, c.attacker)) {\n\t\t\tattackersCaptureableBySlide.push(c.attacker);\n\t\t}\n\t}\n\n\t// 2. Dodge the check(s) - only if we're the one in check (royal queen)\n\n\tconst sortedChecks = sortChecks(checks);\n\n\tfor (const check of sortedChecks) {\n\t\t// Early exit if all slides have already been collapsed by a previous check.\n\t\tif (Object.keys(moves.sliding).length === 0) break;\n\t\tif (\n\t\t\t!check.slidingCheck || // The check isn't even made along a slide\n\t\t\tcheck.colinear || // Don't need to delete the same slide as the check if it's a colinear check\n\t\t\t!isRoyal || // Can't be the piece in check if you're not a royal to begin with\n\t\t\t!coordutil.areCoordsEqual(check.royal, selectedPieceCoords) // Must be the piece in check\n\t\t)\n\t\t\tcontinue;\n\t\t// We ARE the piece in check. Delete all slides that don't dodge the check.\n\t\tconst checkLineGeneralForm = vectors.getLineGeneralFormFrom2Coords(\n\t\t\tcheck.royal,\n\t\t\tcheck.attacker,\n\t\t);\n\t\tfor (const slideDir of Object.keys(moves.sliding)) {\n\t\t\tconst slideDirVec = vectors.getVec2FromKey(slideDir as Vec2Key);\n\t\t\tconst slideLineGeneralForm = vectors.getLineGeneralFormFromCoordsAndVec(\n\t\t\t\tselectedPieceCoords,\n\t\t\t\tslideDirVec,\n\t\t\t);\n\t\t\tif (!vectors.areLinesInGeneralFormEqual(checkLineGeneralForm, slideLineGeneralForm))\n\t\t\t\tcontinue; // Non-coincident slides are legitimate dodges, the brute flag handles their verification.\n\t\t\t// This slide can only ever remain in line of sight of the attacker.\n\t\t\tdelete moves.sliding[slideDir as Vec2Key]; // Collapse the slide.\n\t\t\t// For as long as sliding royals can't move colinearly, there\n\t\t\t// can only be one slide direction of the same vector to delete.\n\t\t\tif (!moves.colinear) break;\n\t\t}\n\t}\n\n\t// 3. Block the check(s)\n\n\tfor (const check of sortedChecks) {\n\t\t// Early exit if all slides have been deleted/collapsed by a previous check.\n\t\tif (Object.keys(moves.sliding).length === 0) break;\n\t\tif (coordutil.areCoordsEqual(check.royal, selectedPieceCoords)) continue; // Must NOT be the piece in check (you can't block your own check)\n\n\t\tconst dist = vectors.chebyshevDistance(check.royal, check.attacker);\n\t\tif (\n\t\t\tisRoyal || // Royals can't block checks, PERIOD, without also putting themselves in check.\n\t\t\t(check.slidingCheck && dist === 1n) || // Can't get between royal & attacker (1 square apart)\n\t\t\t(!check.slidingCheck && (check.path?.length ?? 2) < 3) // Can't block jumping check (or path check with only 2 points)\n\t\t) {\n\t\t\tmoves.sliding = {}; // Collapse all slides, none can block this check.\n\t\t\tbreak; // No more slides left to collapse to resolve other checks.\n\t\t}\n\n\t\tif (check.slidingCheck) {\n\t\t\t// prettier-ignore\n\t\t\t// Has a chance to delete all sliding moves except one, adding the `brute` flag, if the check is colinear.\n\t\t\tappendBlockingMoves(check.royal, check.attacker, moves, selectedPieceCoords, check.colinear);\n\t\t} else {\n\t\t\t// Guaranteed non-arbitrary interpose squares.\n\t\t\tappendPathBlockingMoves(check.path!, moves, selectedPieceCoords);\n\t\t}\n\t}\n\n\t// ---------------------------\n\n\t// (Deferred) Add attacker captures as individual moves, but only for those whose slide was\n\t// collapsed during steps 2 or 3. If the slide was retained (e.g. a colinear check on the royal),\n\t// the slide already covers the capture — adding an individual would be a duplicate.\n\tfor (const attacker of attackersCaptureableBySlide) {\n\t\tif (!legalmoves.doSlideRangesContainSquare(moves, selectedPieceCoords, attacker)) {\n\t\t\tappendMoveToIndividualsAvoidDuplicates(moves.individual, attacker);\n\t\t}\n\t}\n}\n\n/**\n * Deletes any sliding moves from the provided running legal moves that\n * open up a discovered attack on any of our royals.\n * Reads the current checks from the gamefile and ignores any that are already present —\n * only newly-exposed checks (from deleting the piece) are treated as pins.\n * @param gamefile\n * @param moves - The running legal moves of the selected piece\n * @param pieceSelected - The piece with the provided running legal moves\n * @param color - The color of the player the piece belongs to.\n * @param isRoyal - Whether the provided legal moves are for a royal piece.\n */\nfunction addressPins(\n\tgamefile: FullGame,\n\tmoves: LegalMoves,\n\tpieceSelected: Piece,\n\tcolor: Player,\n\tisRoyal: boolean,\n): void {\n\tif (Object.keys(moves.sliding).length === 0) return; // No sliding moves to remove (may have already all been removed in addressChecks())\n\t// Does not reflect checks for `color` if it's not currently their turn to move.\n\t// This is fine because only for whoever's turn it is, moves are check-respected.\n\tconst preExistingChecks = gamefile.boardsim.state.local.checks;\n\n\t/**\n\t * To find out if our piece is pinned (or opens a discovered), we delete it, then test for check.\n\t * Any check that surfaces and is NOT in preExistingChecks resulted from breaking the pin.\n\t */\n\tconst deleteChange = boardchanges.queueDeletePiece([], true, pieceSelected);\n\tboardchanges.runChanges(gamefile, deleteChange, boardchanges.changeFuncs, true);\n\n\tconst checkResults = checkdetection.detectCheck(gamefile, color, true); // { check: boolean, royalsInCheck: Coords[], checks?: CheckInfo[] }\n\n\t// Filter to only the newly-exposed checks (ignore the pre-existing ones).\n\tconst newChecks: CheckInfo[] = checkResults.checks!.filter((c) => {\n\t\treturn !preExistingChecks.some(\n\t\t\t(p) =>\n\t\t\t\tcoordutil.areCoordsEqual(p.royal, c.royal) &&\n\t\t\t\tcoordutil.areCoordsEqual(p.attacker, c.attacker),\n\t\t);\n\t});\n\t// console.log('New checks:', newChecks);\n\n\t/**\n\t * Iterate through all newly-exposed check pairs.\n\t * Delete all sliding moves but the one in the direction of the line between the attacker and our royal.\n\t * If it was a `path` check (rose), then collapse all slides into only individuals that block the path.\n\t */\n\n\touter: for (const check of sortChecks(newChecks)) {\n\t\t// Early exit if all slides have been deleted/collapsed by a previous new check.\n\t\tif (Object.keys(moves.sliding).length === 0) break;\n\n\t\tconst { royal, attacker } = check;\n\n\t\t// If the piece can capture the attacker, append it as an individual move\n\t\t// to be simulated later (removes the pin) BEFORE collapsing the slides.\n\t\tif (legalmoves.doSlideRangesContainSquare(moves, pieceSelected.coords, attacker)) {\n\t\t\tappendMoveToIndividualsAvoidDuplicates(moves.individual, attacker);\n\t\t}\n\n\t\t// If this piece is a royal, retaining the pin also keeps itself in check. So just collapse all slides.\n\t\tif (isRoyal) {\n\t\t\tmoves.sliding = {};\n\t\t\tbreak outer; // No more slides left to collapse to resolve other pins\n\t\t}\n\n\t\tif (!check.slidingCheck) {\n\t\t\t// Case 1: Individual jumping `path` check was exposed (Rose).\n\t\t\tif (!check.path)\n\t\t\t\tthrow Error(\n\t\t\t\t\t`Attacker giving non-sliding check has no path! It's impossible for a sliding move to expose a pathless jumping check. Either the position is illegal, or this check was pre-existing and was not correctly filtered out. Color: ${typeutil.strcolors[color]}`,\n\t\t\t\t);\n\t\t\t// Append any legal blocking squares on the path, then collapse all slides.\n\t\t\t// Guaranteed non-arbitrary interpose squares.\n\t\t\tappendPathBlockingMoves(check.path, moves, pieceSelected.coords);\n\t\t\t// We don't have to keep iterating through check pairs, since\n\t\t\t// if none of these newly added path-blocking/capture moves are legal, nothing else will be.\n\t\t\t// They are all simulated to see if they resolve the check. There are only finitely many.\n\t\t\tbreak outer;\n\t\t}\n\n\t\t// Case 2: Sliding check - That means this piece is on the same\n\t\t// line between the attacker and royal, AND in between them!\n\n\t\tconst checkLineGeneralForm = vectors.getLineGeneralFormFrom2Coords(royal, attacker);\n\t\t// Delete all sliding moves but the one in the direction of the line between the attacker and the royal.\n\t\tfor (const slideDir of Object.keys(moves.sliding)) {\n\t\t\t// 'dx,dy'\n\t\t\tconst slideDirVec = vectors.getVec2FromKey(slideDir as Vec2Key); // [dx,dy]\n\t\t\t// Delete the slide if it is NOT along the pin line.\n\t\t\tconst slideLineGeneralForm = vectors.getLineGeneralFormFromCoordsAndVec(\n\t\t\t\tpieceSelected.coords,\n\t\t\t\tslideDirVec,\n\t\t\t);\n\t\t\tif (!vectors.areLinesInGeneralFormEqual(checkLineGeneralForm, slideLineGeneralForm)) {\n\t\t\t\tdelete moves.sliding[slideDir as Vec2Key]; // Not the same line, delete it.\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Slide is along the pin line.\n\t\t\t// Restrict to the zone strictly between the royal and the attacker (both exclusive, capturing move is added separately above).\n\t\t\t// prettier-ignore\n\t\t\trestrictSlideBetweenSquares(moves, slideDir as Vec2Key, slideDirVec, pieceSelected.coords, royal, attacker, check.colinear);\n\t\t}\n\t}\n\n\tboardchanges.runChanges(gamefile, deleteChange, boardchanges.changeFuncs, false); // Add the piece back\n\n\t// console.log(\"Legal moves after removing sliding moves that open discovered:\");\n\t// console.log(moves);\n}\n\n/**\n * Restricts the slide `slideDir` in `moves.sliding` to the zone strictly between `royal` and `attacker`,\n * intersected with the slide's current physical limits. Deletes the slide if no overlap remains.\n * Both the royal and attacker squares are excluded; captures are appended as individual moves by the caller.\n * @param direction - How much the piece moves in each step of the slide.\n * @param colinear - If true, sets `moves.brute` so every surviving square is verified by simulation.\n */\nfunction restrictSlideBetweenSquares(\n\tmoves: LegalMoves,\n\tslideDir: Vec2Key,\n\tdirection: Vec2,\n\tpieceCoords: Coords,\n\troyal: Coords,\n\tattacker: Coords,\n\tcolinear: boolean,\n): void {\n\tconst sliding = moves.sliding!;\n\tconst axis: 0 | 1 = direction[0] === 0n ? 1 : 0;\n\tconst stepsToRoyal: BigDecimal = bd.divide(\n\t\tbd.fromBigInt(royal[axis] - pieceCoords[axis]),\n\t\tbd.fromBigInt(direction[axis]),\n\t);\n\tconst stepsToAttacker: BigDecimal = bd.divide(\n\t\tbd.fromBigInt(attacker[axis] - pieceCoords[axis]),\n\t\tbd.fromBigInt(direction[axis]),\n\t);\n\t// Both endpoints are excluded; captures are handled as individual moves.\n\t// `floor(min) + 1` and `ceil(max) - 1` give correct integer bounds even when step counts are fractional (e.g. direction [2,0]).\n\tconst zoneMin = bd.toBigInt(bd.floor(bd.min(stepsToRoyal, stepsToAttacker))) + 1n;\n\tconst zoneMax = bd.toBigInt(bd.ceil(bd.max(stepsToRoyal, stepsToAttacker))) - 1n;\n\tif (zoneMin > zoneMax) {\n\t\tdelete sliding[slideDir]; // Zone is empty.\n\t\t// console.log('Deleting slide: No squares between the royal and the attacker.');\n\t\treturn;\n\t}\n\tconst currentLimits = sliding[slideDir]!;\n\t// console.log(\n\t// \t`For slide ${slideDir}, intersecting current limits [${currentLimits[0]}, ${currentLimits[1]}] with blocking zone between royal ${royal} and attacker ${attacker} at steps [${zoneMin}, ${zoneMax}]`,\n\t// );\n\tconst newMin = currentLimits[0] === null ? zoneMin : bimath.max(currentLimits[0], zoneMin);\n\tconst newMax = currentLimits[1] === null ? zoneMax : bimath.min(currentLimits[1], zoneMax);\n\tif (newMin > newMax) {\n\t\tdelete sliding[slideDir]; // Slide can't reach the zone.\n\t\t// console.log(\"Deleting slide because it can't reach the blocking zone.\");\n\t\treturn;\n\t}\n\tsliding[slideDir] = [newMin, newMax];\n\t// console.log(\n\t// \t`Narrowing slide to steps [${newMin}, ${newMax}] to only include the blocking zone.`,\n\t// );\n\tif (colinear) moves.brute = true;\n}\n\n/**\n * Appends legal blocking moves to the provided moves object if the piece\n * is able to get between squares 1 & 2.\n * Should NOT be called if the piece with the legal moves is a royal piece.\n *\n * If colinears are present and the piece is on the same line as the line between\n * the attacker and the royal, sliding moves may be deleted.\n * @param gamefile\n * @param square1 - `[x,y]`\n * @param square2 - `[x,y]`\n * @param moves - The legal moves object of the piece selected, to see if it is able to block between squares 1 & 2\n * @param coords - The coordinates of the piece with the provided legal moves: `[x,y]`\n * @param attackerColinear - Whether the attacker piece giving check is a more complicated colinear mover (huygen).\n */\nfunction appendBlockingMoves(\n\tsquare1: Coords,\n\tsquare2: Coords,\n\tmoves: LegalMoves,\n\tcoords: Coords,\n\tattackerColinear: boolean,\n): void {\n\t/** The minimum bounding box that contains our 2 squares, at opposite corners. */\n\tconst box: BoundingBox = {\n\t\tleft: bimath.min(square1[0], square2[0]),\n\t\tright: bimath.max(square1[0], square2[0]),\n\t\ttop: bimath.max(square1[1], square2[1]),\n\t\tbottom: bimath.min(square1[1], square2[1]),\n\t};\n\n\tfor (const lineKey in moves.sliding) {\n\t\t// 'dx,dy'\n\t\tconst line = coordutil.getCoordsFromKey(lineKey as Vec2Key); // [dx,dy]\n\t\tconst line1GeneralForm = vectors.getLineGeneralFormFromCoordsAndVec(coords, line);\n\t\tconst line2GeneralForm = vectors.getLineGeneralFormFrom2Coords(square1, square2);\n\t\tconst blockPoint = geometry.calcIntersectionPointOfLines(\n\t\t\t...line1GeneralForm,\n\t\t\t...line2GeneralForm,\n\t\t); // The intersection point of the 2 lines.\n\n\t\tconst coincident = vectors.areLinesInGeneralFormEqual(line1GeneralForm, line2GeneralForm);\n\n\t\tif (blockPoint === undefined && !coincident) {\n\t\t\t// Case 1: Parallel, but not coincident -> no intersection point.\n\t\t\tdelete moves.sliding[lineKey as Vec2Key]; // Collapse the slide.\n\t\t} else if (blockPoint) {\n\t\t\t// Case 2: Not parallel, and has a single intersection point.\n\t\t\tif (!bdcoords.areCoordsIntegers(blockPoint)) {\n\t\t\t\t// It doesn't intersect at a whole number, impossible for our piece to move here!\n\t\t\t\tdelete moves.sliding[lineKey as Vec2Key]; // Collapse the slide.\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst blockPointInt = bdcoords.coordsToBigInt(blockPoint); // Zero precision loss since we're already confident they are integers.\n\t\t\tif (\n\t\t\t\t!bounds.boxContainsSquare(box, blockPointInt) || // Intersection point not between our 2 points, but outside of them.\n\t\t\t\tcoordutil.areCoordsEqual(blockPointInt, square1) || // Can't move onto our piece that's in check,\n\t\t\t\tcoordutil.areCoordsEqual(blockPointInt, square2) || // nor to the piece that is checking us (those are considered outside this method)\n\t\t\t\t// Does our piece's slide range include that block point? The slide must be intact to test this correctly, so we can't collapse it before this.\n\t\t\t\t!legalmoves.doSlideRangesContainSquare(moves, coords, blockPointInt)\n\t\t\t) {\n\t\t\t\tdelete moves.sliding[lineKey as Vec2Key]; // Collapse the slide.\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\t// Can block!\n\t\t\tdelete moves.sliding[lineKey as Vec2Key]; // Collapse the slide (can do this now because doSlideRangesContainSquare() was already called, which needed the slide to be intact).\n\t\t\t// Add as an individual move to be simulated later.\n\t\t\tappendMoveToIndividualsAvoidDuplicates(moves.individual, blockPointInt);\n\t\t} else {\n\t\t\t// Case 3: Coincident (Our piece is on the same line as the check)\n\t\t\t// -> Restrict the slide to the blocking zone (strictly between the royal and checker),\n\t\t\t// and add the `brute` flag if the check is colinear.\n\t\t\t// DON'T collapse the slide.\n\t\t\t// console.log('Entered coincident blocking case.');\n\t\t\t// prettier-ignore\n\t\t\trestrictSlideBetweenSquares(moves, lineKey as Vec2Key, line, coords, square1, square2, attackerColinear);\n\t\t}\n\t}\n}\n\n/**\n * Takes a `path` special flag of a checking attacker piece, and appends any legal individual\n * blocking moves our selected piece can land on.\n * Should NOT be called if the piece with the legal moves is a royal piece.\n * @param gamefile\n * @param path - Individual move's `path` special move flag, with guaranteed at least 3 waypoints within it.\n * @param legalMoves - The precalculated legal moves of the selected piece\n * @param selectedPieceCoords\n */\nfunction appendPathBlockingMoves(\n\tpath: MoveSpecialTags['path'],\n\tlegalMoves: LegalMoves,\n\tselectedPieceCoords: Coords,\n): void {\n\t/**\n\t * How do we tell if our selected piece can block an individual move with a path (Rose piece)?\n\t *\n\t * Whether it can move to any of the waypoints in the path (exluding start and end waypoints).\n\t * The reason we exclude the start waypoint is because we already check earlier\n\t * if it's legal to capure the attacker.\n\t */\n\n\tfor (let i = 1; i < path.length - 1; i++) {\n\t\t// Iterate through all path points, EXCLUDING start and end.\n\t\tconst blockPoint = path[i]!;\n\t\t// Can our selected piece move to this square?\n\t\tif (legalmoves.doSlideRangesContainSquare(legalMoves, selectedPieceCoords, blockPoint))\n\t\t\tappendMoveToIndividualsAvoidDuplicates(legalMoves.individual, blockPoint); // Can block!\n\t}\n\n\tlegalMoves.sliding = {}; // Collapse all sliding moves\n}\n\n/** Appends the provided move to the list of legal individual moves if it's not already present. */\nfunction appendMoveToIndividualsAvoidDuplicates(individuals: CoordsTagged[], move: Coords): void {\n\tif (!individuals.some((im: CoordsTagged) => coordutil.areCoordsEqual(im, move))) {\n\t\tindividuals.push(move);\n\t}\n}\n\n/**\n * Sorts checks by `path` first (guaranteed non-arbitrary interpose squares),\n * then non-colinear sliding checks (to avoid adding the `brute` flag whenever possible),\n * then colinear sliding checks last.\n * Mutating. Sorts in place.\n */\nfunction sortChecks(checks: CheckInfo[]): CheckInfo[] {\n\treturn checks.sort((a, b) => {\n\t\tconst rank = (c: CheckInfo): number => {\n\t\t\tif (!c.slidingCheck) return 0; // path check\n\t\t\tif (!c.colinear) return 1; // non-colinear sliding check\n\t\t\treturn 2; // colinear sliding check\n\t\t};\n\t\treturn rank(a) - rank(b);\n\t});\n}\n\n/**\n * Simulates moving the piece to the destination coords,\n * then tests if it results in the player who owns the piece being in check.\n * @param gamefile\n * @param piece - The piece moving to the destination coords\n * @param destCoords - The coords to move the piece to, with any attached special tags to execute with the move.\n * @param color - The color of the player the piece belongs to.\n * @returns Whether the move would result in the player owning the piece being in check.\n */\nfunction isMoveCheckInvalid(\n\tgamefile: FullGame,\n\tpiece: Piece,\n\tdestCoords: CoordsTagged,\n\tcolor: Player,\n): boolean {\n\t// pieceSelected: { type, index, coords }\n\tconst moveTagged: MoveTagged = {\n\t\tstartCoords: jsutil.deepCopyObject(piece.coords),\n\t\tendCoords: moveutil.stripSpecialMoveTagsFromCoords(destCoords),\n\t};\n\tspecialdetect.transferSpecialTags_FromCoordsToMove(destCoords, moveTagged);\n\treturn getSimulatedCheck(gamefile, moveTagged, color).check;\n}\n\n/**\n * Simulates a move to get the check\n * @returns false if the move does not result in check, otherwise a list of the coords of all the royals in check.\n */\nfunction getSimulatedCheck(\n\tgamefile: FullGame,\n\tmoveTagged: MoveTagged,\n\tcolorToTestInCheck: Player,\n): ReturnType<typeof checkdetection.detectCheck> {\n\treturn movepiece.simulateMoveWrapper(gamefile, moveTagged, () =>\n\t\tcheckdetection.detectCheck(gamefile, colorToTestInCheck),\n\t);\n}\n\n// Exports --------------------------------------------------------------------------------\n\nexport default {\n\tremoveCheckInvalidMoves,\n\tisMoveCheckInvalid,\n\tgetSimulatedCheck,\n};\n"
  },
  {
    "path": "src/shared/chess/logic/clock.ts",
    "content": "// src/shared/chess/logic/clock.ts\n\n/**\n * This script keeps track of both players timer,\n * updates them each frame,\n * and the update() method will return the loser\n * if somebody loses on time.\n */\n\nimport type { Player } from '../util/typeutil.js';\nimport type { PlayerGroup } from '../util/typeutil.js';\nimport type { ClockDependant, Game } from './gamefile.js';\nimport type { ClockValues, TimeControl } from '../../types.js';\n\nimport typeutil from '../util/typeutil.js';\nimport moveutil from '../util/moveutil.js';\nimport timeutil from '../../util/timeutil.js';\nimport clockutil from '../util/clockutil.js';\nimport gamefileutility from '../util/gamefileutility.js';\n\n// Types --------------------------------------------------------------------------\n\nexport type ClockData = {\n\t/** The time each player has remaining, in milliseconds.*/\n\tcurrentTime: PlayerGroup<number>;\n\n\t/** Contains information about the start time of the game. */\n\tstartTime: {\n\t\t/** The number of minutes both sides started with. */\n\t\tminutes: number;\n\t\t/** The number of miliseconds both sides started with.  */\n\t\tmillis: number;\n\t\t/** The increment used, in milliseconds. */\n\t\tincrement: number;\n\t};\n} & (\n\t| {\n\t\t\t/** We need this separate from gamefile's \"whosTurn\", because when we are\n\t\t\t * in an online game and we make a move, we want our Clock to continue\n\t\t\t * ticking until we receive the Clock information back from the server!*/\n\t\t\tcolorTicking: Player;\n\t\t\t/** The amount of time in millis the current player had at the beginning of their turn, in milliseconds.\n\t\t\t * When set to undefined no clocks are ticking*/\n\t\t\ttimeRemainAtTurnStart: number;\n\t\t\t/** The time at the beginning of the current player's turn, in milliseconds elapsed since the Unix epoch.*/\n\t\t\ttimeAtTurnStart: number;\n\t  }\n\t| {\n\t\t\t/** We need this separate from gamefile's \"whosTurn\", because when we are\n\t\t\t * in an online game and we make a move, we want our Clock to continue\n\t\t\t * ticking until we receive the Clock information back from the server!*/\n\t\t\tcolorTicking: undefined;\n\t\t\t/** The amount of time in millis the current player had at the beginning of their turn, in milliseconds.\n\t\t\t * When set to undefined no clocks are ticking*/\n\t\t\ttimeRemainAtTurnStart: undefined;\n\t\t\t/** The time at the beginning of the current player's turn, in milliseconds elapsed since the Unix epoch.*/\n\t\t\ttimeAtTurnStart: undefined;\n\t  }\n);\n\n// Functions -----------------------------------------------------------------------\n\n/**\n * Sets the clocks. If no current clock values are specified, clocks will\n * be set to the starting values, according to the game's TimeControl metadata.\n */\nfunction init(players: Iterable<Player>, time_control: TimeControl): ClockDependant {\n\tconst untimed = clockutil.isClockValueInfinite(time_control);\n\tif (untimed) return { untimed: true, clocks: undefined };\n\tconst clockPartsSplit = clockutil.getMinutesAndIncrementFromClock(time_control)!; // { minutes, increment }\n\n\tconst clocks: ClockData = {\n\t\tstartTime: {\n\t\t\tminutes: clockPartsSplit.minutes,\n\t\t\tmillis: timeutil.minutesToMillis(clockPartsSplit.minutes),\n\t\t\tincrement: clockPartsSplit.increment,\n\t\t},\n\t\tcurrentTime: {},\n\n\t\tcolorTicking: undefined,\n\t\ttimeAtTurnStart: undefined,\n\t\ttimeRemainAtTurnStart: undefined,\n\t};\n\n\t// start both players with the default.\n\tfor (const color of players) {\n\t\tclocks.currentTime[color] = clocks.startTime.millis;\n\t}\n\n\treturn { untimed: false, clocks };\n}\n\n/**\n * Updates the gamefile with new clock information received from the server.\n * @param basegame - The game to update the clocks of.\n * @param clockValues - The new clock values to set.\n */\nfunction edit(currentClocks: ClockData, clockValues: ClockValues): void {\n\tconst colorTicking = clockValues.colorTicking;\n\tconst now = Date.now();\n\n\tif (colorTicking !== undefined) {\n\t\t// Adjust the clock value according to the precalculated time they will lost by timeout.\n\t\tif (clockValues.timeColorTickingLosesAt === undefined)\n\t\t\tthrow Error(\n\t\t\t\t'clockValues should have been modified to account for ping BEFORE editing the clocks. Use adjustClockValuesForPing() beore edit()',\n\t\t\t);\n\t\tconst colorTickingTrueTimeRemaining = clockValues.timeColorTickingLosesAt - now;\n\t\tclockValues.clocks[colorTicking] = colorTickingTrueTimeRemaining;\n\t}\n\n\tcurrentClocks.colorTicking = colorTicking;\n\tcurrentClocks.currentTime = { ...clockValues.clocks };\n\n\tif (colorTicking !== undefined) {\n\t\tcurrentClocks.timeAtTurnStart = now;\n\n\t\tcurrentClocks.timeRemainAtTurnStart = currentClocks.currentTime[colorTicking];\n\t}\n}\n\n/**\n * Call after flipping whosTurn. Flips colorTicking in local games.\n * @returns The time in milliseconds the player who just moved has remaining, if the clocks are ticking.\n */\nfunction push(basegame: Game, clocks: ClockData): number | undefined {\n\tconst prevcolor = moveutil.getWhosTurnAtMoveIndex(basegame, basegame.moves.length - 2);\n\n\tif (!moveutil.isGameResignable(basegame)) return clocks.currentTime[prevcolor]!;\n\n\t// Add increment to the previous player's clock and capture their remaining time to later insert into move.\n\tif (clocks.timeAtTurnStart !== undefined) {\n\t\t// Update current values\n\t\tconst timePassedSinceTurnStart = Date.now() - clocks.timeAtTurnStart;\n\n\t\tclocks.currentTime[clocks.colorTicking] =\n\t\t\tclocks.timeRemainAtTurnStart - timePassedSinceTurnStart;\n\t\t// 3+ moves\n\t\tclocks.currentTime[prevcolor]! += timeutil.secondsToMillis(clocks.startTime.increment!);\n\t}\n\n\t// Set up clocksticking for the new turn.\n\tclocks.colorTicking = basegame.whosTurn;\n\tclocks.timeRemainAtTurnStart = clocks.currentTime[clocks.colorTicking]!;\n\tclocks.timeAtTurnStart = Date.now();\n\n\treturn clocks.currentTime[prevcolor];\n}\n\nfunction stop(basegame: Game): void {\n\tif (basegame.untimed) return;\n\tconst clocks = basegame.clocks;\n\n\tif (clocks.colorTicking === undefined) return; // Clocks already stopped\n\n\tconst timeSpent = Date.now() - clocks.timeAtTurnStart!;\n\tlet newTime = clocks.timeRemainAtTurnStart! - timeSpent;\n\tif (newTime < 0) newTime = 0;\n\n\tclocks.currentTime[clocks.colorTicking]! = newTime;\n\n\tendGame(basegame);\n}\n\nfunction endGame(basegame: Game): void {\n\tif (basegame.untimed) return;\n\tconst clocks = basegame.clocks;\n\tdelete clocks.timeRemainAtTurnStart;\n\tdelete clocks.timeAtTurnStart;\n\tdelete clocks.colorTicking;\n}\n\n/**\n * Called every frame, updates values.\n * @param basegame\n * @returns undefined if clocks still have time, otherwise it's the color who won.\n */\nfunction update(basegame: Game): Player | undefined {\n\tif (\n\t\tbasegame.untimed ||\n\t\tgamefileutility.isGameOver(basegame) ||\n\t\t!moveutil.isGameResignable(basegame)\n\t)\n\t\treturn;\n\n\tconst clocks = basegame.clocks;\n\tif (clocks.timeAtTurnStart === undefined) return;\n\n\t// Update current values\n\tconst timePassedSinceTurnStart = Date.now() - clocks.timeAtTurnStart;\n\n\tclocks.currentTime[clocks.colorTicking] = Math.ceil(\n\t\tclocks.timeRemainAtTurnStart - timePassedSinceTurnStart,\n\t);\n\n\tfor (const [playerStr, time] of Object.entries(clocks.currentTime)) {\n\t\tconst player: Player = Number(playerStr) as Player;\n\t\tif ((time as number) <= 0) {\n\t\t\tclocks.currentTime[player] = 0;\n\t\t\treturn typeutil.invertPlayer(player); // The color who won on time\n\t\t}\n\t}\n\n\treturn; // Without this, typescript complains not all code paths return a value.\n}\n\n/**\n * Returns the true time remaining for the player whos clock is ticking.\n * Independant of reading clocks.currentTime, because that isn't updated\n * every frame if the user unfocuses the window.\n */\nfunction getColorTickingTrueTimeRemaining(clocks: ClockData): number | undefined {\n\tif (clocks.colorTicking === undefined) return;\n\tconst timeElapsedSinceTurnStartMillis = Date.now() - clocks.timeAtTurnStart;\n\treturn clocks.timeRemainAtTurnStart - timeElapsedSinceTurnStartMillis;\n}\n\nfunction printClocks(basegame: Game): void {\n\tif (basegame.untimed) return console.log('Game is untimed.');\n\tconst clocks = basegame.clocks!;\n\tfor (const color in clocks.currentTime) {\n\t\tconsole.log(`${color} time: ${clocks.currentTime[Number(color) as Player]}`);\n\t}\n\tconsole.log(`timeRemainAtTurnStart: ${clocks.timeRemainAtTurnStart}`);\n\tconsole.log(`timeAtTurnStart: ${clocks.timeAtTurnStart}`);\n}\n\nfunction createEdit(clocks: ClockData): ClockValues {\n\tconst tickingData: Omit<ClockValues, 'clocks'> = {};\n\tif (clocks.colorTicking !== undefined) {\n\t\ttickingData.colorTicking = clocks.colorTicking;\n\t\ttickingData.timeColorTickingLosesAt = clocks.timeAtTurnStart + clocks.timeRemainAtTurnStart;\n\t}\n\n\treturn {\n\t\tclocks: clocks.currentTime,\n\t\t...tickingData,\n\t};\n}\n\nexport default {\n\tinit,\n\tcreateEdit,\n\tedit,\n\tstop,\n\tendGame,\n\tupdate,\n\tpush,\n\tgetColorTickingTrueTimeRemaining,\n\tprintClocks,\n};\n"
  },
  {
    "path": "src/shared/chess/logic/fourdimensionalmoves.ts",
    "content": "// src/shared/chess/logic/fourdimensionalmoves.ts\n\n/**\n * This script contains overrides for calculating the legal moves\n * of pieces in four dimensional variants, and for executing those moves.\n *\n * Pieces cannot jump to other timelike boards using spacelike movements,\n * nor can they jump out of bounds.\n */\n\nimport type { Piece } from '../util/boardutil.js';\nimport type { Coords } from '../util/coordutil.js';\nimport type { Player } from '../util/typeutil.js';\nimport type { MoveRunning } from './specialmove.js';\nimport type { CoordsTagged } from './movepiece.js';\nimport type { UnboundedRectangle } from '../../util/math/bounds.js';\nimport type { Game, Board, FullGame } from './gamefile.js';\n\nimport state from './state.js';\nimport bimath from '../../util/math/bimath.js';\nimport typeutil from '../util/typeutil.js';\nimport coordutil from '../util/coordutil.js';\nimport boardutil from '../util/boardutil.js';\nimport legalmoves from './legalmoves.js';\nimport boardchanges from './boardchanges.js';\nimport specialdetect from './specialdetect.js';\nimport { players as p } from '../util/typeutil.js';\nimport fourdimensionalgenerator from '../variants/fourdimensionalgenerator.js';\n\n// Pawn Legal Move Calculation and Execution -----------------------------------------------------------------\n\n/** Calculates the legal pawn moves in the four dimensional variant. */\nfunction fourDimensionalPawnMove(\n\tgamefile: FullGame,\n\tcoords: Coords,\n\tcolor: Player,\n\tpremove: boolean,\n): CoordsTagged[] {\n\tconst legalMoves: CoordsTagged[] = [];\n\tlegalMoves.push(...pawnLegalMoves(gamefile, coords, color, 'spacelike', premove)); // Spacelike\n\tlegalMoves.push(...pawnLegalMoves(gamefile, coords, color, 'timelike', premove)); // Timelike\n\treturn legalMoves;\n}\n\n/**\n * Calculates legal pawn moves for either the spacelike or timelike dimensions.\n * @param gamefile\n * @param coords - The coordinates of the pawn\n * @param color - The color of the pawn\n * @param movetype - spacelike move or timelike move\n */\nfunction pawnLegalMoves(\n\tgamefile: FullGame,\n\tcoords: Coords,\n\tcolor: Player,\n\tmovetype: 'spacelike' | 'timelike',\n\tpremove: boolean,\n): CoordsTagged[] {\n\tconst { basegame, boardsim } = gamefile;\n\tconst dim = fourdimensionalgenerator.get4DBoardDimensions();\n\tconst distance = movetype === 'spacelike' ? 1n : dim.BOARD_SPACING;\n\tconst distance_complement = movetype === 'spacelike' ? dim.BOARD_SPACING : 1n;\n\n\t// White and black pawns move and capture in opposite directions.\n\tconst yDistanceParity = color === p.WHITE ? distance : -distance;\n\tconst individualMoves: CoordsTagged[] = [];\n\t// How do we go about calculating a pawn's legal moves?\n\n\t// 1. It can move forward if there is no piece there\n\n\t// Is there a piece in front of it? And do not allow pawn to leave the 4D board\n\tconst singlePushCoord: CoordsTagged = [coords[0], coords[1] + yDistanceParity];\n\tlet moveValidity = legalmoves.testSquareValidity(\n\t\tboardsim,\n\t\tbasegame.gameRules.worldBorder,\n\t\tsinglePushCoord,\n\t\tcolor,\n\t\tpremove,\n\t\tfalse,\n\t);\n\n\tif (\n\t\tmoveValidity === 0 && // Pawns forward-motion validity check must be 0, as they can't capture forward.\n\t\tsinglePushCoord[0] > dim.MIN_X &&\n\t\tsinglePushCoord[0] < dim.MAX_X &&\n\t\tsinglePushCoord[1] > dim.MIN_Y &&\n\t\tsinglePushCoord[1] < dim.MAX_Y // Pawn within boundaries\n\t) {\n\t\tappendPawnMoveAndAttachPromoteTag(basegame, individualMoves, singlePushCoord, color); // No piece, add the move\n\n\t\t// Is the double push legal?\n\t\tconst doublePushCoord: CoordsTagged = [\n\t\t\tsinglePushCoord[0],\n\t\t\tsinglePushCoord[1] + yDistanceParity,\n\t\t];\n\t\tmoveValidity = legalmoves.testSquareValidity(\n\t\t\tboardsim,\n\t\t\tbasegame.gameRules.worldBorder,\n\t\t\tdoublePushCoord,\n\t\t\tcolor,\n\t\t\tpremove,\n\t\t\tfalse,\n\t\t);\n\n\t\tif (\n\t\t\tdoesPieceHaveSpecialRight(boardsim, coords) &&\n\t\t\tmoveValidity === 0 &&\n\t\t\tdoublePushCoord[0] > dim.MIN_X &&\n\t\t\tdoublePushCoord[0] < dim.MAX_X &&\n\t\t\tdoublePushCoord[1] > dim.MIN_Y &&\n\t\t\tdoublePushCoord[1] < dim.MAX_Y\n\t\t) {\n\t\t\t// Add the double push!\n\t\t\tdoublePushCoord.enpassantCreate = specialdetect.getEnPassantGamefileProperty(\n\t\t\t\tcoords,\n\t\t\t\tdoublePushCoord,\n\t\t\t);\n\t\t\tappendPawnMoveAndAttachPromoteTag(basegame, individualMoves, doublePushCoord, color); // Add the double push!\n\t\t}\n\t}\n\n\t// 2. It can capture diagonally if there are opponent pieces there\n\tconst strong_pawns = fourdimensionalgenerator.getMovementType().STRONG_PAWNS;\n\n\tconst coordsToCapture: CoordsTagged[] = [\n\t\t[coords[0] - distance, coords[1] + yDistanceParity],\n\t\t[coords[0] + distance, coords[1] + yDistanceParity],\n\t];\n\tif (strong_pawns)\n\t\tcoordsToCapture.push(\n\t\t\t// Add the brawn-like captures\n\t\t\t[coords[0] - distance_complement, coords[1] + yDistanceParity],\n\t\t\t[coords[0] + distance_complement, coords[1] + yDistanceParity],\n\t\t);\n\tfor (const captureCoords of coordsToCapture) {\n\t\tconst moveValidity = legalmoves.testSquareValidity(\n\t\t\tboardsim,\n\t\t\tbasegame.gameRules.worldBorder,\n\t\t\tcaptureCoords,\n\t\t\tcolor,\n\t\t\tpremove,\n\t\t\ttrue,\n\t\t); // true for capture is required\n\t\tif (moveValidity <= 1)\n\t\t\tappendPawnMoveAndAttachPromoteTag(basegame, individualMoves, captureCoords, color); // Good to add the capture!\n\t}\n\n\t// 3. It can capture en passant if a pawn next to it just pushed twice.\n\tif (!premove) {\n\t\t// Only add if we're not premoving, since premove captures are added above\n\t\taddPossibleEnPassant(gamefile, individualMoves, coords, color, distance, distance);\n\t\tif (strong_pawns)\n\t\t\taddPossibleEnPassant(\n\t\t\t\tgamefile,\n\t\t\t\tindividualMoves,\n\t\t\t\tcoords,\n\t\t\t\tcolor,\n\t\t\t\tdistance_complement,\n\t\t\t\tdistance,\n\t\t\t);\n\t}\n\n\treturn individualMoves;\n}\n\n/**\n * Adds the en passant capture to the list of individual moves if it is possible.\n * @param gamefile\n * @param individualMoves - The list of individual moves to add the en passant capture to\n * @param coords - The coordinates of the pawn\n * @param color - The color of the pawn\n * @param xdistance\n * @param ydistance\n */\nfunction addPossibleEnPassant(\n\t{ basegame, boardsim }: FullGame,\n\tindividualMoves: CoordsTagged[],\n\tcoords: Coords,\n\tcolor: Player,\n\txdistance: bigint,\n\tydistance: bigint,\n): void {\n\tif (!boardsim.state.global.enpassant) return; // No enpassant flag on the game, no enpassant possible\n\tif (color !== basegame.whosTurn) return; // Not our turn (the only color who can legally capture enpassant is whos turn it is). If it IS our turn, this also guarantees the captured pawn will be an enemy pawn.\n\tconst enpassantCapturedPawnType = boardutil.getTypeFromCoords(\n\t\tboardsim.pieces,\n\t\tboardsim.state.global.enpassant.pawn,\n\t)!;\n\tif (typeutil.getColorFromType(enpassantCapturedPawnType) === color) return; // The captured pawn is not an enemy pawn. THIS IS ONLY EVER NEEDED if we can move opponent pieces on our turn, which is the case in EDIT MODE.\n\n\tconst xDifference = boardsim.state.global.enpassant.square[0] - coords[0];\n\tif (bimath.abs(xDifference) !== xdistance) return; // Not immediately left or right of us\n\t// prettier-ignore\n\tconst yDistanceParity = color === p.WHITE ? ydistance : color === p.BLACK ? -ydistance : (() => { throw new Error(\"Invalid color!\"); })();\n\n\tif (coords[1] + yDistanceParity !== boardsim.state.global.enpassant.square[1]) return; // Not one in front of us\n\n\t// It is capturable en passant!\n\n\t/** The square the pawn lands on. */\n\tconst enPassantSquare: CoordsTagged = coordutil.copyCoords(\n\t\tboardsim.state.global.enpassant.square,\n\t);\n\n\t// TAG THIS MOVE as an en passant capture!! gamefile looks for this tag\n\t// on the individual move to detect en passant captures and to know what piece to delete\n\tenPassantSquare.enpassant = true;\n\tappendPawnMoveAndAttachPromoteTag(basegame, individualMoves, enPassantSquare, color);\n}\n\n/**\n * Appends the provided move to the running individual moves list,\n * and adds the `promoteTrigger` special flag to it if it landed on a promotion rank.\n */\nfunction appendPawnMoveAndAttachPromoteTag(\n\tbasegame: Game,\n\tindividualMoves: CoordsTagged[],\n\tlandCoords: CoordsTagged,\n\tcolor: Player,\n): void {\n\tif (basegame.gameRules.promotionRanks !== undefined) {\n\t\tconst teamPromotionRanks = basegame.gameRules.promotionRanks[color];\n\t\tif (teamPromotionRanks?.includes(landCoords[1])) landCoords.promoteTrigger = true;\n\t}\n\n\tindividualMoves.push(landCoords);\n}\n\nfunction doesPieceHaveSpecialRight(boardsim: Board, coords: Coords): boolean {\n\tconst key = coordutil.getKeyFromCoords(coords);\n\treturn boardsim.state.global.specialRights.has(key);\n}\n\n/** Executes a four dimensional pawn move.  */\nfunction doFourDimensionalPawnMove(boardsim: Board, piece: Piece, move: MoveRunning): boolean {\n\tconst moveChanges = move.changes;\n\n\t// If it was a double push, then queue adding the new enpassant square to the gamefile!\n\tif (move.enpassantCreate !== undefined)\n\t\tstate.createEnPassantState(move, boardsim.state.global.enpassant, move.enpassantCreate);\n\n\tif (!move.enpassant && move.promotion === undefined) return false; // No special move to execute, return false to signify we didn't move the piece.\n\n\tconst captureCoords = move.enpassant ? boardsim.state.global.enpassant!.pawn : move.endCoords;\n\tconst capturedPiece = boardutil.getPieceFromCoords(boardsim.pieces, captureCoords);\n\n\tif (capturedPiece) boardchanges.queueCapture(moveChanges, true, capturedPiece); // Delete the piece captured\n\tboardchanges.queueMovePiece(moveChanges, true, piece, move.endCoords); // Move the pawn\n\n\tif (move.promotion !== undefined) {\n\t\t// Handle promotion special move\n\t\tboardchanges.queueDeletePiece(moveChanges, true, {\n\t\t\ttype: piece.type,\n\t\t\tcoords: move.endCoords,\n\t\t\tindex: piece.index,\n\t\t}); // Delete original pawn\n\t\tboardchanges.queueAddPiece(moveChanges, {\n\t\t\ttype: move.promotion,\n\t\t\tcoords: move.endCoords,\n\t\t\tindex: -1,\n\t\t}); // Add promoted piece\n\t}\n\n\treturn true; // Special move was executed!\n}\n\n// Knight Legal Move Calculation --------------------------------------------------------------------------------\n\n/**\n * Calculates the legal knight moves in the current four dimensional variant\n * for both spacelike and timelike dimensions.\n * @param gamefile\n * @param coords - The coordinates of the knight\n * @param color - The color of the knight\n */\nfunction fourDimensionalKnightMove(\n\tgamefile: FullGame,\n\tcoords: Coords,\n\tcolor: Player,\n\tpremove: boolean,\n): Coords[] {\n\tconst individualMoves: Coords[] = [];\n\tconst dim = fourdimensionalgenerator.get4DBoardDimensions();\n\n\tfor (let baseH = 2n; baseH >= -2n; baseH--) {\n\t\tfor (let baseV = 2n; baseV >= -2n; baseV--) {\n\t\t\tfor (let offsetH = 2n; offsetH >= -2n; offsetH--) {\n\t\t\t\tfor (let offsetV = 2n; offsetV >= -2n; offsetV--) {\n\t\t\t\t\t// If the squared distance to the tile is 5, then add the move\n\t\t\t\t\tif (\n\t\t\t\t\t\tbaseH * baseH + baseV * baseV + offsetH * offsetH + offsetV * offsetV ===\n\t\t\t\t\t\t5n\n\t\t\t\t\t) {\n\t\t\t\t\t\tconst x = coords[0] + dim.BOARD_SPACING * baseH + offsetH;\n\t\t\t\t\t\tconst y = coords[1] + dim.BOARD_SPACING * baseV + offsetV;\n\t\t\t\t\t\tconst endCoords: Coords = [x, y];\n\n\t\t\t\t\t\t// Don't allow the move if it's blocked by a friendly piece or void\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\tlegalmoves.testSquareValidity(\n\t\t\t\t\t\t\t\tgamefile.boardsim,\n\t\t\t\t\t\t\t\tgamefile.basegame.gameRules.worldBorder,\n\t\t\t\t\t\t\t\tendCoords,\n\t\t\t\t\t\t\t\tcolor,\n\t\t\t\t\t\t\t\tpremove,\n\t\t\t\t\t\t\t\tfalse,\n\t\t\t\t\t\t\t) === 2\n\t\t\t\t\t\t)\n\t\t\t\t\t\t\tcontinue;\n\n\t\t\t\t\t\t// do not allow knight to leave the 4D board\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\tendCoords[0] <= dim.MIN_X ||\n\t\t\t\t\t\t\tendCoords[0] >= dim.MAX_X ||\n\t\t\t\t\t\t\tendCoords[1] <= dim.MIN_Y ||\n\t\t\t\t\t\t\tendCoords[1] >= dim.MAX_Y\n\t\t\t\t\t\t)\n\t\t\t\t\t\t\tcontinue;\n\n\t\t\t\t\t\t// do not allow the knight to make move if (baseH, baseV) do not match change in 2D chessboard\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\t(endCoords[0] - dim.MIN_X) / dim.BOARD_SPACING -\n\t\t\t\t\t\t\t\t(coords[0] - dim.MIN_X) / dim.BOARD_SPACING !==\n\t\t\t\t\t\t\t\tbaseH ||\n\t\t\t\t\t\t\t(endCoords[1] - dim.MIN_Y) / dim.BOARD_SPACING -\n\t\t\t\t\t\t\t\t(coords[1] - dim.MIN_Y) / dim.BOARD_SPACING !==\n\t\t\t\t\t\t\t\tbaseV\n\t\t\t\t\t\t)\n\t\t\t\t\t\t\tcontinue;\n\t\t\t\t\t\tindividualMoves.push(endCoords);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn individualMoves;\n}\n\n// King Legal Move Calculation ------------------------------------------------------------------------------\n\n/** Calculates the legal king moves in the four dimensional variant. */\nfunction fourDimensionalKingMove(\n\tgamefile: FullGame,\n\tcoords: Coords,\n\tcolor: Player,\n\tpremove: boolean,\n): Coords[] {\n\tconst legalMoves: Coords[] = kingLegalMoves(\n\t\tgamefile.boardsim,\n\t\tgamefile.basegame.gameRules.worldBorder,\n\t\tcoords,\n\t\tcolor,\n\t\tpremove,\n\t);\n\tlegalMoves.push(...specialdetect.kings(gamefile, coords, color, premove)); // Adds legal castling\n\treturn legalMoves;\n}\n\n/**\n * Calculates legal king moves for either the spacelike and timelike dimensions.\n * @param gamefile\n * @param coords - The coordinates of the king\n * @param color - The color of the king\n */\nfunction kingLegalMoves(\n\tboardsim: Board,\n\tworldBorder: UnboundedRectangle | undefined,\n\tcoords: Coords,\n\tcolor: Player,\n\tpremove: boolean,\n): Coords[] {\n\tconst individualMoves: Coords[] = [];\n\tconst dim = fourdimensionalgenerator.get4DBoardDimensions();\n\n\tfor (let baseH = 1n; baseH >= -1n; baseH--) {\n\t\tfor (let baseV = 1n; baseV >= -1n; baseV--) {\n\t\t\tfor (let offsetH = 1n; offsetH >= -1n; offsetH--) {\n\t\t\t\tfor (let offsetV = 1n; offsetV >= -1n; offsetV--) {\n\t\t\t\t\t// only allow moves that change one or two dimensions if triagonals and diagonals are disabled\n\t\t\t\t\tif (\n\t\t\t\t\t\t!fourdimensionalgenerator.getMovementType().STRONG_KINGS_AND_QUEENS &&\n\t\t\t\t\t\tbaseH * baseH + baseV * baseV + offsetH * offsetH + offsetV * offsetV > 2\n\t\t\t\t\t)\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\tif (baseH === 0n && baseV === 0n && offsetH === 0n && offsetV === 0n) continue;\n\n\t\t\t\t\tconst x = coords[0] + dim.BOARD_SPACING * baseH + offsetH;\n\t\t\t\t\tconst y = coords[1] + dim.BOARD_SPACING * baseV + offsetV;\n\t\t\t\t\tconst endCoords: Coords = [x, y];\n\n\t\t\t\t\t// Do not allow the move if it's blocked by a friendly piece or void\n\t\t\t\t\tif (\n\t\t\t\t\t\tlegalmoves.testSquareValidity(\n\t\t\t\t\t\t\tboardsim,\n\t\t\t\t\t\t\tworldBorder,\n\t\t\t\t\t\t\tendCoords,\n\t\t\t\t\t\t\tcolor,\n\t\t\t\t\t\t\tpremove,\n\t\t\t\t\t\t\tfalse,\n\t\t\t\t\t\t) === 2\n\t\t\t\t\t)\n\t\t\t\t\t\tcontinue;\n\n\t\t\t\t\t// do not allow king to leave the 4D board\n\t\t\t\t\tif (\n\t\t\t\t\t\tendCoords[0] <= dim.MIN_X ||\n\t\t\t\t\t\tendCoords[0] >= dim.MAX_X ||\n\t\t\t\t\t\tendCoords[1] <= dim.MIN_Y ||\n\t\t\t\t\t\tendCoords[1] >= dim.MAX_Y\n\t\t\t\t\t)\n\t\t\t\t\t\tcontinue;\n\n\t\t\t\t\tindividualMoves.push(endCoords);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn individualMoves;\n}\n\n// Exports ---------------------------------------------------------------------\n\nexport default {\n\tfourDimensionalPawnMove,\n\tdoFourDimensionalPawnMove,\n\tfourDimensionalKnightMove,\n\tfourDimensionalKingMove,\n};\n"
  },
  {
    "path": "src/shared/chess/logic/gamefile.ts",
    "content": "// src/shared/chess/logic/gamefile.ts\n\nimport type { CoordsKey } from '../util/coordutil.js';\nimport type { GameRules } from '../util/gamerules.js';\nimport type { ClockData } from './clock.js';\nimport type { MovePacket } from '../../types.js';\nimport type { BoundingBox } from '../../util/math/bounds.js';\nimport type { VariantCode } from '../variants/variantdictionary.js';\nimport type { PieceMoveset } from './movesets.js';\nimport type { GameConclusion } from '../util/winconutil.js';\nimport type { VariantOptions } from './initvariant.js';\nimport type { OrganizedPieces } from './organizedpieces.js';\nimport type { SpecialMoveFunction } from './specialmove.js';\nimport type { MoveFull, MoveRecord } from './movepiece.js';\nimport type { ClockValues, MetaData } from '../../types.js';\nimport type { GameState, GlobalGameState } from './state.js';\nimport type { Player, RawType, RawTypeGroup } from '../util/typeutil.js';\n\nimport clock from './clock.js';\nimport jsutil from '../../util/jsutil.js';\nimport variant from '../variants/variant.js';\nimport typeutil from '../util/typeutil.js';\nimport boardutil from '../util/boardutil.js';\nimport movepiece from './movepiece.js';\nimport gamerules from '../util/gamerules.js';\nimport legalmoves from './legalmoves.js';\nimport initvariant from './initvariant.js';\nimport wincondition from './wincondition.js';\nimport checkdetection from './checkdetection.js';\nimport organizedpieces from './organizedpieces.js';\nimport gamefileutility from '../util/gamefileutility.js';\n\ninterface Snapshot {\n\t/** In key format 'x,y':'type' */\n\tposition: Map<CoordsKey, number>;\n\t/** The global state of the game beginning */\n\tstate_global: GlobalGameState;\n\t/** This is the full-move number at the start of the game. Used for converting to ICN notation. */\n\tfullMove: number;\n\t/** The bounding box surrounding the starting position, without padding. INTEGER coords, not floating. */\n\tbox: BoundingBox;\n}\n\n/**\n * Purely game data\n * Used on both sides\n */\ntype Game = {\n\t/** Information about the game */\n\tmetadata: MetaData;\n\t/** The game's start timestamp in milliseconds since epoch, derived from UTCDate/UTCTime metadata. */\n\tdateTimestamp: number;\n\tmoves: MoveRecord[];\n\tgameRules: GameRules;\n\twhosTurn: Player;\n\tgameConclusion?: GameConclusion;\n} & ClockDependant;\n\n/**\n * The Game variables that depend on the clock.\n */\ntype ClockDependant =\n\t| {\n\t\t\tuntimed: true;\n\t\t\tclocks: undefined;\n\t  }\n\t| {\n\t\t\tuntimed: false;\n\t\t\tclocks: ClockData;\n\t  };\n\n/**\n * Game data used for simulating game logic and board state\n * Use by client always, may not be used by the server.\n */\ntype Board = {\n\t/** An array of all types of pieces that are in this game, without their color extension: `['pawns','queens']` */\n\texistingTypes: number[];\n\t/** An array of all RAW piece types that are in this game. */\n\texistingRawTypes: RawType[];\n\n\tmoves: MoveFull[];\n\tpieces: OrganizedPieces;\n\tstate: GameState;\n\n\tpieceMovesets: RawTypeGroup<() => PieceMoveset>;\n\tspecialMoves: RawTypeGroup<SpecialMoveFunction>;\n\n\tspecialVicinity: Record<CoordsKey, RawType[]>;\n\tvicinity: Record<CoordsKey, RawType[]>;\n\n\t/** Whether the gamefile is for the board editor. If true, the piece list will contain MUCH more undefined placeholders, and for every single type of piece, as pieces are added commonly in that! */\n\teditor: boolean;\n\n\t/**\n\t * The variant code. Null for custom/pasted positions without a known variant.\n\t * Is used to infer variant-specific game rules, such as piece movesets.\n\t */\n\tvariant: VariantCode | null;\n\n\t/**\n\t * Information about the beginning snapshot of the game (position, positionString, specialRights, turn)\n\t */\n\tstartSnapshot: Snapshot;\n};\n\n/**\n * Both game data AND board state used on the client-side,\n * and in the future *sometimes* used on the server-side,\n * when the server starts doing legal move validation.\n */\ntype FullGame = {\n\tbasegame: Game;\n\tboardsim: Board;\n};\n\n/** Additional options that may go into the gamefile constructor.\n * Typically used if we're pasting a game, or reloading an online one. */\ninterface Additional {\n\t/** Existing moves, if any, to forward to the front of the game. Should be specified if reconnecting to an online game or pasting a game. */\n\tmoves?: MovePacket[];\n\t/** If a custom position is needed, for instance, when pasting a game, then these options should be included. */\n\tvariantOptions?: VariantOptions;\n\t/** The conclusion of the game, if loading an online game that has already ended. */\n\tgameConclusion?: GameConclusion;\n\t/** Any already existing clock values for the gamefile. */\n\tclockValues?: ClockValues;\n\t/** Whether the gamefile is for the board editor. If true, the piece list will contain MUCH more undefined placeholders, and for every single type of piece, as pieces are added commonly in that! */\n\teditor?: boolean;\n\t/** If present, the resulting gamefile will have a world border this distance away from the starting position's bounding box. */\n\tworldBorderDist?: bigint;\n\t/** Exact dimensions of the world border. OVERRIDES {@link worldBorderDist} if both are specified. */\n\tworldBorder?: BoundingBox;\n}\n\n/** Creates a new {@link Game} object from provided arguments */\nfunction initGame(\n\tmetadata: MetaData,\n\tdateTimestamp: number,\n\tvariantCode: VariantCode | null,\n\tgameConclusion?: GameConclusion,\n\tclockValues?: ClockValues,\n\tvariantOptions?: VariantOptions,\n): Game {\n\tconst gameRules = initvariant.getVariantGamerules(variantCode, dateTimestamp, variantOptions);\n\tconst clockDependantVars: ClockDependant = clock.init(\n\t\tnew Set(gameRules.turnOrder),\n\t\tmetadata.TimeControl ?? '-', // Fallback to untimed if TimeControl metadata not specified\n\t);\n\tconst game: Game = {\n\t\tmetadata,\n\t\tdateTimestamp,\n\t\tmoves: [],\n\t\tgameRules,\n\t\twhosTurn: gameRules.turnOrder[0]!,\n\t\t...clockDependantVars,\n\t};\n\n\tif (clockValues) {\n\t\tif (game.untimed)\n\t\t\tthrow Error(\n\t\t\t\t'Cannot set clock values for untimed game. Should not have specified clockValues.',\n\t\t\t);\n\t\tclock.edit(game.clocks, clockValues);\n\t}\n\n\tgamefileutility.setConclusion(game, gameConclusion);\n\n\treturn game;\n}\n\n/** Creates a new {@link Board} object from provided arguments */\nfunction initBoard(\n\tgameRules: GameRules,\n\tvariantCode: VariantCode | null,\n\tdateTimestamp: number,\n\tvariantOptions?: VariantOptions,\n\teditor: boolean = false,\n\t/** Only has an effect if the `worldBorder` gamerule is not present. */\n\tworldBorderDist?: bigint,\n): Board {\n\tconst { position, state_global, fullMove } = initvariant.getVariantVariantOptions(\n\t\tgameRules,\n\t\tvariantCode,\n\t\tdateTimestamp,\n\t\tvariantOptions,\n\t);\n\n\tconst state: GameState = {\n\t\tlocal: {\n\t\t\tmoveIndex: -1,\n\t\t\tinCheck: false,\n\t\t\tchecks: [],\n\t\t},\n\t\tglobal: jsutil.deepCopyObject(state_global),\n\t};\n\n\tconst { pieceMovesets, specialMoves } = initvariant.getPieceMovesets(\n\t\tvariantCode,\n\t\tdateTimestamp,\n\t\tgameRules.slideLimit,\n\t);\n\n\tconst { pieces, existingTypes, existingRawTypes } = organizedpieces.processInitialPosition(\n\t\tposition,\n\t\tpieceMovesets,\n\t\tgameRules.turnOrder,\n\t\teditor,\n\t\tgameRules.promotionsAllowed,\n\t);\n\n\ttypeutil.deleteUnusedFromRawTypeGroup(existingRawTypes, specialMoves);\n\n\tlet startingPositionBox = boardutil.getBoundingBoxOfAllPieces(pieces);\n\t// Fallback if no pieces present\n\tif (startingPositionBox === undefined)\n\t\tstartingPositionBox = { left: 1n, right: 8n, bottom: 1n, top: 8n };\n\n\t// worldBorder: Receives the smaller of the two, if either the variant property or the override are defined.\n\tlet worldBorderProperty: bigint | undefined = variant.getVariantWorldBorder(variantCode);\n\tif (worldBorderDist !== undefined) {\n\t\tif (worldBorderProperty === undefined)\n\t\t\tworldBorderProperty = worldBorderDist; // Use the provided world border if the variant doesn't have one.\n\t\telse if (worldBorderDist < worldBorderProperty) worldBorderProperty = worldBorderDist; // Use the smaller of the two if both exist.\n\t}\n\n\tif (gameRules.worldBorder === undefined && worldBorderProperty !== undefined) {\n\t\t// No override for exact world border dimensions provided, calculate it using the provided distance.\n\t\tgameRules.worldBorder = {\n\t\t\tleft: startingPositionBox.left - worldBorderProperty,\n\t\t\tright: startingPositionBox.right + worldBorderProperty,\n\t\t\tbottom: startingPositionBox.bottom - worldBorderProperty,\n\t\t\ttop: startingPositionBox.top + worldBorderProperty,\n\t\t};\n\t}\n\n\tconst startSnapshot: Snapshot = {\n\t\tposition,\n\t\tstate_global,\n\t\tfullMove,\n\t\tbox: startingPositionBox,\n\t};\n\n\tconst vicinity = legalmoves.genVicinity(pieceMovesets);\n\tconst specialVicinity = legalmoves.genSpecialVicinity(\n\t\tvariantCode,\n\t\tdateTimestamp,\n\t\texistingRawTypes,\n\t);\n\n\tconst moves: MoveFull[] = [];\n\n\treturn {\n\t\tpieces,\n\t\texistingTypes,\n\t\texistingRawTypes,\n\t\tstate,\n\t\tmoves,\n\t\tvicinity,\n\t\tspecialVicinity,\n\t\tpieceMovesets,\n\t\tspecialMoves,\n\t\teditor,\n\t\tvariant: variantCode,\n\t\tstartSnapshot,\n\t};\n}\n\n/**\n * Attaches a board to a specific game. Used for loading a game after it was started.\n * @param validateMoves - During game construction, throws an error if any move played is illegal.\n */\nfunction loadGameWithBoard(\n\tbasegame: Game,\n\tboardsim: Board,\n\tmoves: MovePacket[] = [],\n\tvalidateMoves?: boolean,\n): FullGame {\n\tconst gamefile = { basegame, boardsim };\n\n\t// Do we need to convert any checkmate win conditions to royalcapture?\n\tif (!wincondition.isCheckmateCompatibleWithGame(gamefile))\n\t\tgamerules.swapCheckmateForRoyalCapture(basegame.gameRules);\n\n\t{\n\t\t// Set the game's `inCheck` and `checks` properties at the front of the game.\n\t\tconst trackChecks = gamefileutility.isOpponentUsingWinCondition(\n\t\t\tbasegame,\n\t\t\tbasegame.whosTurn,\n\t\t\t'checkmate',\n\t\t);\n\t\tconst checkResults = checkdetection.detectCheck(gamefile, basegame.whosTurn, trackChecks); // { check: boolean, royalsInCheck: Coords[], checks?: CheckInfo[] }\n\t\tboardsim.state.local.inCheck = checkResults.check ? checkResults.royalsInCheck : false;\n\t\tif (trackChecks) boardsim.state.local.checks = checkResults.checks ?? [];\n\t}\n\n\tmovepiece.makeAllMovesInGame(gamefile, moves, validateMoves);\n\t// Do not overwrite pre-existing server conclusion, if present.\n\tif (basegame.gameConclusion === undefined) gamefileutility.doGameOverChecks(gamefile);\n\treturn gamefile;\n}\n\n/**\n * Initiates both the base game and board of the FullGame at the same time.\n * Used on just the client.\n * @param validateMoves - During game construction, throws an error if any move played is illegal.\n */\nfunction initFullGame(\n\tmetadata: MetaData,\n\tdateTimestamp: number,\n\tvariantCode: VariantCode | null,\n\tadditional: Additional = {},\n\tvalidateMoves?: true,\n): FullGame {\n\tconst basegame = initGame(\n\t\tmetadata,\n\t\tdateTimestamp,\n\t\tvariantCode,\n\t\tadditional.gameConclusion,\n\t\tadditional.clockValues,\n\t\tadditional.variantOptions,\n\t);\n\tconst boardsim = initBoard(\n\t\tbasegame.gameRules,\n\t\tvariantCode,\n\t\tdateTimestamp,\n\t\tadditional.variantOptions,\n\t\tadditional.editor,\n\t\tadditional.worldBorderDist,\n\t);\n\treturn loadGameWithBoard(basegame, boardsim, additional.moves, validateMoves);\n}\n\nexport type { Game, Board, FullGame, Snapshot, ClockDependant, Additional };\n\nexport default {\n\tinitGame,\n\tinitBoard,\n\tinitFullGame,\n};\n"
  },
  {
    "path": "src/shared/chess/logic/icn/icncommentutils.ts",
    "content": "// src/shared/chess/logic/icn/icncommentutils.ts\n\n/**\n * This scripts creates and parses embeded command sequences\n * that go into the comments of moves in Infinite Chess Notation.\n *\n * An example of a clock embeded sequence is '[%clk 0:01:57.3]'\n *\n * More info on embeded command sequences:\n * https://www.enpassant.dk/chess/palview/enhancedpgn.htm\n */\n\n// Types ----------------------------------------------------------------------------\n\n/** All valid command sequences. */\nconst validCommands = ['clk'] as const;\n\ntype Command = (typeof validCommands)[number];\n\n/**\n * Represents a generic command ready to be embedded,\n * containing the command name and its formatted value string.\n */\nexport interface CommandObject {\n\t/** The name of the command (e.g., 'clk', 'timestamp'). */\n\tcommand: Command; // Use the Command union type\n\t/** The string value associated with the command. */\n\tvalue: string;\n}\n\n/** Defines the structure returned when extracting commands from a comment string. */\ninterface ExtractedCommentData {\n\t/**\n\t * The remaining comment text after all command sequences have been removed.\n\t * Leading/trailing whitespace is trimmed, and multiple spaces resulting\n\t * from command removal are collapsed into single spaces.\n\t */\n\tcomment: string;\n\t/**\n\t * A record where keys are the command names (e.g., \"clk\", \"timestamp\")\n\t * and values are the corresponding argument strings associated with those commands.\n\t */\n\tcommands: CommandObject[];\n}\n\n// General Command Functions --------------------------------------------------------------------\n\n/**\n * Combines a comment string and a list of command objects into a single\n * string suitable for a PGN comment field (without outer curly braces \"{}\").\n * @param [comment] Optional. The human-readable comment string. Can be empty or contain only whitespace. (e.g. \"Sacrifice!!!\")\n * @param cmdObjs An array of CommandObject instances. Can be empty.\n * @returns A combined string with formatted commands followed by the comment (e.g. \"[%clk 0:01:57.3] Sacrifice!!!\").\n */\nfunction combineCommentAndCommands(cmdObjs: CommandObject[], comment?: string): string {\n\t/** All parts going into the comment, including command sequences and the human-readable comment. */\n\tconst parts: string[] = [];\n\tparts.push(...cmdObjs.map(formatCommandSequence));\n\tif (comment && comment.trim().length > 0) parts.push(comment.trim());\n\treturn parts.join(' ');\n}\n\n/**\n * Takes a command object (containing a command name and its value)\n * and constructs the standard embedded command sequence string.\n *\n * Example: { command: 'clk', value: '1:23:45.6' } => \"[%clk 1:23:45.6]\"\n * Example: { command: 'timestamp', value: '1678886400' } => \"[%timestamp 1678886400]\"\n */\nfunction formatCommandSequence(cmdObj: CommandObject): string {\n\treturn `[%${cmdObj.command} ${cmdObj.value}]`;\n}\n\n/**\n * Parses a comment string (expected without the outer curly braces `{}`)\n * to extract embedded command sequences (like [%clk ...] or [%timestamp ...])\n * and the remaining human-readable comment text.\n *\n * Command sequences may appear anywhere within the string.\n *\n * @param commentString The comment content string.\n * @returns An object containing the extracted commands and the cleaned comment text.\n */\nfunction extractCommandsFromComment(commentString: string): ExtractedCommentData {\n\tconst commands: CommandObject[] = [];\n\tconst commandRegex = /\\[%(\\w+) ([^\\]]+)\\]/g; // The 'g' flag makes it find all occurrences globally.\n\n\t// First, extract all commands and store them.\n\t// We use matchAll for a more robust way to get all matches and capture groups.\n\tconst matches = commentString.matchAll(commandRegex);\n\tfor (const match of matches) {\n\t\tconst commandName = match[1]! as Command; // e.g., \"clk\"\n\t\tconst commandValue = match[2]!; // e.g., \"0:09:56.7\"\n\t\t// Only parse valid commands, simply ignore and discard all others\n\t\tif (validCommands.includes(commandName))\n\t\t\tcommands.push({ command: commandName, value: commandValue });\n\t}\n\n\t// Second, remove all command sequences from the original string to get the raw comment.\n\t// Replace each found command sequence with an empty string.\n\tlet rawComment = commentString.replace(commandRegex, '');\n\n\t// Third, clean up the resulting comment string:\n\t// Replace multiple consecutive spaces (which might occur where commands were removed) with a single space.\n\trawComment = rawComment.trim().replace(/\\s{2,}/g, ' ');\n\n\treturn {\n\t\tcomment: rawComment,\n\t\tcommands,\n\t};\n}\n\n// Parsing 'clk' Command Sequences --------------------------------------------------------------------\n\n/**\n * Takes a time in milliseconds and creates a CommandObject containing\n * the 'clk' command name and the time formatted as H:MM:SS.D.\n * The input milliseconds are rounded UP to the nearest 100ms boundary\n * before conversion.\n */\nfunction createClkCommandObject(timeMillis: number): CommandObject {\n\tlet formattedValue: string;\n\n\tif (typeof timeMillis !== 'number')\n\t\tthrow Error(\n\t\t\t`Invalid typeof for timeMillis when constructing clk comment embeded command sequence: expected number, got ${typeof timeMillis}`,\n\t\t);\n\tif (isNaN(timeMillis))\n\t\tthrow Error(`timeMillis is NaN when constructing clk comment embeded command sequence!`);\n\n\t// Handle edge case: if time is 0 or less, return 0 time object.\n\tif (timeMillis <= 0) {\n\t\tformattedValue = '0:00:00.0';\n\t} else {\n\t\t// Round the total milliseconds UP to the nearest 100ms boundary.\n\t\tconst roundedUpMillis = Math.ceil(timeMillis / 100) * 100;\n\n\t\t// Calculate H:MM:SS.D based on the rounded-up value.\n\t\tconst totalSecondsRounded = Math.floor(roundedUpMillis / 1000);\n\t\tconst hours = Math.floor(totalSecondsRounded / 3600);\n\t\tconst minutes = Math.floor((totalSecondsRounded % 3600) / 60);\n\t\tconst seconds = totalSecondsRounded % 60;\n\n\t\t// Calculate tenths based on the rounded-up milliseconds.\n\t\tconst tenths = (roundedUpMillis % 1000) / 100;\n\n\t\t// Convert minutes and seconds to strings and pad with leading zeros if needed.\n\t\tconst paddedMinutes = minutes.toString().padStart(2, '0');\n\t\tconst paddedSeconds = seconds.toString().padStart(2, '0');\n\n\t\t// Create the formatted time value string\n\t\tformattedValue = `${hours}:${paddedMinutes}:${paddedSeconds}.${tenths}`;\n\t}\n\n\t// Return the command object conforming to CommandObject\n\treturn {\n\t\tcommand: 'clk', // The specific command name for this function\n\t\tvalue: formattedValue,\n\t};\n}\n\n/**\n * Takes a clock time string value (extracted from a %clk command) and returns\n * the number of milliseconds represented by that time.\n * @param clkValueString The time string in H:MM:SS.D format (e.g., \"1:23:45.6\").\n * @returns The total time in milliseconds.\n */\nfunction getMillisFromClkTimeValue(clkValueString: string): number {\n\t// Regular expression to match the format and capture the time components.\n\tconst regex = /^(\\d+):(\\d{2}):(\\d{2})\\.(\\d)$/;\n\n\tconst match = clkValueString.match(regex);\n\n\tif (!match)\n\t\tthrow new Error(\n\t\t\t`Clock time value string is not in the required H:MM:SS.D format! (${clkValueString})`,\n\t\t);\n\n\t// Extract the captured groups. match[0] is the full string.\n\t// Groups are 1-indexed.\n\tconst hoursStr = match[1];\n\tconst minutesStr = match[2];\n\tconst secondsStr = match[3];\n\tconst tenthsStr = match[4];\n\n\t// Convert the captured string parts to numbers.\n\tconst hours = Number(hoursStr);\n\tconst minutes = Number(minutesStr);\n\tconst seconds = Number(secondsStr);\n\tconst tenths = Number(tenthsStr);\n\n\t// Calculate the total time in milliseconds.\n\t// prettier-ignore\n\tconst totalMillis =\n\t\t(hours * 3600 * 1000) +  // Hours to milliseconds\n\t\t(minutes * 60 * 1000) +  // Minutes to milliseconds\n\t\t(seconds * 1000) +       // Seconds to milliseconds\n\t\t(tenths * 100); // Tenths of a second to milliseconds\n\n\treturn totalMillis;\n}\n\n// Exports ----------------------------------------------------------------------------\n\nexport default {\n\tcombineCommentAndCommands,\n\textractCommandsFromComment,\n\n\tcreateClkCommandObject,\n\tgetMillisFromClkTimeValue,\n};\n"
  },
  {
    "path": "src/shared/chess/logic/icn/icnconverter.ts",
    "content": "// src/shared/chess/logic/icn/icnconverter.ts\n\n/**\n * Universal Infinite Chess Notation [Converter] and Interface\n * by Naviary and Andreas Tsevas\n * https://github.com/tsevasa/infinite-chess-notation\n *\n * This script converts games from a JSON notation to a\n * compact ICN (Infinite Chess Noation) and back,\n * still human-readable, but taking less space to describe positions.\n */\n\nimport type { BaseRay } from '../../../util/math/geometry.js';\nimport type { MetaData } from '../../../types.js';\nimport type { GameRules } from '../../util/gamerules.js';\nimport type { UnboundedRectangle } from '../../../util/math/bounds.js';\nimport type { GameruleWinCondition } from '../../util/winconutil.js';\nimport type { EnPassant, GlobalGameState } from '../state.js';\n\nimport jsutil from '../../../util/jsutil.js';\nimport bimath from '../../../util/math/bimath.js';\nimport typeutil from '../../util/typeutil.js';\nimport winconutil from '../../util/winconutil.js';\nimport coordutil, { Coords, CoordsKey } from '../../util/coordutil.js';\nimport icncommentutils, { CommandObject } from './icncommentutils.js';\nimport {\n\trawTypes as r,\n\text as e,\n\tplayers as p,\n\tRawType,\n\tPlayer,\n\tPlayerGroup,\n} from '../../util/typeutil.js';\n\n// Types ------------------------------------------------------------------------------\n\n/** Represents the game format coming IN to the converter. */\ninterface LongFormatIn extends LongFormatBase {\n\tmetadata: MetaData;\n\tmoves?: MovePreprint[];\n}\n\n/** Represents the game format coming OUT of the converter. */\ninterface LongFormatOut extends LongFormatBase {\n\tmetadata: MetaData;\n\tmoves?: MoveParsed[];\n}\n\n/** Shared properties between in & out game formats. */\ninterface LongFormatBase {\n\t/**\n\t * IN => Required if you want the position specified in the ICN. Otherwise, Variant, UTCDate, and UTCTime metadata are required.\n\t * OUT => Specified if the ICN contains the position. Otherwise, Variant metadata is required in the ICN.\n\t */\n\tposition?: Map<CoordsKey, number>;\n\tgameRules: GameRules;\n\tfullMove: number;\n\t/** Same rules as for {@link LongFormatBase['position']}, but for the specialRights. */\n\tstate_global: Partial<GlobalGameState>;\n\t/** Overrides the variant's preset annotations, if specified. */\n\tpresetAnnotes?: PresetAnnotes;\n}\n\n/** The named capture groups of a shortform move. */\ntype NamedCaptureMoveGroups = {\n\tstartCoordsKey: CoordsKey;\n\tendCoordsKey: CoordsKey;\n\t/** The piece abbreviation of the promoted piece, if present. */\n\tpromotionAbbr?: string;\n\t/**\n\t * An un-parsed comment on a move. This may contain embedded command sequences.\n\t * However it won't include the opening \"{\" or closing \"}\" braces.\n\t */\n\tcomment?: string;\n};\n\n/** Input to the ICN serializer. Includes optional information for prettifying the move list. */\ninterface MovePreprint extends MoveParsed {\n\t/** The type of piece moved */\n\ttype?: number;\n\tflags?: {\n\t\t/** Whether the move delivered check. */\n\t\tcheck: boolean;\n\t\t/** Whether the move delivered mate (or the killing move). */\n\t\tmate: boolean;\n\t\t/** Whether the move caused a capture */\n\t\tcapture: boolean;\n\t};\n}\n\n/** Output of the ICN parser. Includes information extractable from a shortform move. */\ninterface MoveParsed extends MoveCoords {\n\ttoken: string;\n\t/**\n\t * Any human-readable comment made on the move, specified in the ICN.\n\t * FUTURE: This should go back into the ICN when copying the game.\n\t */\n\tcomment?: string;\n\t/** How much time the player had left after they made their move, in millis. */\n\tclockStamp?: number;\n}\n\n/** The bare minimum information needed to make a move. */\ninterface MoveCoords {\n\tstartCoords: Coords;\n\tendCoords: Coords;\n\t/** Present if the move was a special-move promotion. This is the integer type of the promoted piece. */\n\tpromotion?: number;\n}\n\n/**\n * Permanent preset annotations. Can't be erased.\n * Helpful for emphasizing important lines/squares in showcasings.\n */\ntype PresetAnnotes = {\n\t/** In compacted string form: '23,94|23,76' */\n\tsquares?: Coords[];\n\t/** In compacted string form: '23,94>-1,0|23,76>-1,0' */\n\trays?: BaseRay[];\n};\n\n// Dictionaries -----------------------------------------------------------------------\n\n/**\n * 1-2 letter codes for each player number.\n * This is used for specifying the turn order in ICN.\n */\nconst player_codes = {\n\t[p.NEUTRAL]: 'n', // I dont think we need this, good to have in case\n\t[p.WHITE]: 'w',\n\t[p.BLACK]: 'b',\n\t// Colored players\n\t[p.RED]: 'r',\n\t[p.BLUE]: 'bu',\n\t[p.YELLOW]: 'y',\n\t[p.GREEN]: 'g',\n} as const;\nconst player_codes_inverted = jsutil.invertObj(player_codes);\n\ntype PlayerCode = (typeof player_codes)[keyof typeof player_codes];\n\n/** 1-2 letter codes for the standard white, black, and neutral pieces. */\n// prettier-ignore\nconst piece_codes = {\n\t[r.KING + e.W]: 'K',          [r.KING + e.B]: 'k',\n\t[r.PAWN + e.W]: 'P',          [r.PAWN + e.B]: 'p',\n\t[r.KNIGHT + e.W]: 'N',        [r.KNIGHT + e.B]: 'n',\n\t[r.BISHOP + e.W]: 'B',        [r.BISHOP + e.B]: 'b',\n\t[r.ROOK + e.W]: 'R',          [r.ROOK + e.B]: 'r',\n\t[r.QUEEN + e.W]: 'Q',         [r.QUEEN + e.B]: 'q',\n\t[r.AMAZON + e.W]: 'AM',       [r.AMAZON + e.B]: 'am',\n\t[r.HAWK + e.W]: 'HA',         [r.HAWK + e.B]: 'ha',\n\t[r.CHANCELLOR + e.W]: 'CH',   [r.CHANCELLOR + e.B]: 'ch',\n\t[r.ARCHBISHOP + e.W]: 'AR',   [r.ARCHBISHOP + e.B]: 'ar',\n\t[r.GUARD + e.W]: 'GU',        [r.GUARD + e.B]: 'gu',\n\t[r.CAMEL + e.W]: 'CA',        [r.CAMEL + e.B]: 'ca',\n\t[r.GIRAFFE + e.W]: 'GI',      [r.GIRAFFE + e.B]: 'gi',\n\t[r.ZEBRA + e.W]: 'ZE',        [r.ZEBRA + e.B]: 'ze',\n\t[r.CENTAUR + e.W]: 'CE',      [r.CENTAUR + e.B]: 'ce',\n\t[r.ROYALQUEEN + e.W]: 'RQ',   [r.ROYALQUEEN + e.B]: 'rq',\n\t[r.ROYALCENTAUR + e.W]: 'RC', [r.ROYALCENTAUR + e.B]: 'rc',\n\t[r.KNIGHTRIDER + e.W]: 'NR',  [r.KNIGHTRIDER + e.B]: 'nr',\n\t[r.HUYGEN + e.W]: 'HU',       [r.HUYGEN + e.B]: 'hu',\n\t[r.ROSE + e.W]: 'RO',         [r.ROSE + e.B]: 'ro',\n\t// Neutrals\n\t[r.OBSTACLE + e.N]: 'ob',\n\t[r.VOID + e.N]: 'vo',\n};\nconst piece_codes_inverted = jsutil.invertObj(piece_codes);\n\n/** The codes for raw, color-less piece types. */\nconst piece_codes_raw = {\n\t[r.KING]: 'k',\n\t[r.PAWN]: 'p',\n\t[r.KNIGHT]: 'n',\n\t[r.BISHOP]: 'b',\n\t[r.ROOK]: 'r',\n\t[r.QUEEN]: 'q',\n\t[r.AMAZON]: 'am',\n\t[r.HAWK]: 'ha',\n\t[r.CHANCELLOR]: 'ch',\n\t[r.ARCHBISHOP]: 'ar',\n\t[r.GUARD]: 'gu',\n\t[r.CAMEL]: 'ca',\n\t[r.GIRAFFE]: 'gi',\n\t[r.ZEBRA]: 'ze',\n\t[r.CENTAUR]: 'ce',\n\t[r.ROYALQUEEN]: 'rq',\n\t[r.ROYALCENTAUR]: 'rc',\n\t[r.KNIGHTRIDER]: 'nr',\n\t[r.HUYGEN]: 'hu',\n\t[r.ROSE]: 'ro',\n\t// Neutrals\n\t[r.OBSTACLE]: 'ob',\n\t[r.VOID]: 'vo',\n};\nconst piece_codes_raw_inverted = jsutil.invertObj(piece_codes_raw);\n\n// Variables ------------------------------------------------------------------\n\n/** The desired ordering metadata should be placed in the ICN */\nconst metadata_ordering: (keyof MetaData)[] = [\n\t'Event',\n\t'Site',\n\t'Variant',\n\t'Round',\n\t'UTCDate',\n\t'UTCTime',\n\t'TimeControl',\n\t'White',\n\t'Black',\n\t'WhiteID',\n\t'BlackID',\n\t'WhiteElo',\n\t'BlackElo',\n\t'WhiteRatingDiff',\n\t'BlackRatingDiff',\n\t'Result',\n\t'Termination',\n];\n\n// Defaults when pasting an ICN ----------------------------------------------------------\n\n/**\n * The default promotions allowed, if the ICN does not specify.\n * If, when converting a game into ICN, the promotionsAllowed\n * gamerule matches this, then we won't specify custom promotions in the ICN.\n */\nconst default_promotions: RawType[] = [r.QUEEN, r.ROOK, r.BISHOP, r.KNIGHT];\n\n/** Tests if the provided array of legal promotions is the default set of promotions. */\nfunction isPromotionListDefaultPromotions(promotionList: RawType[]): boolean {\n\tif (promotionList.length !== default_promotions.length) return false;\n\treturn default_promotions.every((promotion) => promotionList.includes(promotion));\n}\n\n/** The default win condition for each player, if none specified in the ICN. */\nconst default_win_condition = 'checkmate' as const;\n/** The default turn order, if none specified in the ICN. */\nconst defaultTurnOrder = [p.WHITE, p.BLACK];\n/** The default full move, if none specified in the ICN. */\nconst defaultFullMove = 1;\n\n//////////////////////////////////////////////////////////////////////////////////////////////////////////////\n//\t\t\t\t\t\t\t\t\t\t\tREGULAR EXPRESSIONS\t\t\t\t\t\t\t\t\t\t\t\t//\n//////////////////////////////////////////////////////////////////////////////////////////////////////////////\n\n/**\n * Simulates possessive behavior for a regex pattern string `str` (e.g., \\d+)\n * using the lookahead/named backreference technique `(?:(?=(?<name>str))\\k<name>)`.\n * Can essentially transform any (...?), (...+), or (...*) regex into a possessive version (...?+), (...?+), or (...*+).\n *\n * Using this prevents catastrophic backtracking in regexes, as once a possessive group is matched,\n * those characters can never be released to see if the string can be matched in a different way.\n * @param {string} str - Regex pattern string to make possessive.\n * @returns {string} Pattern string with possessive simulation.\n */\nconst possessive = (() => {\n\tlet counter = 0;\n\t// The actual function that gets assigned to possessive()\n\treturn function (str: string): string {\n\t\tconst uniqueGroupName = `_g${counter++}`; // Generate unique name internally\n\t\treturn String.raw`(?:(?=(?<${uniqueGroupName}>${str}))\\k<${uniqueGroupName}>)`;\n\t};\n})();\n\nconst countingNumberSource = String.raw`[1-9]\\d*`; // 1+   Positive. Disallows leading 0's\nconst wholeNumberSource = String.raw`(?:0|[1-9]\\d*)`; // 0+   Positive. Disallows leading 0's unless it's 0\nconst integerSource = String.raw`(?:0|-?[1-9]\\d*)`; // Prevents \"-0\", or numbers with leading 0's like \"000005\"\nconst unboundedIntegerSource = String.raw`(?:_|${integerSource})`; // Allows _ as a placeholder for infinity\n\nconst coordsKeyRegexSource = `${integerSource},${integerSource}`; // '-1,2'\n\nconst piece_code_regex_source = '[a-zA-Z]{1,2}';\nconst raw_piece_code_regex_source = '[a-z]{1,2}';\n\n/**\n * Returns a regex for matching a piece abbreviation like '3Q' or 'nr'. '3Q' => Player-3 queen (red)\n * Optionally captures the piece abbreviation, and the player\n * number if present, using custom capture group names.\n * Disallows negatives, or leading 0's\n *\n * This prevents duplicate capture group names when a bigger regex contains\n * multiple smaller pieceAbbrev regexes, as we can make them different.\n * @param capturing - Whether to capture the player number and piece abbreviation.\n */\nfunction getPieceAbbrevRegexSource(capturing: boolean): string {\n\tconst player = capturing ? '<player>' : ':';\n\tconst abbrev = capturing ? '<abbrev>' : ':';\n\tconst result = `(?${player}${wholeNumberSource})?(?${abbrev}${piece_code_regex_source})`;\n\t// console.log(\"Generated PieceAbbrev Regex Source:\", result);\n\treturn result;\n}\n\n/**\n * A regex for matching a single piece entry in a shortform position in ICN.\n * For example, 'P1,2+' => Pawn at 1,2 with special right.\n * It optionally captures the piece abbreviation, coords key, and special right into named groups.\n */\nfunction getPieceEntryRegexSource(capturing: boolean): string {\n\tconst pieceAbbr = capturing ? '<pieceAbbr>' : ':';\n\tconst coordsKey = capturing ? '<coordsKey>' : ':';\n\tconst specialRight = capturing ? '<specialRight>' : ':';\n\n\treturn String.raw`(?${pieceAbbr}${getPieceAbbrevRegexSource(false)})(?${coordsKey}${coordsKeyRegexSource})(?${specialRight}\\+)?`; // 'P1,2+' => Pawn at 1,2 with special right\n}\n\n/** Returns a regex source for matching the promotion segment in a move, optionally capturing  */\nfunction getPromotionRegexSource(capturing: boolean): string {\n\tconst promotionAbbr = capturing ? '<promotionAbbr>' : ':';\n\treturn `(?:=(?${promotionAbbr}${getPieceAbbrevRegexSource(false)}))?`; // '=Q' => Promotion to queen\n}\n/**\n * A regex for matching a move in the MOST COMPACT form: '1,7>2,8=Q'\n * The start coords, end coords, and promotion abbrev are all captured into named groups.\n */\nconst moveRegexCompact = new RegExp(\n\t`^(?<startCoordsKey>${coordsKeyRegexSource})>(?<endCoordsKey>${coordsKeyRegexSource})${getPromotionRegexSource(true)}$`,\n);\n/**\n * A regex for dynamically matching all forms of a move in ICN.\n * The move may optionally include a piece abbreviation, spaces between segments,\n * a separator of \">\" or \"x\", check/mate flags \"+\" or \"#\", symbols !?, ?!, !!, and a comment.\n * \"P1,7 x 2,8 =Q + !! {Promotion!!!}\"\n *\n * It optionally captures the start coords, end coords, promotion abbrev, and the comment, all into named groups.\n */\nfunction getMoveRegexSource(capturing: boolean): string {\n\tconst startCoordsKey = capturing ? '<startCoordsKey>' : ':';\n\tconst endCoordsKey = capturing ? '<endCoordsKey>' : ':';\n\tconst comment = capturing ? '<comment>' : ':';\n\tconst result =\n\t\tpossessive(`(?:${getPieceAbbrevRegexSource(false)})?`) + // Optional starting piece abbreviation \"P\"   DOESN'T NEED TO BE CAPTURED, this avoids a crash cause of duplicate capture group names\n\t\t`(?${startCoordsKey}${coordsKeyRegexSource})` + // Starting coordinates\n\t\tpossessive(` ?`) + // Optional space\n\t\t`[>x]` + // Separator\n\t\tpossessive(` ?`) + // Optional space\n\t\t`(?${endCoordsKey}${coordsKeyRegexSource})` + // Ending coordinates\n\t\tpossessive(` ?`) + // Optional space\n\t\tpossessive(getPromotionRegexSource(capturing)) + // Optional promotion (\"=\" REQUIRED)\n\t\tpossessive(` ?`) + // Optional space\n\t\tpossessive(`[+#]?`) + // Optional check/checkmate\n\t\tpossessive(` ?`) + // Optional space\n\t\tpossessive(`(?:[!?]{1,2})?`) + // Optional symbols: !?, ?!, !!\n\t\tpossessive(' ?') + // Optional space\n\t\tpossessive(String.raw`(?:\\{(?${comment}[^}]+)\\})?`); // Optional comment (not-greedy). Comments should NOT contain a closing brace \"}\".\n\t// console.log(\"Generated Move Regex Source:\", result);\n\treturn result;\n}\n// console.log(\"MoveRegexSource:\", getMoveRegexSource(false));\n\n/**\n * Construct the regexes for matching sections of the ICN.\n *\n * [Variant \"Classical\"] w 3,4 0/100 1 (8;Q,R,B,N|1;q,r,b,n) checkmate Rays:14,-140>-1,-1 P1,2+|P2,2+|P3,2+|P4,2+|P5,2+\n */\n\n/**\n * Matches following whitespace, or end of string.\n * Adding this to many of the section regexes prevents them from\n * confusing other sections with similar starts.\n */\nconst whiteSpaceOrEnd = String.raw`(?:\\s+|$)`; // Matches whitespace or end of string\nconst whiteSpaceOrEndRegex = new RegExp(whiteSpaceOrEnd, 'y');\n\n/** Regex source that matches and captures a single metadata entry. */\nconst singleMetadataSource = String.raw`\\[([a-zA-Z]+)\\s+\"([^\"]{1,200})\"\\]`; // Max metadata value length of 200 chars for safety. This prevents, if we forget a closing \", the regex consuming the entirity of the ICN\nconst metadataRegex = new RegExp(\n\tString.raw`${singleMetadataSource}(?:\\s+${singleMetadataSource})*${whiteSpaceOrEnd}`,\n\t'y',\n); // 'y' flag for sticky matching (only matches at the regex's lastIndex property, not after)\n\nconst turnOrderRegex = new RegExp(\n\tString.raw`(?<turnOrder>${raw_piece_code_regex_source}(?::${raw_piece_code_regex_source})*)${whiteSpaceOrEnd}`,\n\t'y',\n);\n\nconst enpassantRegex = new RegExp(\n\tString.raw`(?<enpassant>${coordsKeyRegexSource})${whiteSpaceOrEnd}`,\n\t'y',\n);\n\nconst moveRuleRegex = new RegExp(\n\tString.raw`(?<moveRule>${wholeNumberSource}/${countingNumberSource})${whiteSpaceOrEnd}`,\n\t'y',\n);\n\nconst fullMoveRegex = new RegExp(\n\tString.raw`(?<fullMove>${countingNumberSource})${whiteSpaceOrEnd}`,\n\t'y',\n);\n\nconst promotionRanksSource = `${integerSource}(?:,${integerSource})*`; // '8,16,24,32'\nconst promotionsAllowedSource = `${piece_code_regex_source}(?:,${piece_code_regex_source})*`; // 'q,r,b,n'\nconst singlePlayerPromotionSource = `(?:${promotionRanksSource}(?:;${promotionsAllowedSource})?)?`; // '8,16,24,32;q,r,b,n' | ''\n/** Captures the promotion ranks and promotions allowed section in the ICN. */\nconst promotionsRegex = new RegExp(\n\tString.raw`\\((?<promotions>${singlePlayerPromotionSource}(?:\\|${singlePlayerPromotionSource})*)\\)${whiteSpaceOrEnd}`,\n\t'y',\n);\n\n/**\n * Matches the world border segment in ICN: 'left,right,bottom,top'\n * Example: '-7,16,-7,16'\n * `_` can be used to represent infinity.\n */\nconst worldBorderRegex = new RegExp(\n\tString.raw`(?<worldBorder>${unboundedIntegerSource},${unboundedIntegerSource},${unboundedIntegerSource},${unboundedIntegerSource})${whiteSpaceOrEnd}`,\n\t'y',\n);\n\nconst singleWinConSource = `(?:${winconutil.GAMERULE_WIN_CONDITIONS.join('|')})`; // 'royalcapture'\nconst singlePlayerWinConSource = `${singleWinConSource}(?:,${singleWinConSource})*`; // 'royalcapture,koth'\n/** Captures the win conditions section in the ICN. */\nconst winConditionRegex = new RegExp(\n\tString.raw`\\(?(?<winConditions>${singlePlayerWinConSource}(?:\\|${singlePlayerWinConSource})*)\\)?${whiteSpaceOrEnd}`,\n\t'y',\n);\n\n/**\n * Matches the preset squares segment in ICN\n * 'Squares:x,y|x,y'\n */\nconst presetSquaresRegex = new RegExp(\n\tString.raw`Squares:(?<squarePresets>${coordsKeyRegexSource}(?:\\|${coordsKeyRegexSource})*)${whiteSpaceOrEnd}`,\n\t'y',\n); // 'Squares:x,y|x,y'\n\n/** Matches a single preset ray, optionally capturing its properties. */\nconst singleRaySource = `${coordsKeyRegexSource}>${coordsKeyRegexSource}`; // 'x,y>dx,dy'\n/**\n * Matches the preset rays segment in ICN\n * 'Rays:x,y>dx,dy|x,y>dx,dy'\n */\nconst presetRaysRegex = new RegExp(\n\tString.raw`Rays:(?<rayPresets>${singleRaySource}(\\|${singleRaySource})*)${whiteSpaceOrEnd}`,\n\t'y',\n); // 'Rays:x,y>dx,dy|x,y>dx,dy'\n\n// SKIP THE POSITION (It can be too big to capture all at once)\n\n/**\n * Matches any possible delimiter between moves in the moves section of an ICN.\n * This could be a pipe \"|\", or the move number \"14.\"\n */\nconst movesDelimiter = String.raw`(?:\\s?${countingNumberSource}\\. | ?\\| ?)`; // \" 14. \" or \" | \"\n/** Matches an entire moves list in an ICN, no matter its styling. */\nconst movesRegexSource =\n\tpossessive(String.raw`(?:${countingNumberSource}\\. )?`) + // The first move number, if present\n\tgetMoveRegexSource(false) +\n\tpossessive(`(?:${movesDelimiter}${getMoveRegexSource(false)})*`);\n// console.log(\"MovesRegexSource:\", movesRegexSource);\n/** Captures the moes list  */\nconst movesRegex = new RegExp(String.raw`(?<moves>${movesRegexSource})${whiteSpaceOrEnd}`, 'y');\n\n//\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n//\t\t\t\t\t\t\t\t\t\t END OF REGULAR EXPRESSIONS\n//\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n\n// Getting & Parsing Abbreviations --------------------------------------------------------------------------------\n\n/**\n * Gets the 1-2 letter abbreviation of the given piece type.\n * White pieces are capitalized, black pieces are lowercase.\n * If a piece is neither white nor black, its player number\n * will be placed before its abbreviation, overriding the color.\n *\n * [43] pawn(white) => 'P'\n * [52] queen(black) => 'q'\n * [68] king(red) => '3k'\n */\nfunction getAbbrFromType(type: number): string {\n\tlet short = piece_codes[type];\n\tif (!short) {\n\t\tconst [r, p] = typeutil.splitType(type);\n\t\tshort = String(p) + piece_codes_raw[r];\n\t}\n\treturn short;\n}\n\n/**\n * Gets the integer piece type from a 1-2 letter piece abbreviation.\n * Capitolized abbrev's are white, lowercase are black, or neutral.\n * It may contain a proceeding number, overriding the player color.\n *\n * 'P' => [43] pawn(white)\n * 'q' => [52] queen(black)\n * '3k' => [68] king(red)\n */\nfunction getTypeFromAbbr(pieceAbbr: string): number {\n\tconst results = new RegExp(`^${getPieceAbbrevRegexSource(true)}$`).exec(pieceAbbr);\n\tif (results === null) throw Error(`Piece abbreviation is in invalid form: (${pieceAbbr})`);\n\n\tconst playerStr = results.groups!['player'];\n\tconst abbrev = results.groups!['abbrev']!;\n\n\tlet typeStr: string | undefined;\n\n\tif (playerStr === undefined) {\n\t\t// No player number override is present\n\t\ttypeStr = piece_codes_inverted[abbrev];\n\t\tif (typeStr === undefined) throw Error(`Unknown piece abbreviation: (${pieceAbbr})`);\n\t\treturn Number(typeStr);\n\t} else {\n\t\t// Player number override present   '3Q'\n\t\tconst rawTypeStr = piece_codes_raw_inverted[abbrev.toLowerCase()];\n\t\tif (rawTypeStr === undefined) throw Error(`Unknown raw piece abbreviation: (${pieceAbbr})`);\n\t\treturn typeutil.buildType(Number(rawTypeStr) as RawType, Number(playerStr) as Player);\n\t}\n}\n\n// Main Functions Converting Games To and From ICN -----------------------------------------------------------------\n\n/**\n * Converts a game in JSON format to Infinite Chess Notation.\n * @param longformat - The game in JSON format. Required properties below.\n * @param longformat.metadata - The metadata of the game. Variant, UTCDate, and UTCTime are required if options.skipPosition = true\n * @param [longformat.position] The position of the game, where the values is the integer piece type at that coordsKey. Required if options.skipPosition = false\n * @param longformat.gameRules - The required gameRules to create the ICN\n * @param longformat.fullMove - The fullMove property of the gamefile (usually 1)\n * @param longformat.state_global - The game's global state. This contains the following properties which change over the duration of a game: `specialRights`, `enpassant`, `moveRuleState`.\n * @param [longformat.moves] - If provided, they will be placed into the ICN\n * @param options - Various styling options for the resulting ICN, mostly affecting the moves section. Descriptions are below.\n * * compact => Exclude piece abbreviations, 'x', '+' or '#' markers => '1,7>2,8=Q'\n *     IF FALSE THEN THE MOVES must have their `type` and `flags` properties!!!\n * * spaces => Spaces between segments of a move. => 'P1,7 x 2,8 =Q +'\n * * comments => Include move comments and clk embeded command sequences => 'P1,7x2,8=Q+{[%clk 0:09:56.7]}'\n * * move_numbers => Include move numbers, prettifying the notation.\n * * make_new_lines => Include line breaks in the ICN, between metadata, and between move numbers.\n */\nfunction LongToShort_Format(\n\tlongformat: LongFormatIn,\n\toptions: {\n\t\tskipPosition?: boolean;\n\t\tcompact: boolean;\n\t\tspaces: boolean;\n\t\tcomments: boolean;\n\t\tmake_new_lines: boolean;\n\t\tmove_numbers: boolean;\n\t},\n): string {\n\t// console.log(\"Converting longformat to shortform ICN:\", jsutil.deepCopyObject(longformat));\n\n\t/** Will contain the Metadata, Positon, and Move sections. */\n\tconst segments: string[] = [];\n\n\t// =================================== Section 1: Metadata ===================================\n\n\tconst metadataSegments: string[] = [];\n\n\t// Appended in the correct order given by metadata_key_ordering\n\tconst metadataCopy = jsutil.deepCopyObject(longformat.metadata);\n\tfor (const metadata_name of metadata_ordering) {\n\t\tif (metadataCopy[metadata_name] === undefined) {\n\t\t\tdelete metadataCopy[metadata_name]; // Delete it (sometimes its DECLARED as undefined). Prevents it from increasing the key count\n\t\t\tcontinue; // Skip to the next metadata\n\t\t}\n\t\tmetadataSegments.push(`[${metadata_name} \"${metadataCopy[metadata_name]}\"]`);\n\t\tdelete metadataCopy[metadata_name];\n\t}\n\t// Are there any remaining we missed?\n\tif (Object.keys(metadataCopy).length > 0)\n\t\tthrow Error(\n\t\t\t`metadata_ordering is missing metadata keys (${Object.keys(metadataCopy).join(', ')})`,\n\t\t);\n\n\tif (metadataSegments.length > 0) {\n\t\tconst metadataDelimiter = options.make_new_lines ? '\\n' : ' ';\n\t\tsegments.push(metadataSegments.join(metadataDelimiter));\n\t}\n\n\t// =================================== Section 2: Position ===================================\n\n\t/** Each of these are separated by a space. */\n\tconst positionSegments: string[] = [];\n\n\t/**\n\t * The ordering goes:\n\t *\n\t * Turn order\n\t * Enpassant\n\t * Move rule\n\t * Full move counter\n\t * Promotion lines\n\t * World border\n\t * Win conditions\n\t * Preset Square Highlights\n\t * Preset Ray Highlights\n\t * Position\n\t * Moves\n\t *\n\t * As an example:\n\t *\n\t * w 0/100 1 (8;Q,R,B,N|1;q,r,b,n) checkmate {\"slideLimit\": 100, \"cannotPassTurn\": true} P1,2+|P2,2+|P3,2+|P4,2+|P5,2+\n\t */\n\n\t// Turn order\n\tconst turnOrderArray: PlayerCode[] = longformat.gameRules.turnOrder.map((player) => {\n\t\tif (!(player in player_codes))\n\t\t\tthrow new Error(`No player code found for player (${player})!`);\n\t\treturn player_codes[player];\n\t});\n\tlet turn_order = turnOrderArray.join(':'); // 'w:b'\n\tif (turn_order === 'w:b')\n\t\tturn_order = 'w'; // Short for 'w:b'\n\telse if (turn_order === 'b:w') turn_order = 'b'; // Short for 'b:w'\n\tpositionSegments.push(turn_order);\n\n\t// En passant\n\tif (longformat.state_global.enpassant) {\n\t\t// Only add it SO LONG AS THE distance to the pawn is 1 square!! Which may not be true if it's a 4D game.\n\t\tconst yDistance = bimath.abs(\n\t\t\tlongformat.state_global.enpassant.square[1] - longformat.state_global.enpassant.pawn[1],\n\t\t);\n\t\tif (yDistance === 1n)\n\t\t\tpositionSegments.push(\n\t\t\t\tcoordutil.getKeyFromCoords(longformat.state_global.enpassant.square),\n\t\t\t);\n\t\t// '1,3'\n\t\telse\n\t\t\tconsole.warn(\n\t\t\t\t'Enpassant distance is more than 1 square, not specifying it in the ICN. Enpassant:',\n\t\t\t\tlongformat.state_global.enpassant,\n\t\t\t);\n\t}\n\n\t// 50 Move Rule\n\tif (\n\t\tlongformat.gameRules.moveRule !== undefined ||\n\t\tlongformat.state_global.moveRuleState !== undefined\n\t) {\n\t\t// Make sure both moveRule and moveRuleState are present\n\t\tif (longformat.state_global.moveRuleState === undefined)\n\t\t\tthrow Error(\n\t\t\t\t'moveRuleState must be present when convering a game with moveRule to shortform!',\n\t\t\t);\n\t\tif (longformat.gameRules.moveRule === undefined)\n\t\t\tthrow Error(\n\t\t\t\t'moveRule must be present when convering a game with moveRuleState to shortform!',\n\t\t\t);\n\n\t\tpositionSegments.push(\n\t\t\t`${longformat.state_global.moveRuleState}/${longformat.gameRules.moveRule}`,\n\t\t); // '0/100'\n\t}\n\n\t// Full move counter\n\tpositionSegments.push(String(longformat.fullMove));\n\n\t// Promotion lines\n\tif (longformat.gameRules.promotionRanks || longformat.gameRules.promotionsAllowed) {\n\t\t// Make sure both promotionRanks and promotionsAllowed are present\n\t\tif (!longformat.gameRules.promotionRanks)\n\t\t\tthrow Error(\n\t\t\t\t'promotionRanks must be present when converting a game with promotionsAllowed to shortform!',\n\t\t\t);\n\t\tif (!longformat.gameRules.promotionsAllowed)\n\t\t\tthrow Error(\n\t\t\t\t'promotionsAllowed must be present when converting a game with promotionRanks to shortform!',\n\t\t\t);\n\n\t\tconst promotionRanksCopy = jsutil.deepCopyObject(longformat.gameRules.promotionRanks);\n\t\tconst promotionsAllowedCopy = jsutil.deepCopyObject(longformat.gameRules.promotionsAllowed);\n\n\t\t/** A sorted list (ascending) of all unique player numbers in the game. */\n\t\tconst uniquePlayers = Array.from(new Set(longformat.gameRules.turnOrder)).sort(\n\t\t\t(a, b) => a - b,\n\t\t);\n\n\t\tconst playerSegments: string[] = []; // ['8,17','1,10']\n\t\tfor (const player of uniquePlayers) {\n\t\t\tconst playerSegment: string[] = []; // ['8,17','n,r,b,q']\n\n\t\t\tconst ranks = promotionRanksCopy[player] ?? [];\n\t\t\tif (ranks.length === 0) {\n\t\t\t\t// They have no promotions, but still add them. For example it may look like '(8|)'\n\t\t\t\tplayerSegments.push('');\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst ranksString = ranks.join(',');\n\t\t\tplayerSegment.push(ranksString);\n\n\t\t\tconst promotions: RawType[] = promotionsAllowedCopy[player] ?? [];\n\t\t\tif (promotions.length === 0)\n\t\t\t\tthrow Error(\n\t\t\t\t\t`Player was given promotion ranks, but no promotions allowed! (${player}: ${ranksString})`,\n\t\t\t\t);\n\t\t\tif (!isPromotionListDefaultPromotions(promotions)) {\n\t\t\t\tconst promotionsAbbrevs = promotions.map((type) => piece_codes_raw[type]).join(','); // 'N,R,B,Q'\n\t\t\t\tplayerSegment.push(promotionsAbbrevs);\n\t\t\t}\n\n\t\t\tplayerSegments.push(playerSegment.join(';'));\n\t\t\tdelete promotionRanksCopy[player]; // Remove the player from the object\n\t\t\tdelete promotionsAllowedCopy[player]; // Remove the player from the object\n\t\t}\n\t\tpositionSegments.push('(' + playerSegments.join('|') + ')'); // '(8,17|1,10)'\n\n\t\t// Check if there are any remaining players not accounted for\n\t\tif (Object.keys(promotionRanksCopy).length > 0)\n\t\t\tthrow Error(\n\t\t\t\t'Not all players with promotion ranks had a turn in the turn order! ' +\n\t\t\t\t\tObject.keys(promotionRanksCopy).join(', '),\n\t\t\t);\n\t\tif (Object.keys(promotionsAllowedCopy).length > 0)\n\t\t\tthrow Error(\n\t\t\t\t'Not all players with promotions allowed had a turn in the turn order! ' +\n\t\t\t\t\tObject.keys(promotionsAllowedCopy).join(', '),\n\t\t\t);\n\t}\n\n\t// World Border\n\tif (longformat.gameRules.worldBorder) {\n\t\tconst { left, right, bottom, top } = longformat.gameRules.worldBorder;\n\t\tpositionSegments.push(`${left ?? '_'},${right ?? '_'},${bottom ?? '_'},${top ?? '_'}`);\n\t}\n\n\t// Win conditions\n\tconst playerWinConSegments: string[] = []; // ['checkmate','checkmate|allpiecescaptured']\n\t// Sort by ascending player number\n\tconst sortedPlayers = (\n\t\tObject.keys(longformat.gameRules.winConditions).map(Number) as Player[]\n\t).sort((a, b) => a - b);\n\tfor (const player of sortedPlayers) {\n\t\tplayerWinConSegments.push(longformat.gameRules.winConditions[player]!.join(',')); // 'checkmate,allpiecescaptured'\n\t}\n\tconst allPlayersMatchWinConditions = playerWinConSegments.every(\n\t\t(segment) => segment === playerWinConSegments[0],\n\t);\n\tif (allPlayersMatchWinConditions) {\n\t\tif (playerWinConSegments[0]! !== default_win_condition)\n\t\t\tpositionSegments.push(playerWinConSegments[0]!); // Don't include parenthesis => 'royalcapture' | 'checkmate,koth'\n\t\t// Else all players have checkmate, no need to specify!\n\t} else {\n\t\t// One or more players have differing win conditions\n\t\tpositionSegments.push('(' + playerWinConSegments.join('|') + ')'); // Include parenthesis => '(checkmate|checkmate,allpiecescaptured)'\n\t}\n\n\t// Preset squares\n\tif (longformat.presetAnnotes?.squares) {\n\t\tpositionSegments.push(\n\t\t\t'Squares:' + longformat.presetAnnotes.squares.map(coordutil.getKeyFromCoords).join('|'),\n\t\t);\n\t}\n\n\t// Preset rays\n\tif (longformat.presetAnnotes?.rays) {\n\t\tpositionSegments.push(\n\t\t\t'Rays:' +\n\t\t\t\tlongformat.presetAnnotes.rays\n\t\t\t\t\t.map((pr) => {\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\tcoordutil.getKeyFromCoords(pr.start) +\n\t\t\t\t\t\t\t'>' +\n\t\t\t\t\t\t\tcoordutil.getKeyFromCoords(pr.vector)\n\t\t\t\t\t\t);\n\t\t\t\t\t})\n\t\t\t\t\t.join('|'),\n\t\t);\n\t}\n\n\t// Position - P1,2+|P2,2+|P3,2+|P4,2+|P5,2+\n\tif (!options.skipPosition) {\n\t\tif (longformat.position === undefined)\n\t\t\tthrow Error('longformat.position must be specified when skipPosition = false');\n\t\tif (longformat.state_global.specialRights === undefined)\n\t\t\tthrow Error('longformat.specialRights must be specified when skipPosition = false');\n\t\t// Position can be empty in the editor. This avoids a trailing space in the ICN\n\t\tif (longformat.position.size > 0)\n\t\t\tpositionSegments.push(\n\t\t\t\tgetShortFormPosition(longformat.position, longformat.state_global.specialRights),\n\t\t\t);\n\t} else if (\n\t\t!longformat.metadata.Variant ||\n\t\t!longformat.metadata.UTCDate ||\n\t\t!longformat.metadata.UTCTime\n\t)\n\t\tthrow Error(\n\t\t\t\"longformat.metadata's Variant, UTCDate, and UTCTime must be specified when skipPosition = true\",\n\t\t);\n\n\tsegments.push(positionSegments.join(' ')); // 'w 0/100 1 (8,17|1,10) (checkmate|checkmate,allpiecescaptured) P1,2+|P2,2+|P3,2+|P4,2+|P5,2+'\n\n\t// =================================== Section 3: Moves ===================================\n\n\tif (longformat.moves) {\n\t\tconst move_options = {\n\t\t\tcompact: options.compact,\n\t\t\tspaces: options.spaces,\n\t\t\tcomments: options.comments,\n\t\t\tmove_numbers: options.move_numbers,\n\t\t\t// Required if move_numbers = true:\n\t\t\tmake_new_lines: options.make_new_lines,\n\t\t\tturnOrder: longformat.gameRules.turnOrder,\n\t\t\tfullmove: longformat.fullMove,\n\t\t};\n\t\tsegments.push(getShortFormMovesFromMoves(longformat.moves, move_options));\n\t}\n\n\t// ========================================================================================\n\n\t// Combine them all, with an extra line break if make_new_lines = true\n\n\tconst sectionDelimiter = options.make_new_lines ? '\\n\\n' : ' ';\n\treturn segments.join(sectionDelimiter); // 'w 0/100 1 (8,17|1,10) (checkmate|checkmate,allpiecescaptured) {\"slideLimit\": 100, \"cannotPassTurn\": true} P1,2+|P2,2+|P3,2+|P4,2+|P5,2+'\n}\n\n/**\n * Converts a string in Infinite Chess Notation to game in JSON format.\n *\n * Throws an error if the ICN is invalid.\n */\nfunction ShortToLong_Format(icn: string): LongFormatOut {\n\t// console.log(\"====== Parsing ICN ======\");\n\n\tconst metadata: Record<string, string> = {}; // Required\n\tlet turnOrder: Player[]; // Required\n\tlet enpassant: EnPassant | undefined;\n\tlet moveRule: number | undefined;\n\tlet moveRuleState: number | undefined;\n\tlet fullMove: number; // Required\n\tlet promotionRanks: PlayerGroup<bigint[]> | undefined;\n\tlet promotionsAllowed: PlayerGroup<RawType[]> | undefined;\n\tlet winConditions: PlayerGroup<GameruleWinCondition[]> = {}; // Required\n\tlet worldBorder: UnboundedRectangle | undefined;\n\tlet presetSquares: Coords[] | undefined;\n\tlet presetRays: BaseRay[] | undefined;\n\tlet position: Map<CoordsKey, number> | undefined;\n\tlet specialRights: Set<CoordsKey> | undefined;\n\tlet moves: MoveParsed[] | undefined;\n\n\t/** The current index we are observing in the entire ICN string. Start at 0 and work up. */\n\tlet lastIndex = 0;\n\n\t/**\n\t * Find the first non-whitespace character in the ICN,\n\t * which should be the start of the first section.\n\t */\n\tconst whitespaceRegex = /\\s+/y; // Sticky so it only matches at lastIndex\n\twhitespaceRegex.lastIndex = lastIndex; // Not needed? But safe\n\tif (whitespaceRegex.exec(icn)) lastIndex = whitespaceRegex.lastIndex; // Adjust the lastIndex to the first non-whitespace character\n\tif (lastIndex === icn.length) throw Error('ICN is empty.');\n\t// console.log(\"First non-whitespace character:\", icn[lastIndex], \"at index\", lastIndex);\n\n\t// ==================================== BEGIN ===================================\n\n\t// Metadata\n\t// Test if the metadata lies at our current index being observed\n\tmetadataRegex.lastIndex = lastIndex;\n\n\tconst metadataResults = metadataRegex.exec(icn);\n\tif (metadataResults) {\n\t\tconst blockEnd = metadataRegex.lastIndex; // First character index after the metadata block\n\n\t\tconst singleMetadataRegex = new RegExp(singleMetadataSource, 'g');\n\t\tsingleMetadataRegex.lastIndex = lastIndex;\n\n\t\t// Since the moveRegex has the global flag, exec() will return the next match each time.\n\t\t// NO STRING SPLITTING REQUIRED\n\t\tlet match: RegExpExecArray | null;\n\t\twhile (\n\t\t\tsingleMetadataRegex.lastIndex < blockEnd &&\n\t\t\t(match = singleMetadataRegex.exec(icn)) !== null\n\t\t) {\n\t\t\tconst key = match[1]!;\n\t\t\tconst value = match[2]!;\n\t\t\tmetadata[key] = value;\n\t\t}\n\n\t\t// console.log(\"Parsed metadata:\", jsutil.deepCopyObject(metadata));\n\n\t\tlastIndex = blockEnd; // Update the ICN index being observed\n\t}\n\n\t// Turn order\n\t// Test if the turn order lies at our current index being observed\n\tturnOrderRegex.lastIndex = lastIndex;\n\n\tconst turnOrderResults = turnOrderRegex.exec(icn);\n\tif (turnOrderResults) {\n\t\tlet turnOrderString = turnOrderResults.groups!['turnOrder']!; // 'w:b'\n\t\t// console.log(`Turn Order: \"${turnOrderString}\"`);\n\t\t// Substitues\n\t\tif (turnOrderString === 'w')\n\t\t\tturnOrderString = 'w:b'; // 'w' is short for 'w:b'\n\t\telse if (turnOrderString === 'b') turnOrderString = 'b:w'; // 'b' is short for 'b:w'\n\t\tconst turnOrderArray = turnOrderString.split(':'); // ['w','b']\n\t\tturnOrder = [\n\t\t\t...turnOrderArray.map((p_code) => {\n\t\t\t\tif (!(p_code in player_codes_inverted))\n\t\t\t\t\tthrow Error(\n\t\t\t\t\t\t`Unknown player code (${p_code}) when parsing turn order of ICN! Turn order (${turnOrderResults.groups!['turnOrder']})`,\n\t\t\t\t\t);\n\t\t\t\treturn Number(player_codes_inverted[p_code]);\n\t\t\t}),\n\t\t] as Player[]; // [1,2]\n\n\t\tlastIndex = turnOrderRegex.lastIndex; // Update the ICN index being observed\n\t} else {\n\t\t// Set default turn order\n\t\tturnOrder = jsutil.deepCopyObject(defaultTurnOrder);\n\t}\n\n\t/** A sorted list (ascending) of all unique player numbers in the game. */\n\tconst uniquePlayers = Array.from(new Set(turnOrder)).sort((a, b) => a - b);\n\n\t// Enpassant\n\t// Test if the enpassant square lies at our current index being observed\n\tenpassantRegex.lastIndex = lastIndex;\n\n\tconst enpassantResults = enpassantRegex.exec(icn);\n\tif (enpassantResults) {\n\t\tconst enpassantString = enpassantResults.groups!['enpassant']! as CoordsKey;\n\n\t\tconst coords = coordutil.getCoordsFromKey(enpassantString);\n\t\tconst lastTurn = turnOrder[turnOrder.length - 1];\n\t\t// prettier-ignore\n\t\tconst yParity = lastTurn === p.WHITE ? 1n : lastTurn === p.BLACK ? -1n : (() => { throw new Error(`Invalid last turn (${lastTurn}) when parsing enpassant in ICN!`); })();\n\t\tenpassant = { square: coords, pawn: [coords[0], coords[1] + yParity] };\n\n\t\tlastIndex = enpassantRegex.lastIndex; // Update the ICN index being observed\n\t}\n\n\t// Move rule\n\t// Test if the move rule lies at our current index being observed\n\tmoveRuleRegex.lastIndex = lastIndex;\n\n\tconst moveRuleResults = moveRuleRegex.exec(icn);\n\tif (moveRuleResults) {\n\t\tconst moveRuleGroup = moveRuleResults.groups!['moveRule']!;\n\n\t\t[moveRuleState, moveRule] = moveRuleGroup.split('/').map(Number);\n\t\tif (moveRuleState! > moveRule!)\n\t\t\tthrow Error(`Invalid move rule \"${moveRuleGroup}\" when parsing ICN!`);\n\n\t\tlastIndex = moveRuleRegex.lastIndex; // Update the ICN index being observed\n\t}\n\n\t// Full move\n\t// Test if the full move counter lies at our current index being observed\n\tfullMoveRegex.lastIndex = lastIndex;\n\n\tconst fullMoveResults = fullMoveRegex.exec(icn);\n\tif (fullMoveResults) {\n\t\tfullMove = Number(fullMoveResults.groups!['fullMove']!);\n\n\t\tlastIndex = fullMoveRegex.lastIndex; // Update the ICN index being observed\n\t} else {\n\t\t// Set default full move\n\t\tfullMove = defaultFullMove;\n\t}\n\n\t// Promotions ranks + allowed\n\t// Test if the promotions information lies at our current index being observed\n\tpromotionsRegex.lastIndex = lastIndex;\n\n\tconst promotionsResults = promotionsRegex.exec(icn);\n\tif (promotionsResults) {\n\t\t// console.log(\"Results of promotions regex:\", promotionsResults);\n\t\tconst promotionsString = promotionsResults.groups!['promotions']!;\n\n\t\tpromotionRanks = {};\n\t\tpromotionsAllowed = {};\n\t\tconst promotions = promotionsString.split('|'); // ['8,16,24,32;q,r,b,n','1,9,17,25;q,r,b,n']\n\t\t// Make sure the number of promotions matches the number of players\n\t\tif (promotions.length !== uniquePlayers.length)\n\t\t\tthrow new Error(\n\t\t\t\t`Number of promotions (${promotions.length}) does not match number of unique players (${uniquePlayers.length})! Received promotions: \"${promotionsString}\"`,\n\t\t\t);\n\t\tfor (const player of uniquePlayers) {\n\t\t\tconst playerPromotions = promotions.shift()!; // '8,16,24,32;q,r,b,n'\n\t\t\tpromotionRanks[player] = []; // Initialize empty\n\t\t\tif (playerPromotions === '') continue; // Player has no promotions. Maybe promotions were \"(8|)\"\n\t\t\tconst [ranks, allowed] = playerPromotions.split(';'); // The allowed section is optional\n\t\t\tpromotionRanks[player] = ranks!.split(',').map(BigInt);\n\t\t\t// prettier-ignore\n\t\t\tpromotionsAllowed[player] = allowed ? [...new Set(allowed.split(',').map(raw => {\n\t\t\t\tconst rawPieceCode = piece_codes_raw_inverted[raw.toLowerCase()];\n\t\t\t\tif (rawPieceCode === undefined) throw new Error(`Unknown raw piece code (${raw}) when parsing promotions allowed!`);\n\t\t\t\treturn Number(rawPieceCode) as RawType;\n\t\t\t}))] : jsutil.deepCopyObject(default_promotions);\n\t\t}\n\n\t\tlastIndex = promotionsRegex.lastIndex; // Update the ICN index being observed\n\t}\n\n\t// World Border\n\t// Test if the world border lies at our current index being observed\n\tworldBorderRegex.lastIndex = lastIndex;\n\n\tconst borderResult = worldBorderRegex.exec(icn);\n\tif (borderResult) {\n\t\tconst [left, right, bottom, top] = borderResult\n\t\t\t.groups!['worldBorder']!.split(',')\n\t\t\t.map((value) => (value === '_' ? null : BigInt(value))) as [\n\t\t\tbigint | null,\n\t\t\tbigint | null,\n\t\t\tbigint | null,\n\t\t\tbigint | null,\n\t\t];\n\t\tworldBorder = { left, right, bottom, top };\n\n\t\tlastIndex = worldBorderRegex.lastIndex; // Update the ICN index being observed\n\t}\n\n\t// Win conditions\n\t// Test if the win conditions lie at our current index being observed\n\twinConditionRegex.lastIndex = lastIndex;\n\n\tconst winConditionResults = winConditionRegex.exec(icn);\n\tif (winConditionResults) {\n\t\tconst winConditionsString = winConditionResults.groups!['winConditions']!;\n\t\tconst winConStrings = winConditionsString.split('|'); // ['checkmate','checkmate,allpiecescaptured']\n\t\twinConditions = {};\n\t\t// If winConStrings.length is 1, all players have the same win conditions\n\t\tif (winConStrings.length === 1 && winConStrings[0] !== undefined) {\n\t\t\t// The regex guarantees that the win conditions are valid\n\t\t\tconst winConArray = winConStrings[0].split(',') as GameruleWinCondition[]; // ['checkmate','allpiecescaptured']\n\t\t\tfor (const player of turnOrder) {\n\t\t\t\twinConditions[player] = [...winConArray];\n\t\t\t}\n\t\t} else {\n\t\t\t// Each player has their own win conditions\n\t\t\t// Make sure the number of win conditions matches the number of unique players\n\t\t\tif (winConStrings.length !== uniquePlayers.length)\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Number of win conditions (${winConStrings.length}) does not match number of players (${uniquePlayers.length})!`,\n\t\t\t\t);\n\t\t\tfor (const player of uniquePlayers) {\n\t\t\t\tconst winConString = winConStrings.shift()!;\n\t\t\t\t// The regex guarantees that the win conditions are valid\n\t\t\t\twinConditions[player] = winConString.split(',') as GameruleWinCondition[]; // ['checkmate','allpiecescaptured']\n\t\t\t}\n\t\t}\n\n\t\tlastIndex = winConditionRegex.lastIndex; // Update the ICN index being observed\n\t} else {\n\t\t// Set default win conditions\n\t\tfor (const player of turnOrder) {\n\t\t\twinConditions[player] = [default_win_condition];\n\t\t}\n\t}\n\n\t// Preset Squares\n\t// Test if the preset squares lie at our current index being observed\n\tpresetSquaresRegex.lastIndex = lastIndex;\n\n\tconst squaresResult = presetSquaresRegex.exec(icn);\n\tif (squaresResult) {\n\t\tpresetSquares = parsePresetSquares(squaresResult.groups!['squarePresets']!);\n\n\t\tlastIndex = presetSquaresRegex.lastIndex; // Update the ICN index being observed\n\t}\n\n\t// Preset Rays\n\t// Test if the preset rays lie at our current index being observed\n\tpresetRaysRegex.lastIndex = lastIndex;\n\n\tconst raysResult = presetRaysRegex.exec(icn);\n\tif (raysResult) {\n\t\tpresetRays = parsePresetRays(raysResult.groups!['rayPresets']!);\n\n\t\tlastIndex = presetRaysRegex.lastIndex; // Update the ICN index being observed\n\t}\n\n\t/**\n\t * Moves\n\t *\n\t * MUST BE TESTED BEFORE THE POSITION, as the position may\n\t * wrongfully think the moves section is the start of the position,\n\t * since the start of a move can look like a piece entry.\n\t */\n\ttestNextSectionForMoves();\n\n\t/**\n\t * Position\n\t *\n\t * SPECIAL HANDLING FOR THE POSITION (It can be too long to regex match all at once)\n\t * MUST BE TESTED AFTER THE MOVES, as this may wrongfully interpret the\n\t * start of the moves section as the start of the position, if the position isn't present.\n\t */\n\tif (!moves) {\n\t\t// This next section GUARANTEED to not be the moves section\n\t\t// Test if this next section is the position section\n\n\t\tconst pieceEntryRegex = new RegExp(getPieceEntryRegexSource(true), 'y');\n\t\tconst delimiter = /\\|/y; // The delimiter between piece entries\n\n\t\t// Set the lastIndex to the current index being observed in the ICN\n\t\tpieceEntryRegex.lastIndex = lastIndex;\n\n\t\t// Check for the present of the first piece entry\n\t\tlet match: RegExpExecArray | null = pieceEntryRegex.exec(icn);\n\t\tif (match) {\n\t\t\t// The POSITION is present!\n\t\t\t// Initialize\n\t\t\tposition = new Map<CoordsKey, number>();\n\t\t\tspecialRights = new Set<CoordsKey>();\n\n\t\t\tprocessPieceEntry(match);\n\n\t\t\t// Repeatedly check for the next piece entry.\n\t\t\t// EFFICIENT. Works for arbitrarily large positions!\n\t\t\twhile (true) {\n\t\t\t\t// Check if the next character is a delimiter\n\t\t\t\tdelimiter.lastIndex = pieceEntryRegex.lastIndex; // Set the lastIndex to the current index being observed\n\t\t\t\tif (delimiter.exec(icn)) {\n\t\t\t\t\t// Delimiter found\n\t\t\t\t\tpieceEntryRegex.lastIndex = delimiter.lastIndex; // Set the lastIndex to the current index being observed\n\t\t\t\t\tmatch = pieceEntryRegex.exec(icn); // Get the next match\n\t\t\t\t\tif (match) processPieceEntry(match);\n\t\t\t\t\telse\n\t\t\t\t\t\tthrow Error(\n\t\t\t\t\t\t\t`Position section is malformed! No valid piece entry follows a \"|\".`,\n\t\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tbreak; // No delimiter found. End of position. Exit the loop.\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// console.log(\"Parsed position:\", position);\n\n\t\t\t// Make sure there's whitespace or end of string immediately following\n\t\t\twhiteSpaceOrEndRegex.lastIndex = pieceEntryRegex.lastIndex;\n\t\t\tif (!whiteSpaceOrEndRegex.exec(icn))\n\t\t\t\tthrow Error(\n\t\t\t\t\t'Position section needs to be followed by whitespace or end of string!',\n\t\t\t\t);\n\n\t\t\tlastIndex = whiteSpaceOrEndRegex.lastIndex; // Update the ICN index being observed\n\t\t}\n\n\t\t/** Adds the matched piece entry to the position and specialRights. */\n\t\tfunction processPieceEntry(match: RegExpExecArray): void {\n\t\t\t// named groups are: pieceAbbr, coordsKey, specialRight\n\t\t\tconst pieceAbbr = match.groups!['pieceAbbr']!;\n\t\t\tconst coordsKey = match.groups!['coordsKey']! as CoordsKey;\n\t\t\tconst hasSpecialRight = match.groups!['specialRight'] === '+';\n\n\t\t\tconst pieceType = getTypeFromAbbr(pieceAbbr);\n\n\t\t\tposition!.set(coordsKey, pieceType);\n\t\t\tif (hasSpecialRight) specialRights!.add(coordsKey);\n\t\t}\n\t}\n\n\t// Now we can test if the moves section came *after* the positon section.\n\tif (!moves) testNextSectionForMoves();\n\n\tfunction testNextSectionForMoves(): void {\n\t\t// Test if the beginning of the string matches the moves regex\n\t\tmovesRegex.lastIndex = lastIndex;\n\n\t\tconst movesResults = movesRegex.exec(icn);\n\t\tif (movesResults) {\n\t\t\tconst movesString = movesResults.groups!['moves']!;\n\t\t\tmoves = parseShortFormMoves(movesString);\n\n\t\t\tlastIndex = movesRegex.lastIndex; // Update the ICN index being observed\n\t\t}\n\t}\n\n\t// =================================== END ===================================\n\n\t// Make sure there's no unmatched characters remaining\n\tif (lastIndex < icn.length) {\n\t\tconst remainingICN = icn.slice(lastIndex);\n\t\tthrow Error(`Unexpected characters remaining in the ICN after parsing! \"${remainingICN}\"`);\n\t}\n\n\t// Construct the return object...\n\n\tconst gameRules: GameRules = {\n\t\tturnOrder,\n\t\twinConditions,\n\t};\n\tif (promotionRanks) gameRules.promotionRanks = promotionRanks;\n\tif (promotionsAllowed) gameRules.promotionsAllowed = promotionsAllowed;\n\tif (moveRule !== undefined) gameRules.moveRule = moveRule;\n\tif (worldBorder) gameRules.worldBorder = worldBorder;\n\n\tconst state_global: Partial<GlobalGameState> = {};\n\tif (enpassant) state_global.enpassant = enpassant;\n\tif (moveRuleState !== undefined) state_global.moveRuleState = moveRuleState;\n\tif (specialRights) state_global.specialRights = specialRights;\n\n\tconst longFormatOut: LongFormatOut = {\n\t\tmetadata: metadata as unknown as MetaData,\n\t\tgameRules,\n\t\tfullMove,\n\t\tstate_global,\n\t};\n\tif (position) longFormatOut.position = position;\n\tif (moves) longFormatOut.moves = moves;\n\tif (presetSquares || presetRays) {\n\t\tlongFormatOut.presetAnnotes = {};\n\t\tif (presetSquares) longFormatOut.presetAnnotes.squares = presetSquares;\n\t\tif (presetRays) longFormatOut.presetAnnotes.rays = presetRays;\n\t}\n\n\t// console.log(\"Finished parcing ICN!\");\n\t// console.log(\"Parsed longformat:\", jsutil.deepCopyObject(longFormatOut));\n\n\treturn longFormatOut;\n}\n\n// Compacting & Parsing Single Moves -------------------------------------------------------------------------------\n\n/**\n * Converts a MoveCoords into the most minimal string form: '1,7>2,8=Q'\n *\n * THE `=` IS REQUIRED because in future multiplayer games we will\n * have promotion to colored pieces, so we need to be able to distinguish\n * the player number from the end-Y coordinate! \"1,7>2,8=3Q\" => Red queen\n *\n * {@link getShortFormMoveFromMove} is also capable of this, but less efficient.\n */\nfunction getCompactMoveFromDraft(moveCoords: MoveCoords): string {\n\tconst startCoordsKey = coordutil.getKeyFromCoords(moveCoords.startCoords);\n\tconst endCoordsKey = coordutil.getKeyFromCoords(moveCoords.endCoords);\n\tconst promotionAbbr =\n\t\tmoveCoords.promotion !== undefined ? getAbbrFromType(moveCoords.promotion) : undefined;\n\treturn getCompactMoveFromParts(startCoordsKey, endCoordsKey, promotionAbbr);\n}\n\nfunction getCompactMoveFromParts(\n\tstartCoordsKey: string,\n\tendCoordsKey: string,\n\tpromotionAbbr?: string,\n): string {\n\tconst promotedPieceStr = promotionAbbr ? '=' + promotionAbbr : '';\n\treturn startCoordsKey + '>' + endCoordsKey + promotedPieceStr; // 'a,b>c,d=X'\n}\n\n/**\n * Converts a move into shortform notation, with various styling options available.\n *\n * compact => Exclude piece abbreviations, 'x', '+' or '#' markers => '1,7>2,8=Q'.\n *     IF FALSE THEN THE MOVES must have their `type` and `flags` properties!!!\n * spaces => Spaces between segments of a move => 'P1,7 x 2,8 =Q +'\n * comments => Include move comments and clk embeded command sequences => 'P1,7x2,8=Q+{[%clk 0:09:56.7] Capture, promotion, and a check!}'\n */\nfunction getShortFormMoveFromMove(\n\tmove: MovePreprint,\n\toptions: { compact: boolean; spaces: boolean; comments: boolean },\n): string {\n\t// console.log(\"Options for getShortFormMoveFromMove:\", options);\n\n\tif (options.compact && !options.spaces && !options.comments)\n\t\tconsole.warn(\n\t\t\t'getCompactMoveFromDraft() is more efficient to get the most-compact form of a move.',\n\t\t);\n\tif (!options.compact) {\n\t\tif (move.type === undefined)\n\t\t\tthrow Error(`move.type must be present when compact = false! (${move.token})`);\n\t\tif (move.flags === undefined)\n\t\t\tthrow Error(`move.flags must be present when compact = false! (${move.token})`);\n\t}\n\n\t// TESTING. Randomly give the move either a comment or a clk value.\n\t// if (Math.random() < 0.3) move.comment = \"Comment example\";\n\t// if (Math.random() < 0.3) move.clockStamp = Math.random() * 100000;\n\n\t/** Each \"segment\" of the entire move will be separated by a space, if spaces is true */\n\tconst segments: string[] = [];\n\n\t// 1st segment: piece abbreviation + start coords\n\tconst startCoordsKey = coordutil.getKeyFromCoords(move.startCoords);\n\tif (options.compact)\n\t\tsegments.push(startCoordsKey); // '1,2'\n\telse {\n\t\tconst pieceAbbr = getAbbrFromType(move.type!);\n\t\tsegments.push(pieceAbbr + startCoordsKey); // 'P1,2'\n\t}\n\n\t// 2nd segment: If it was a capture, use 'x' instead of '>'\n\tif (options.compact) segments.push('>');\n\telse segments.push(move.flags!.capture ? 'x' : '>');\n\n\t// 3rd segment: end coords\n\tsegments.push(coordutil.getKeyFromCoords(move.endCoords));\n\n\t// 4th segment: Specify the promoted piece, if present\n\tif (move.promotion !== undefined) {\n\t\tconst promotedPieceAbbr = getAbbrFromType(move.promotion);\n\t\tsegments.push('=' + promotedPieceAbbr); // =Q  \"=\" REQUIRED\n\t}\n\n\t// 5th segment: Append the check/mate flags '#' or '+'\n\tif (!options.compact && (move.flags!.mate || move.flags!.check))\n\t\tsegments.push(move.flags!.mate ? '#' : '+');\n\n\t// 6th segment: Comment, if present, with the clk embedded command sequence\n\t// For example: {[%clk 0:09:56.7] White captures en passant}\n\tif (options.comments && (move.comment || move.clockStamp !== undefined)) {\n\t\t/**\n\t\t * Everything in a comment that has to be separated by a space.\n\t\t * This should include all embeded command sequences, like [%clk 0:09:56.7]\n\t\t */\n\t\tconst cmdObjs: CommandObject[] = [];\n\t\t// Include the clk embeded command sequence, if the player's clockStamp is present on the move.\n\t\tif (move.clockStamp !== undefined)\n\t\t\tcmdObjs.push(icncommentutils.createClkCommandObject(move.clockStamp)); // '[%clk 0:09:56.7]'\n\n\t\tconst fullComment = icncommentutils.combineCommentAndCommands(cmdObjs, move.comment); // '[%clk 0:09:56.7] White captures en passant'\n\t\tif (fullComment) segments.push('{' + fullComment + '}'); // '{[%clk 0:09:56.7] White captures en passant}'\n\t}\n\n\t// Return the shortform move, adding a space between all segments, if spaces is true\n\tconst segmentDelimiter = options.spaces ? ' ' : '';\n\treturn segments.join(segmentDelimiter); // 'P1,7 x 2,8 =Q + {[%clk 0:09:56.7] White captures en passant}' | 'P1,7x2,8=Q+{[%clk 0:09:56.7] White captures en passant}' | '1,7>2,8Q{[%clk 0:09:56.7]}' | '1,7>2,8Q'\n}\n\n/**\n * Parses a compact token move '1,7>2,8=Q' to a readable MoveParsed.\n * `comment` and `clockStamp` will NOT be present.\n */\nfunction parseTokenMove(tokenMove: string): MoveParsed {\n\tconst match = moveRegexCompact.exec(tokenMove);\n\tif (match === null) throw Error('Invalid compact move: ' + tokenMove);\n\treturn getParsedMoveFromNamedCapturedMoveGroups(match.groups as NamedCaptureMoveGroups);\n}\n\n// /** Parses a shortform move in any dynamic format to a readable json. */\n// function parseMoveFromShortFormMove(shortFormMove: string): MoveParsed {\n// \tconst moveRegex = new RegExp(`^${getMoveRegexSource(true)}$`);\n// \tconst match = moveRegex.exec(shortFormMove);\n// \tif (match === null) throw Error('Invalid shortform move: ' + shortFormMove);\n// \treturn getParsedMoveFromNamedCapturedMoveGroups(match.groups as NamedCaptureMoveGroups);\n// }\n\n/**\n * Takes the result.groups of a regex match and parses them into a move.\n *\n * Throws an error if the coordinates would become Infinity when cast to\n * a javascript number, or if the promoted piece abbreviation is invalid.\n */\nfunction getParsedMoveFromNamedCapturedMoveGroups(\n\tcapturedGroups: NamedCaptureMoveGroups,\n): MoveParsed {\n\tconst startCoordsKey = capturedGroups!.startCoordsKey;\n\tconst endCoordsKey = capturedGroups!.endCoordsKey;\n\tconst promotionAbbr = capturedGroups!.promotionAbbr;\n\tconst comment = capturedGroups!.comment;\n\n\tconst startCoords = coordutil.getCoordsFromKey(startCoordsKey);\n\tconst endCoords = coordutil.getCoordsFromKey(endCoordsKey);\n\n\tconst parsedMove: MoveParsed = {\n\t\tstartCoords,\n\t\tendCoords,\n\t\ttoken: getCompactMoveFromParts(startCoordsKey, endCoordsKey, promotionAbbr),\n\t};\n\tif (promotionAbbr) parsedMove.promotion = getTypeFromAbbr(promotionAbbr);\n\tif (comment) {\n\t\t// Parse the human readable comment from the embeded command sequences\n\t\tconst parsedComment = icncommentutils.extractCommandsFromComment(comment);\n\t\tparsedMove.comment = parsedComment.comment;\n\t\tparsedComment.commands.forEach((cmdObj) => {\n\t\t\tif (cmdObj.command === 'clk')\n\t\t\t\tparsedMove.clockStamp = icncommentutils.getMillisFromClkTimeValue(cmdObj.value);\n\t\t});\n\t}\n\n\treturn parsedMove;\n}\n\n// Compacting & Parsing Move Lists --------------------------------------------------------------------------------\n\n/**\n * Converts a gamefile's moves list into shortform, ready to place into the ICN.\n * Various styling options are available:\n *\n * compact => Exclude piece abbreviations, 'x', '+' or '#' markers => '1,7>2,8=Q'\n *     IF FALSE THEN THE MOVES must have their `type` and `flags` properties!!!\n * spaces => Spaces between segments of a move. => 'P1,7 x 2,8 =Q +'\n * comments => Include move comments and clk embeded command sequences => 'P1,7x2,8=Q+{[%clk 0:09:56.7]}'\n * move_numbers => Include move numbers, prettifying the notation. This makes turnOrder, fullmove, and make_new_lines required.\n * make_new_lines => Include new lines between move numbers (only when move_numbers = true)\n */\nfunction getShortFormMovesFromMoves(\n\tmoves: MovePreprint[],\n\toptions: { compact: boolean; spaces: boolean; comments: boolean } & (\n\t\t| { move_numbers: false }\n\t\t| { move_numbers: true; turnOrder: Player[]; fullmove: number; make_new_lines: boolean }\n\t),\n): string {\n\t// console.log(\"Getting shortform moves with options:\", options);\n\n\t// Converts a gamefile's moves list to the most minimal and compact string notation `1,2>3,4|5,6>7,8=N`\n\tif (options.compact && !options.spaces && !options.comments && !options.move_numbers)\n\t\treturn moves.map((move) => move.token).join('|'); // Most efficient, as the MoveFull already has the compact form.\n\n\tif (!options.move_numbers) {\n\t\tconst shortforms = moves.map((move) => getShortFormMoveFromMove(move, options));\n\t\tconst moveDelimiter = options.spaces ? ' | ' : '|';\n\t\treturn shortforms.join(moveDelimiter);\n\t}\n\n\t// Include move_numbers with the notation\n\treturn getShortFormMovesFromMoves_MoveNumbers(moves, options); // Beautiful form with move numbers, new lines, and comments!\n}\n\n/**\n * Converts a gamefile's moves list to a NUMBERED shortform notation.\n * Various styling options are available:\n *\n * compact => Exclude piece abbreviations, 'x', '+' or '#' markers => '1,7>2,8Q'\n * spaces => Spaces between segments of a move. => 'P1,7 x 2,8 =Q +'\n * comments => Include move comments and clk embeded command sequences => 'P1,7x2,8=Q+{[%clk 0:09:56.7]}'\n * make_new_lines => Include new lines between move numbers\n */\nfunction getShortFormMovesFromMoves_MoveNumbers(\n\tmoves: MovePreprint[],\n\toptions: {\n\t\tturnOrder: Player[];\n\t\tfullmove: number;\n\t\tcompact: boolean;\n\t\tspaces: boolean;\n\t\tcomments: boolean;\n\t\tmake_new_lines: boolean;\n\t},\n): string {\n\t/**\n\t * Example preview: (compact = false, spaces = true, comments = true, fullmove = 1)\n\t *\n\t * 1. P4,2 > 4,4  | p4,7 > 4,6\n\t * 2. P4,4 > 4,5  | p3,7 > 3,5\n\t * 3. P4,5 x 3,6 {White captures en passant} | b6,8 > 3,11\n\t * 4. P3,6 x 2,7  | b3,11 > -4,4 ?\n\t * 5. P2,7 x 1,8 =Q | b-4,4 > 2,-2 +\n\t * 6. K5,1 > 4,2  | n7,8 > 6,6\n\t * 7. Q1,8 x 2,8  | k5,8 > 7,8 {Castling}\n\t * 8. Q2,8 x 1,7  | q4,8 > 0,4\n\t * 9. Q1,7 > 7,13 + | k7,8 > 8,8\n\t * 10. Q7,13 x 7,7 + {Queen sacrifice} | k8,8 x 7,7 !!\n\t * 11. P8,2 > 8,4 ?! | q0,4 > 4,4 # {Bad game from both players}\n\t */\n\n\t/** If true, we can read move.token */\n\tconst mostCompactForm = options.compact && !options.spaces && !options.comments;\n\n\tconst moveLines: string[] = [];\n\tlet currentLine: string = '';\n\tmoves.forEach((move, i) => {\n\t\tconst turnIndex = i % options.turnOrder.length;\n\n\t\t// If turn index is 0, start out with the move number\n\t\tif (turnIndex === 0)\n\t\t\tcurrentLine += `${Math.floor(i / options.turnOrder.length) + options.fullmove}. `;\n\t\t// Else add the move delimiter\n\t\telse currentLine += ' | ';\n\n\t\t// Add the shortform move to the current line\n\t\tcurrentLine += mostCompactForm ? move.token : getShortFormMoveFromMove(move, options);\n\n\t\t// If turn index is the last player, push the current line and start a new one.\n\t\tif (turnIndex === options.turnOrder.length - 1) {\n\t\t\tmoveLines.push(currentLine);\n\t\t\tcurrentLine = '';\n\t\t}\n\t});\n\n\t// If the last line is not empty, push it to the lines.\n\tif (currentLine !== '') moveLines.push(currentLine);\n\n\tconst linesDelimiter = options.make_new_lines ? '\\n' : ' ';\n\treturn moveLines.join(linesDelimiter);\n}\n\n/** Parses the shortform moves of an ICN into a JSON readable format. */\nfunction parseShortFormMoves(shortformMoves: string): MoveParsed[] {\n\t// console.log(\"Parsing shortform moves:\", shortformMoves);\n\n\tconst moves: MoveParsed[] = [];\n\tconst moveRegex = new RegExp(getMoveRegexSource(true), 'g');\n\n\t// Since the moveRegex has the global flag, exec() will return the next match each time.\n\t// NO STRING SPLITTING REQUIRED\n\tlet match: RegExpExecArray | null;\n\twhile ((match = moveRegex.exec(shortformMoves)) !== null) {\n\t\tmoves.push(\n\t\t\tgetParsedMoveFromNamedCapturedMoveGroups(match.groups as NamedCaptureMoveGroups),\n\t\t);\n\t}\n\n\t// console.log(\"Parsed moves:\", moves);\n\treturn moves;\n}\n\n// Converting Positions ------------------------------------------------------------------------------------------\n\n/**\n * Accepts a gamefile's starting position and specialRights properties, returns the position in compressed notation (.e.g., \"P5,6+|k15,-56|Q5000,1\")\n * @param position - A piece iterator giving us each piece's coordsKey and pieceType.\n * \t\t\t\t\t Using an iterable (which a Map<CoordsKey, number> also is considered a valid input) allows\n * \t\t\t\t\t optimization elsewhere in the code, allowing us to avoid creating massive intermediate maps.\n * @param specialRights - The special rights of each piece in the gamefile, a set of CoordsKeys, where the piece at that coordinate can perform their special move (pawn double push, castling rights..)\n * @returns The position of the game in compressed form, where each piece with a + has its special move ability (.e.g., \"P5,6+|k15,-56|Q5000,1\")\n */\nfunction getShortFormPosition(\n\tposition: Iterable<[CoordsKey, number]>,\n\tspecialRights: Set<CoordsKey>,\n): string {\n\tconst pieces: string[] = []; // ['P1,2+','P2,2+', ...]\n\tfor (const [coordsKey, type] of position) {\n\t\tconst pieceAbbr = getAbbrFromType(type);\n\t\tconst specialRightsString = specialRights.has(coordsKey) ? '+' : '';\n\t\tpieces.push(pieceAbbr + coordsKey + specialRightsString);\n\t}\n\t// Using join avoids overhead of repeatedly creating and copying large intermediate strings.\n\treturn pieces.join('|');\n}\n\n/**\n * Generates the specialRights property of a gamefile, given the provided position and gamerules.\n * Only gives pieces that can castle their right if they are on the same rank, and color, as the king, and at least 3 squares away\n *\n * This can be manually used to compress the starting position of variants of InfiniteChess.org to shrink the size of the code\n * @param position - The starting position of the gamefile, in the form 'x,y':'pawnsW'\n * @param pawnDoublePush - Whether pawns are allowed to double push\n * @param castleWith - If castling is allowed, this is what piece the king can castle with (e.g., \"rooks\"), otherwise leave it undefined\n * @returns The specialRights gamefile property, a set where entries are coordsKeys 'x,y', where the piece at that location has their special move ability (pawn double push, castling rights..)\n */\nfunction generateSpecialRights(\n\tposition: Map<CoordsKey, number>,\n\tpawnDoublePush: boolean,\n\tcastleWith?: RawType,\n): Set<CoordsKey> {\n\t// Make sure castleWith is with a valid piece to castle with\n\tif (castleWith !== undefined && castleWith !== r.ROOK && castleWith !== r.GUARD)\n\t\tthrow Error(`Cannot allow castling with ${typeutil.debugType(castleWith)}!.`);\n\n\tconst specialRights = new Set<CoordsKey>();\n\tif (pawnDoublePush === false && castleWith === undefined) return specialRights; // Early exit\n\n\t/** Running list of kings discovered, 'x,y': player */\n\tconst kingsFound: Record<CoordsKey, Player> = {};\n\t/** Running list of pieces found that are able to castle (e.g. rooks), 'x,y': Player */\n\tconst castleWithsFound: Record<CoordsKey, Player> = {};\n\n\tfor (const [key, thisPiece] of position.entries()) {\n\t\tconst [rawType, player] = typeutil.splitType(thisPiece);\n\t\tif (pawnDoublePush && rawType === r.PAWN) {\n\t\t\tspecialRights.add(key);\n\t\t} else if (castleWith && typeutil.jumpingRoyals.includes(rawType)) {\n\t\t\tspecialRights.add(key);\n\t\t\tkingsFound[key] = player;\n\t\t} else if (castleWith && rawType === castleWith) {\n\t\t\tcastleWithsFound[key] = player;\n\t\t}\n\t}\n\n\t// Only give the pieces that can castle their special move ability\n\t// if they are the same row and color as a king!\n\tif (Object.keys(kingsFound).length === 0) return specialRights; // Nothing can castle, return now.\n\touterFor: for (const coord in castleWithsFound) {\n\t\t// 'x,y': player\n\t\tconst coords = coordutil.getCoordsFromKey(coord as CoordsKey); // [x,y]\n\t\tfor (const kingCoord in kingsFound) {\n\t\t\t// 'x,y': player\n\t\t\tconst kingCoords = coordutil.getCoordsFromKey(kingCoord as CoordsKey); // [x,y]\n\t\t\tif (coords[1] !== kingCoords[1]) continue; // Not the same y level\n\t\t\tif (castleWithsFound[coord as CoordsKey] !== kingsFound[kingCoord as CoordsKey])\n\t\t\t\tcontinue; // Their players don't match\n\t\t\tconst xDist = bimath.abs(coords[0] - kingCoords[0]);\n\t\t\tif (xDist < 3) continue; // Not at least 3 squares away\n\t\t\tspecialRights.add(coord as CoordsKey); // Same row and color as the king! This piece can castle.\n\t\t\t// We already know this piece can castle, we don't\n\t\t\t// need to see if it's on the same rank as any other king\n\t\t\tcontinue outerFor;\n\t\t}\n\t}\n\treturn specialRights;\n}\n\n/**\n * Takes the position in compressed short form and returns the position and specialRights properties of the gamefile\n * @param shortposition - The compressed position of the gamefile (e.g., \"K5,4+|P1,2|r500,25389\")\n */\nfunction generatePositionFromShortForm(shortposition: string): {\n\tposition: Map<CoordsKey, number>;\n\tspecialRights: Set<CoordsKey>;\n} {\n\t// console.log(\"Parsing shortposition:\", shortposition);\n\n\tconst position = new Map<CoordsKey, number>();\n\tconst specialRights = new Set<CoordsKey>();\n\n\tconst pieceRegex = new RegExp(getPieceEntryRegexSource(true), 'g'); // named groups are: pieceAbbr, coordsKey, specialRight\n\n\t// Since the moveRegex has the global flag, exec() will return the next match each time.\n\t// NO STRING SPLITTING REQUIRED\n\tlet match: RegExpExecArray | null;\n\twhile ((match = pieceRegex.exec(shortposition)) !== null) {\n\t\tconst pieceAbbr = match.groups!['pieceAbbr']!;\n\t\tconst coordsKey = match.groups!['coordsKey']! as CoordsKey;\n\t\tconst hasSpecialRight = match.groups!['specialRight'] === '+';\n\n\t\tconst pieceType = getTypeFromAbbr(pieceAbbr);\n\n\t\tposition.set(coordsKey, pieceType);\n\t\tif (hasSpecialRight) specialRights.add(coordsKey);\n\t}\n\n\t// console.log(\"Parsed position:\", position);\n\n\treturn { position, specialRights };\n}\n\n// Other --------------------------------------------------------------------------------------------------\n\n/**\n * Parses the preset squares from a compacted string form.\n * '23,94|23,76'\n */\nfunction parsePresetSquares(presetSquares: string): Coords[] {\n\tconst coordsKeys = presetSquares.split('|') as CoordsKey[];\n\tconst squares: Coords[] = coordsKeys.map(coordutil.getCoordsFromKey);\n\n\t// console.log(\"Parsed squares:\", squares);\n\n\treturn squares;\n}\n\n/**\n * Parses the preset rays from a compacted string form.\n * '23,94>-1,0|23,76>-1,0'\n */\nfunction parsePresetRays(presetRays: string): BaseRay[] {\n\tconst stringRays: string[] = presetRays.split('|'); // ['75,14>-1,0', '26,29>-1,-1']\n\tconst rays: BaseRay[] = stringRays.map((sr) => {\n\t\tconst [startCoordsKey, vec2Key] = sr.split('>');\n\n\t\tconst start = coordutil.getCoordsFromKey(startCoordsKey as CoordsKey);\n\t\tconst vector = coordutil.getCoordsFromKey(vec2Key as CoordsKey);\n\n\t\treturn { start, vector };\n\t});\n\n\t// console.log(\"Parsed rays:\", rays);\n\n\treturn rays;\n}\n\n// Exports --------------------------------------------------------------------------------------------------------\n\nexport default {\n\tLongToShort_Format,\n\tShortToLong_Format,\n\n\tgetTypeFromAbbr,\n\tgetCompactMoveFromDraft,\n\n\tparseTokenMove,\n\n\tgetShortFormPosition,\n\tgenerateSpecialRights,\n\tgeneratePositionFromShortForm,\n\n\tgetShortFormMovesFromMoves,\n\tparseShortFormMoves,\n\n\tparsePresetSquares,\n\tparsePresetRays,\n\n\t// Regex sources & objects\n\twholeNumberSource,\n\tintegerSource,\n\tpromotionRanksSource,\n\tpromotionsAllowedSource,\n\tdefault_promotions,\n\tdefault_win_condition,\n\tpiece_codes_inverted,\n\tpiece_codes_raw,\n};\n\nexport type { LongFormatIn, LongFormatOut, MovePreprint, MoveParsed, MoveCoords, PresetAnnotes };\n"
  },
  {
    "path": "src/shared/chess/logic/initvariant.ts",
    "content": "// src/shared/chess/logic/initvariant.ts\n\n/**\n * This script prepares our variant when a game is constructed\n */\n\nimport type { Snapshot } from './gamefile.js';\nimport type { GameRules } from '../util/gamerules.js';\nimport type { CoordsKey } from '../util/coordutil.js';\nimport type { VariantCode } from '../variants/variantdictionary.js';\nimport type { PieceMoveset } from './movesets.js';\nimport type { RawTypeGroup } from '../util/typeutil.js';\nimport type { GlobalGameState } from './state.js';\nimport type { SpecialMoveFunction } from './specialmove.js';\n\nimport variant from '../variants/variant.js';\n\n/**\n * Variant options that can be used to load a custom game,\n * whether local or online, instead of one of the default variants.\n */\ninterface VariantOptions {\n\t/**\n\t * The full move number of the turn at the provided position. Default: 1.\n\t * Can be higher if you copy just the positional information in a game with some moves played already.\n\t */\n\tfullMove: number;\n\tgameRules: GameRules;\n\t/**\n\t * The starting position object, containing the pieces organized by key.\n\t * The key of the object is the coordinates of the piece as a string,\n\t * and the value is the type of piece on that coordinate (e.g. [22] pawn (neutral))\n\t */\n\tposition: Map<CoordsKey, number>;\n\t/** The 3 global game states */\n\tstate_global: GlobalGameState;\n}\n\n/**\n * Returns the game rules for the variant.\n * If variant options are provided, their embedded gameRules are used directly.\n * @param variantCode - The variant code, or null for custom/pasted positions.\n * @param timestamp - The game's start timestamp in ms since epoch.\n * @param [options] - Variant options that override the default variant gamerules.\n */\nfunction getVariantGamerules(\n\tvariantCode: VariantCode | null,\n\ttimestamp: number,\n\toptions?: VariantOptions,\n): GameRules {\n\t// Ignores the variant code, and just uses the specified gameRules\n\tif (options) return options.gameRules;\n\t// Default (built-in variant, not pasted)\n\tif (variantCode === null) return variant.getBareMinimumGameRules();\n\treturn variant.getGameRulesOfVariant(variantCode, timestamp);\n}\n\n/**\n * Returns the piece movesets and special moves for the variant.\n * @param variantCode - The variant code, or null for custom/pasted positions.\n * @param timestamp - The game's start timestamp in ms since epoch.\n * @param [slideLimit] - Overrides the slideLimit gamerule of the variant, if specified.\n */\nfunction getPieceMovesets(\n\tvariantCode: VariantCode | null,\n\ttimestamp: number,\n\tslideLimit?: bigint,\n): {\n\tpieceMovesets: RawTypeGroup<() => PieceMoveset>;\n\tspecialMoves: RawTypeGroup<SpecialMoveFunction>;\n} {\n\tconst pieceMovesets = variant.getMovesetsOfVariant(variantCode, timestamp, slideLimit);\n\tconst specialMoves = variant.getSpecialMovesOfVariant(variantCode, timestamp);\n\treturn {\n\t\tpieceMovesets,\n\t\tspecialMoves,\n\t};\n}\n\n/**\n * Fills in any holes in the provided variant options with the variant defaults.\n * @param variantCode - The variant code, or null for custom/pasted positions.\n * @param timestamp - The game's start timestamp in ms since epoch.\n * @param [variantOptions] - The variant options. If position is not specified, the variant code must be provided.\n */\nfunction getVariantVariantOptions(\n\tgamerules: GameRules,\n\tvariantCode: VariantCode | null,\n\ttimestamp: number,\n\tvariantOptions?: VariantOptions,\n): {\n\tposition: Snapshot['position'];\n\tstate_global: Snapshot['state_global'];\n\tfullMove: number;\n} {\n\tlet position: Snapshot['position'];\n\tlet fullMove: Snapshot['fullMove'];\n\t// The 3 global game states\n\tlet specialRights: Snapshot['state_global']['specialRights'];\n\tlet enpassant: Snapshot['state_global']['enpassant'];\n\tlet moveRuleState: Snapshot['state_global']['moveRuleState'];\n\n\t// Even IF options are provided. If the pasted game doesn't contain position information\n\t// then we still have to grab it from the variant!\n\tif (variantOptions) {\n\t\tposition = variantOptions.position;\n\t\tfullMove = variantOptions.fullMove;\n\t\tspecialRights = variantOptions.state_global.specialRights;\n\t\tenpassant = variantOptions.state_global.enpassant;\n\t\tif (\n\t\t\tvariantOptions.gameRules.moveRule !== undefined &&\n\t\t\tvariantOptions.state_global.moveRuleState === undefined\n\t\t)\n\t\t\tthrow Error('If moveRule is specified, moveRuleState must also be specified.');\n\t\tmoveRuleState = variantOptions.state_global.moveRuleState;\n\t} else if (variantCode !== null) {\n\t\t({ position, specialRights } = variant.getStartingPositionOfVariant(\n\t\t\tvariantCode,\n\t\t\ttimestamp,\n\t\t));\n\t\tfullMove = 1; // Every variant has the exact same fullMove value.\n\t\tif (gamerules.moveRule !== undefined) moveRuleState = 0; // Every variant has the exact same initial moveRuleState value.\n\t} else throw Error('Cannot get starting position without a variant code or variant options.');\n\n\t// console.log(\"Variant options:\", variantOptions);\n\n\tconst state_global: Snapshot['state_global'] = { specialRights };\n\tif (enpassant) state_global.enpassant = enpassant;\n\tif (moveRuleState !== undefined) state_global.moveRuleState = moveRuleState;\n\n\treturn {\n\t\tposition,\n\t\tstate_global,\n\t\tfullMove,\n\t};\n}\n\nexport type { VariantOptions };\n\nexport default {\n\tgetVariantGamerules,\n\tgetPieceMovesets,\n\tgetVariantVariantOptions,\n};\n"
  },
  {
    "path": "src/shared/chess/logic/insufficientmaterial.ts",
    "content": "// src/shared/chess/logic/insufficientmaterial.ts\n\n/**\n * This script detects draws by insufficient material.\n */\n\nimport type { Board } from './gamefile.js';\nimport type { Coords } from '../util/coordutil.js';\nimport type { GameRules } from '../util/gamerules.js';\nimport type { GameConclusion } from '../util/winconutil.js';\n\nimport bimath from '../../util/math/bimath.js';\nimport jsutil from '../../util/jsutil.js';\nimport moveutil from '../util/moveutil.js';\nimport boardutil from '../util/boardutil.js';\nimport gamerules from '../util/gamerules.js';\nimport coordutil from '../util/coordutil.js';\nimport typeutil, { Player } from '../util/typeutil.js';\nimport { rawTypes as r, ext as e, players as p, TypeGroup } from '../util/typeutil.js';\n\n// Types -----------------------------------------------------------------------\n\n/**\n * Represents a piece's count, using a tuple for bishops to count them on light and dark squares separately.\n * The tuple should be SORTED in descending order! Otherwise, some insuffmat checks won't work.\n * i.e. whatever light/dark square has the most bishops should be the first entry of the tuple.\n */\ntype PieceCount = number | [number, number];\n/** Defines an object mapping piece types to their counts, representing a specific collection of pieces on the board. */\ntype Scenario = TypeGroup<PieceCount>;\n\n// Constants -------------------------------------------------------------------\n\n/**\n * If the world border exists and is closer than this number in any direction,\n * then take the world border under consideration when doing insuffmat checks.\n *\n * Chosen to be as small as possible yet realistically never actually be reached in practice.\n */\nconst boundForWorldBorderConsideration = 1_000_000n;\n\n/**\n * List of scenarios that are a draw by insufficient material (checkmate and helpmate impossible).\n * In each of these, black is the one being asked whether they're checkmateable.\n *\n * Entries for bishops are given by tuples ordered in descending order, because\n * of parity, so that bishops on different colored squares are treated separately.\n */\nconst INSUFFMAT_SCENARIOS: readonly Scenario[] = [\n\t// Both sides have one king\n\t...withPieces({ [r.KING + e.W]: 1, [r.KING + e.B]: 1 }, [\n\t\t{ [r.QUEEN + e.W]: 1, [r.QUEEN + e.B]: 1 },\n\t\t{ [r.QUEEN + e.W]: 1, [r.ROOK + e.B]: 1 },\n\t\t{ [r.QUEEN + e.W]: 1, [r.BISHOP + e.B]: [1, 0], [r.KNIGHT + e.B]: 1 },\n\t\t{ [r.QUEEN + e.W]: 1, [r.BISHOP + e.B]: [1, 1] },\n\t\t{ [r.QUEEN + e.W]: 1, [r.KNIGHT + e.B]: 2 },\n\t\t{ [r.QUEEN + e.W]: 1, [r.PAWN + e.B]: 1 },\n\t\t{ [r.ROOK + e.W]: 1, [r.BISHOP + e.W]: [1, 0], [r.KNIGHT + e.B]: 1 },\n\t\t{ [r.ROOK + e.W]: 1, [r.KNIGHT + e.W]: 1, [r.KNIGHT + e.B]: 1 },\n\t\t{ [r.ROOK + e.W]: 1, [r.ROOK + e.B]: 1 },\n\t\t{ [r.ROOK + e.W]: 1, [r.BISHOP + e.B]: [1, 1] },\n\t\t{ [r.ROOK + e.W]: 1, [r.BISHOP + e.B]: [1, 0], [r.KNIGHT + e.B]: 1 },\n\t\t{ [r.ROOK + e.W]: 1, [r.KNIGHT + e.B]: 2 },\n\t\t{ [r.ROOK + e.W]: 1, [r.PAWN + e.B]: 1 },\n\t\t{ [r.BISHOP + e.W]: [Infinity, 1] },\n\t\t{ [r.BISHOP + e.W]: [Infinity, 0], [r.KNIGHT + e.W]: 1 },\n\t\t{ [r.BISHOP + e.W]: [Infinity, 0], [r.PAWN + e.B]: 1 },\n\t\t{ [r.BISHOP + e.W]: [1, 1], [r.KNIGHT + e.W]: 1 },\n\t\t{ [r.BISHOP + e.W]: [1, 1], [r.BISHOP + e.B]: [1, 0] },\n\t\t{ [r.BISHOP + e.W]: [1, 1], [r.KNIGHT + e.B]: 1 },\n\t\t{ [r.BISHOP + e.W]: [1, 1], [r.PAWN + e.B]: 1 },\n\t\t{ [r.BISHOP + e.W]: [1, 0], [r.KNIGHT + e.W]: 2 },\n\t\t{ [r.BISHOP + e.W]: [1, 0], [r.KNIGHT + e.W]: 1, [r.BISHOP + e.B]: [1, 0] },\n\t\t{ [r.BISHOP + e.W]: [1, 0], [r.KNIGHT + e.W]: 1, [r.BISHOP + e.B]: [0, 1] },\n\t\t{ [r.BISHOP + e.W]: [1, 0], [r.KNIGHT + e.W]: 1, [r.KNIGHT + e.B]: 1 },\n\t\t{ [r.BISHOP + e.W]: [1, 0], [r.KNIGHT + e.W]: 1, [r.PAWN + e.B]: 1 },\n\t\t{ [r.BISHOP + e.W]: [1, 0], [r.KNIGHT + e.B]: 2 },\n\t\t{ [r.KNIGHT + e.W]: 3 }, // 1K3N-1k\n\t\t{ [r.KNIGHT + e.W]: 2, [r.KNIGHT + e.B]: 1 }, // 1K2N-1k1n\n\t\t{ [r.KNIGHT + e.W]: 2, [r.PAWN + e.B]: 1 },\n\t\t{ [r.PAWN + e.W]: 3, [r.PAWN + e.B]: 1 },\n\t\t// Fairy scenarios\n\t\t{ [r.CHANCELLOR + e.W]: 1 },\n\t\t{ [r.ARCHBISHOP + e.W]: 1, [r.BISHOP + e.W]: [1, 0] },\n\t\t{ [r.ARCHBISHOP + e.W]: 1, [r.KNIGHT + e.W]: 1 },\n\t\t{ [r.KNIGHTRIDER + e.W]: 2 },\n\t\t{ [r.HAWK + e.W]: 2 },\n\t\t{ [r.HAWK + e.W]: 1, [r.BISHOP + e.W]: [1, 0] },\n\t\t{ [r.HUYGEN + e.W]: 2, [r.HUYGEN + e.B]: 1 }, // 1K2HU-1k1hu\n\t\t{ [r.GUARD + e.W]: 1 },\n\t]),\n\t// Only one side has a king (black, the side being checkmated)\n\t...withPieces({ [r.KING + e.B]: 1 }, [\n\t\t{ [r.QUEEN + e.W]: 1, [r.ROOK + e.W]: 1 },\n\t\t{ [r.QUEEN + e.W]: 1, [r.KNIGHT + e.W]: 1 },\n\t\t{ [r.QUEEN + e.W]: 1, [r.BISHOP + e.W]: [1, 0] },\n\t\t{ [r.QUEEN + e.W]: 1, [r.PAWN + e.W]: 1 },\n\t\t{ [r.ROOK + e.W]: 2, [r.BISHOP + e.W]: [1, 0] },\n\t\t{ [r.ROOK + e.W]: 2, [r.KNIGHT + e.W]: 1 },\n\t\t{ [r.ROOK + e.W]: 2, [r.PAWN + e.W]: 1 },\n\t\t{ [r.ROOK + e.W]: 1, [r.BISHOP + e.W]: [1, 0], [r.KNIGHT + e.W]: 1 },\n\t\t{ [r.ROOK + e.W]: 1, [r.KNIGHT + e.W]: 2 },\n\t\t{ [r.ROOK + e.W]: 1, [r.KNIGHT + e.W]: 1, [r.PAWN + e.W]: 1 },\n\t\t{ [r.BISHOP + e.W]: [Infinity, 0], [r.KNIGHT + e.W]: 2 },\n\t\t{ [r.BISHOP + e.W]: [2, 2] },\n\t\t{ [r.BISHOP + e.W]: [2, 1], [r.KNIGHT + e.W]: 1 },\n\t\t{ [r.BISHOP + e.W]: [1, 1], [r.KNIGHT + e.W]: 2 },\n\t\t{ [r.KNIGHT + e.W]: 4 },\n\t\t{ [r.PAWN + e.W]: 6 },\n\t\t// Fairy scenarios\n\t\t{ [r.AMAZON + e.W]: 1 },\n\t\t{ [r.CHANCELLOR + e.W]: 1, [r.ROOK + e.W]: 1 },\n\t\t{ [r.CHANCELLOR + e.W]: 1, [r.KNIGHT + e.W]: 1 },\n\t\t{ [r.ARCHBISHOP + e.W]: 2 },\n\t\t{ [r.ARCHBISHOP + e.W]: 1, [r.BISHOP + e.W]: [2, 0] },\n\t\t{ [r.ARCHBISHOP + e.W]: 1, [r.BISHOP + e.W]: [1, 1] },\n\t\t{ [r.ARCHBISHOP + e.W]: 1, [r.KNIGHT + e.W]: 2 },\n\t\t{ [r.KNIGHTRIDER + e.W]: 3 },\n\t\t{ [r.HUYGEN + e.W]: 4 },\n\t]),\n\t// Only royals -> Can never check each other let alone checkmate each other\n\t{\n\t\t[r.KING + e.W]: Infinity,\n\t\t[r.ROYALCENTAUR + e.W]: Infinity,\n\t\t[r.KING + e.B]: Infinity,\n\t\t[r.ROYALCENTAUR + e.B]: Infinity,\n\t},\n\t// For practice checkmate 2AM-1rc\n\t{ [r.AMAZON + e.W]: 1, [r.ROYALCENTAUR + e.B]: 1 },\n];\n\n/**\n * Same as {@link INSUFFMAT_SCENARIOS} but for games with a world border nearby.\n * These are less strict, as you require less pieces to be able to checkmate\n * when receiving help from the world border.\n */\nconst INSUFFMAT_SCENARIOS_FINITE: readonly Scenario[] = [\n\t// Both sides have one king\n\t...withPieces({ [r.KING + e.W]: 1, [r.KING + e.B]: 1 }, [\n\t\t{ [r.BISHOP + e.W]: [Infinity, 0], [r.BISHOP + e.B]: [Infinity, 0] },\n\t\t{ [r.KNIGHT + e.W]: 1 },\n\t]),\n\t// Only royals -> Can never check each other let alone checkmate each other (same as infinite case)\n\t{\n\t\t[r.KING + e.W]: Infinity,\n\t\t[r.ROYALCENTAUR + e.W]: Infinity,\n\t\t[r.KING + e.B]: Infinity,\n\t\t[r.ROYALCENTAUR + e.B]: Infinity,\n\t},\n];\n\n// Validate at run time that no scenario is a subset of another\n{\n\tfor (const scenarios of [INSUFFMAT_SCENARIOS, INSUFFMAT_SCENARIOS_FINITE]) {\n\t\tfor (let i = 0; i < scenarios.length; i++) {\n\t\t\tfor (let j = 0; j < scenarios.length; j++) {\n\t\t\t\tif (i === j) continue;\n\t\t\t\tif (\n\t\t\t\t\tisSubsumedBy(scenarios[i]!, scenarios[j]!) ||\n\t\t\t\t\tisSubsumedBy(invertScenario(scenarios[i]!), scenarios[j]!)\n\t\t\t\t) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`Redundant insuffmat scenario:\\n${makeScenReadable(scenarios[i]!)}   IS A SUBSET OF:\\n${makeScenReadable(scenarios[j]!)}.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\nfunction makeScenReadable(scen: Scenario): string {\n\tconst transformed = Object.fromEntries(\n\t\tObject.entries(scen).map(([key, val]) => [typeutil.debugType(Number(key)), val]),\n\t);\n\treturn JSON.stringify(transformed);\n}\n\n// Helpers ----------------------------------------------------------------------\n\n/**\n * Merges a set of additional pieces into every scenario in the list.\n * Used to factor out pieces that are implicitly shared across a group of scenarios.\n * @param addedPieces - the pieces to add to every scenario in the list\n * @param scenarios - the list of scenarios to add the pieces to\n */\nfunction withPieces(addedPieces: Scenario, scenarios: readonly Scenario[]): Scenario[] {\n\treturn scenarios.map((s) => ({ ...addedPieces, ...s }));\n}\n\n/**\n * Checks if scenario a is subsumed by scenario b, i.e. every piece type\n * in a is present in b with a count at least as large. If true, a is\n * redundant and an insufficient material scenario.\n */\nfunction isSubsumedBy(a: Scenario, b: Scenario): boolean {\n\tfor (const key in a) {\n\t\tif (!(key in b) || hasMorePieces(a[key]!, b[key]!)) return false;\n\t}\n\treturn true;\n}\n\n/**\n * Checks if a is larger than b, either as a number, or if it has some larger entry as a tuple\n * @param a - number or tuple of two numbers\n * @param b - number or tuple of two numbers\n */\nfunction hasMorePieces(a: PieceCount, b: PieceCount): boolean {\n\tif (typeof a === 'number' && typeof b === 'number') {\n\t\treturn a > b;\n\t} else if (a instanceof Array && b instanceof Array) {\n\t\tconst bArray = b as [number, number];\n\t\treturn a[0] > bArray[0] || a[1] > bArray[1];\n\t} else {\n\t\tthrow new Error(`[Insuffmat] Invalid piece count comparison between ${a} and ${b}`);\n\t}\n}\n\n/**\n * Detects if the provided piecelist scenario is a draw by insufficient material\n * @param scenario - scenario of piececounts in the game, e.g. {'kingsB': 1, 'kingsW': 1, 'queensW': 3}\n * @param boardIsFinite - Whether the world border is close enough to assist with checkmate.\n * @returns *true*, if the scenario is a draw by insufficient material, otherwise *false*\n */\nfunction isScenarioInsuffMat(scenario: Scenario, boardIsFinite: boolean): boolean {\n\tconst scenarios = boardIsFinite ? INSUFFMAT_SCENARIOS_FINITE : INSUFFMAT_SCENARIOS;\n\treturn scenarios.some((drawScenario) => isSubsumedBy(scenario, drawScenario));\n}\n\n/**\n * Returns the parity of the square coordinates.\n * 0 = Dark square. 1 = Light square.\n */\nfunction getCoordsParity(coords: Coords): 0 | 1 {\n\treturn Number(bimath.abs(coords[0] + coords[1]) % 2n) as 0 | 1;\n}\n\nfunction sumTupleCount(tuple: [number, number]): number {\n\treturn tuple[0] + tuple[1];\n}\n\nfunction orderTupleDescending(tuple: [number, number]): [number, number] {\n\tif (tuple[0] < tuple[1]) return [tuple[1], tuple[0]];\n\telse return tuple;\n}\n\n/**\n * Normalizes bishop parity tuples in a scenario in place.\n *\n * White's bishop tuple is sorted into descending order.\n * Black's bishop tuple uses the **same** swap direction as white's,\n * so the relative parity between sides is preserved.\n *\n * When white has no bishops, or white has equal counts on both square colors\n * (parity is irrelevant for white in that case), black's tuple is sorted independently.\n */\nfunction normalizeBishopParities(scen: Scenario): void {\n\tconst wb = scen[r.BISHOP + e.W] as [number, number] | undefined;\n\tconst bb = scen[r.BISHOP + e.B] as [number, number] | undefined;\n\n\tlet didSwapWhite = false;\n\tif (wb !== undefined) {\n\t\tif (wb[0] < wb[1]) {\n\t\t\tscen[r.BISHOP + e.W] = [wb[1], wb[0]];\n\t\t\tdidSwapWhite = true;\n\t\t}\n\t}\n\n\tif (bb !== undefined) {\n\t\tif (didSwapWhite) {\n\t\t\t// Apply the same swap as white to preserve relative parity between sides.\n\t\t\tscen[r.BISHOP + e.B] = [bb[1], bb[0]];\n\t\t} else if (wb === undefined || wb[0] === wb[1]) {\n\t\t\t// No white bishops, or white has equal counts on both colors\n\t\t\t// (parity is irrelevant for white) — sort black independently.\n\t\t\tscen[r.BISHOP + e.B] = orderTupleDescending(bb);\n\t\t}\n\t\t// else: white is already descending with unequal counts — black stays\n\t\t// as-is to preserve the relative parity relationship.\n\t}\n}\n\n// Main Logic ---------------------------------------------------------------\n\n/** Whether the position supports insufficient material checks. */\nfunction doesPositionSupportInsuffmat(gameRules: GameRules, boardsim: Board): boolean {\n\t// Is the win condition is checkmate for both players?\n\tif (\n\t\t!gamerules.doesColorHaveWinCondition(gameRules, p.WHITE, 'checkmate') ||\n\t\t!gamerules.doesColorHaveWinCondition(gameRules, p.BLACK, 'checkmate')\n\t)\n\t\treturn false;\n\tif (\n\t\tgamerules.getWinConditionCountOfColor(gameRules, p.WHITE) !== 1 ||\n\t\tgamerules.getWinConditionCountOfColor(gameRules, p.BLACK) !== 1\n\t)\n\t\treturn false;\n\n\t// Was the last move a capture or promotion\n\tconst lastMove = moveutil.getLastMove(boardsim.moves);\n\tif (lastMove && !(lastMove.flags.capture || lastMove.promotion !== undefined)) return false;\n\n\t// Is there less than 11 non-obstacle or gargoyle pieces?\n\tif (\n\t\tboardutil.getPieceCountOfGame(boardsim.pieces, {\n\t\t\tignoreRawTypes: new Set([r.OBSTACLE]),\n\t\t\tignoreColors: new Set([p.NEUTRAL]),\n\t\t}) +\n\t\t\tboardutil.getPieceCountOfType(boardsim.pieces, r.VOID + e.N) >=\n\t\t11\n\t)\n\t\treturn false;\n\n\treturn true;\n}\n\n/**\n * Builds the current piece scenario that is on the board.\n * @param boardsim\n * @param exclude - Optional function, run for each piece, that returns\n * whether that piece should be excluded from the scenario.\n */\nfunction buildBoardScenario(boardsim: Board, exclude?: (coords: Coords) => boolean): Scenario {\n\t// Create scenario object listing amount of all non-obstacle pieces in the game\n\tconst scenario: Scenario = {};\n\t// bishops are treated specially and separated by parity\n\tconst bishopsW_count: [number, number] = [0, 0];\n\tconst bishopsB_count: [number, number] = [0, 0];\n\tfor (const idx of boardsim.pieces.coords.values()) {\n\t\tconst piece = boardutil.getDefinedPieceFromIdx(boardsim.pieces, idx)!;\n\t\tconst [rawType, player] = typeutil.splitType(piece.type);\n\t\tif (rawType === r.OBSTACLE) continue;\n\t\tif (exclude && exclude(piece.coords))\n\t\t\tcontinue; // Exlude this piece as specified by the custom exclude() function\n\t\telse if (rawType === r.BISHOP) {\n\t\t\tconst parity: 0 | 1 = getCoordsParity(piece.coords);\n\t\t\tif (player === p.WHITE) bishopsW_count[parity] += 1;\n\t\t\telse if (player === p.BLACK) bishopsB_count[parity] += 1;\n\t\t} else if (piece.type in scenario) {\n\t\t\tconst currentCount = scenario[piece.type];\n\t\t\tif (typeof currentCount === 'number') scenario[piece.type] = currentCount + 1;\n\t\t\telse console.error('[Insuffmat] currentCount is not a number');\n\t\t} else scenario[piece.type] = 1;\n\t}\n\n\t// add bishop tuples to scenario, as [dark_count, light_count] (NOT yet sorted).\n\tif (sumTupleCount(bishopsW_count) !== 0) scenario[r.BISHOP + e.W] = bishopsW_count;\n\tif (sumTupleCount(bishopsB_count) !== 0) scenario[r.BISHOP + e.B] = bishopsB_count;\n\n\treturn scenario;\n}\n\n/**\n * Inverts the player of each scenario piece and returns a new scenario.\n * Non-mutating.\n */\nfunction invertScenario(scenario: Scenario): Scenario {\n\t// Create scenario object with inverted players\n\tconst invertedScenario: Scenario = {};\n\tfor (const pieceTypeStr in scenario) {\n\t\tconst pieceInverted = typeutil.invertType(Number(pieceTypeStr));\n\t\tinvertedScenario[pieceInverted] = scenario[pieceTypeStr]!;\n\t}\n\n\t// Re-normalize bishop parities after inversion: what was black's tuple\n\t// (preserved relative to white) is now white's, and may be in ascending order.\n\tnormalizeBishopParities(invertedScenario);\n\n\treturn invertedScenario;\n}\n\n/**\n * Detects if the game is drawn by insufficient material,\n * returning the game conclusion if so.\n */\nexport function detectInsufficientMaterial(\n\tgameRules: GameRules,\n\tboardsim: Board,\n): GameConclusion | undefined {\n\tif (!doesPositionSupportInsuffmat(gameRules, boardsim)) return undefined;\n\n\tconst boardScenariosToCheck = buildBoardScenarios(gameRules, boardsim);\n\tif (boardScenariosToCheck === false) return undefined; // Too many promotable pawns, skip insuffmat check entirely to avoid exponential blowup.\n\n\t// console.log('Checking insuffmat scenarios:', boardScenariosToCheck.map(makeScenReadable));\n\n\tconst invertedBoardScenariosToCheck = boardScenariosToCheck.map((scen) => invertScenario(scen));\n\n\t// Is the world border close enough to assist checkmate?\n\t// prettier-ignore\n\tconst boardIsFinite =\n\t\tgameRules.worldBorder === undefined ? false\n\t\t\t: (gameRules.worldBorder.bottom !== null && -gameRules.worldBorder.bottom <= boundForWorldBorderConsideration) ||\n\t\t\t  (gameRules.worldBorder.left !== null && -gameRules.worldBorder.left <= boundForWorldBorderConsideration) ||\n\t\t\t  (gameRules.worldBorder.right !== null && gameRules.worldBorder.right <= boundForWorldBorderConsideration) ||\n\t\t\t  (gameRules.worldBorder.top !== null && gameRules.worldBorder.top <= boundForWorldBorderConsideration);\n\n\t// It is draw by insuffmat if EVERY board scenario pair is insuffmat.\n\t// A pair is insuffmat if itself OR its invert is insuffmat.\n\tfor (let i = 0; i < boardScenariosToCheck.length; i++) {\n\t\tconst scenario = boardScenariosToCheck[i]!;\n\t\tconst invertedScenario = invertedBoardScenariosToCheck[i]!;\n\t\tif (\n\t\t\t!isScenarioInsuffMat(scenario, boardIsFinite) &&\n\t\t\t!isScenarioInsuffMat(invertedScenario, boardIsFinite)\n\t\t) {\n\t\t\t// console.log('Scenario is not insuffmat:', makeScenReadable(scenario));\n\t\t\treturn undefined; // At least one scenario pair is not insuffmat\n\t\t}\n\t}\n\n\t// Every scenario pair tested has been insuffmat\n\treturn { victor: null, condition: 'insuffmat' };\n}\n\n/**\n * Builds all board scenarios to check for insufficient material, accounting for\n * all possible promotion outcomes of up to 2 promotable pawns.\n * Returns false if there are 3+ promotable pawns (skip insuffmat check entirely).\n */\nfunction buildBoardScenarios(gameRules: GameRules, boardsim: Board): Scenario[] | false {\n\t// Collect all promotable pawns (across all players) into a flat list\n\tconst promotablePawns: Array<{ coords: Coords; player: Player; pawnType: number }> = [];\n\tfor (const idx of boardsim.pieces.coords.values()) {\n\t\tconst piece = boardutil.getDefinedPieceFromIdx(boardsim.pieces, idx)!;\n\t\tconst [rawType, player] = typeutil.splitType(piece.type);\n\t\tif (rawType !== r.PAWN) continue; // Not a pawn\n\t\tif (player === p.NEUTRAL) continue; // Player neutral can't even move pieces let alone promote pawns\n\t\tif ((gameRules.promotionsAllowed?.[player]?.length ?? 0) === 0) continue; // None of them are promotable (this player can't promote to anything)\n\t\tif ((gameRules.promotionRanks?.[player]?.length ?? 0) === 0) continue; // Player has no promotion ranks to promote at\n\t\t// ASSUME the pawn is behind a promotion rank.\n\t\t// Worst case if it isn't: insuffmat isn't triggered when it could be.\n\t\tpromotablePawns.push({ coords: piece.coords, player, pawnType: piece.type });\n\t}\n\n\t// Due to exponential computation (S^P where S is the number of promotion states and P is the\n\t// number of promotable pawns), skip the insuffmat check entirely if there are 3+ promotable pawns.\n\tif (promotablePawns.length > 2) return false;\n\n\t// Build a pawnless base scenario with all promotable pawns excluded.\n\tconst pawnlessScenario = buildBoardScenario(boardsim, (coords) =>\n\t\tpromotablePawns.some((pawn) => coordutil.areCoordsEqual(coords, pawn.coords)),\n\t);\n\n\t/**\n\t * One possible piece a promotable pawn could become (including staying as a pawn).\n\t * Bishops use `bishopParity` since the promotion square color can't\n\t * be predicted, so each color is a separate outcome to check.\n\t */\n\ttype PawnOutcome = { pieceType: number; bishopParity?: 0 | 1 };\n\n\t/** Returns every possible outcome for a pawn: staying unpromoted, or each promotion piece. */\n\tfunction getPawnOutcomes(pawn: { player: Player; pawnType: number }): PawnOutcome[] {\n\t\tconst outcomes: PawnOutcome[] = [{ pieceType: pawn.pawnType }]; // stays as pawn\n\t\tfor (const promotionRawType of gameRules.promotionsAllowed![pawn.player]!) {\n\t\t\tconst pieceType = typeutil.buildType(promotionRawType, pawn.player);\n\t\t\tif (promotionRawType === r.BISHOP) {\n\t\t\t\toutcomes.push({ pieceType, bishopParity: 0 });\n\t\t\t\toutcomes.push({ pieceType, bishopParity: 1 });\n\t\t\t} else {\n\t\t\t\toutcomes.push({ pieceType });\n\t\t\t}\n\t\t}\n\t\treturn outcomes;\n\t}\n\n\t/** Helper to apply the given pawn outcome to the given scenario, returning a new scenario. Non-mutating. */\n\tfunction applyOutcomeToScenario(base: Scenario, outcome: PawnOutcome): Scenario {\n\t\tconst scen = jsutil.deepCopyObject(base);\n\t\tif (outcome.bishopParity !== undefined) {\n\t\t\tif (scen[outcome.pieceType] === undefined) scen[outcome.pieceType] = [0, 0];\n\t\t\t(scen[outcome.pieceType] as [number, number])[outcome.bishopParity] += 1;\n\t\t\t// Do NOT sort here - parity relationships must be preserved across pawn iterations.\n\t\t} else {\n\t\t\tscen[outcome.pieceType] = ((scen[outcome.pieceType] as number | undefined) ?? 0) + 1;\n\t\t}\n\t\treturn scen;\n\t}\n\n\t// For each pawn, expand the scenario list by all of its possible outcomes (Cartesian product).\n\t// For 0 promotable pawns this simply returns [pawnlessScenario] (the base board scenario).\n\tlet scenarios: Scenario[] = [pawnlessScenario];\n\tfor (const pawn of promotablePawns) {\n\t\tconst outcomes = getPawnOutcomes(pawn);\n\t\tscenarios = scenarios.flatMap((base) =>\n\t\t\toutcomes.map((outcome) => applyOutcomeToScenario(base, outcome)),\n\t\t);\n\t}\n\n\t// Finally, normalize bishop parities, keeping sides relationships intact.\n\tfor (const scen of scenarios) normalizeBishopParities(scen);\n\n\treturn scenarios;\n}\n"
  },
  {
    "path": "src/shared/chess/logic/legalmoves.ts",
    "content": "// src/shared/chess/logic/legalmoves.ts\n\n/**\n * This script calculates legal moves\n */\n\nimport type { Piece } from '../util/boardutil.js';\nimport type { VariantCode } from '../variants/variantdictionary.js';\nimport type { PieceMoveset } from './movesets.js';\nimport type { Vec2, Vec2Key } from '../../util/math/vectors.js';\nimport type { OrganizedPieces } from './organizedpieces.js';\nimport type { Board, FullGame } from './gamefile.js';\nimport type { CoordsKey, Coords } from '../util/coordutil.js';\nimport type { CoordsTagged, MoveTagged } from './movepiece.js';\nimport type { RawType, Player, RawTypeGroup } from '../util/typeutil.js';\nimport type { IgnoreFunction, BlockingFunction } from './movesets.js';\n\nimport bimath from '../../util/math/bimath.js';\nimport variant from '../variants/variant.js';\nimport movesets from './movesets.js';\nimport boardutil from '../util/boardutil.js';\nimport coordutil from '../util/coordutil.js';\nimport specialdetect from './specialdetect.js';\nimport checkresolver from './checkresolver.js';\nimport organizedpieces from './organizedpieces.js';\nimport bounds, { UnboundedRectangle } from '../../util/math/bounds.js';\nimport typeutil, { players as p, rawTypes as r } from '../util/typeutil.js';\n\n// Types ---------------------------------------------------------------------------\n\n/**\n * The step-count limits of a sliding direction.\n * * NULL === INFINITY in that direction.\n * * [-2,null] => Can slide 2 steps in the negative direction, or infinitely in the positive.\n * * For knightriders, one [2,1] hop is considered 1 step.\n * The range does NOT have to intersect the piece owning the slide (for example [8n, 12n],\n * which could be the case for colinear blocks), but limits[0] <= limits[1] is true ALWAYS.\n */\ntype SlideLimits = [bigint | null, bigint | null];\n\n/** An object containing all the legal moves of a piece. */\ninterface LegalMoves {\n\t/** A list of the legal jumping move coordinates: `[[1,2], [2,1]]` */\n\tindividual: CoordsTagged[];\n\t/** A dict containing length-2 arrays with the legal left and right slide limits: `{[1,0]:[-5, Infinity]}` */\n\tsliding: Record<Vec2Key, SlideLimits>;\n\t/** If provided, all sliding moves will brute-force test for check to see if their actually legal to move to. Use when our piece moves colinearly to a piece pinning it, or if our piece is a royal queen. */\n\tbrute?: boolean;\n\t/** The ignore function of the piece, to skip over moves. */\n\tignoreFunc: IgnoreFunction;\n\t/** Whether the generated moves are for a colinear mover (huygen). */\n\tcolinear: boolean;\n}\n\n/**\n * A dictionary of vector distances from an origin square containing\n * a list of raw piece types, typically that can capture from that distance.\n */\ntype Vicinity = Record<CoordsKey, RawType[]>;\n\n// Constants -----------------------------------------------------------------------\n\n/**\n * When testing a `brute` flagged slide to see if at least one square on it is legal,\n * this is the maximum number of squares we will simulate, before safety exiting\n * and assuming there is at least one legal move.\n */\nconst MAX_BRUTE_SIMULATIONS = 200n;\n\n// Functions -----------------------------------------------------------------------\n\n/**\n * Calculates the area around you in which jumping pieces can land on you from that distance.\n * This is used for efficient calculating if a king move would put you in check.\n * Must be called after the piece movesets are initialized.\n * In the format: `{ '1,2': ['knights', 'chancellors'], '1,0': ['guards', 'king']... }`\n * DOES NOT include pawn moves.\n * @param pieceMovesets - MUST BE TRIMMED beforehand to not include movesets of types not present in the game!!!!!\n * @returns The vicinity object\n */\nfunction genVicinity(pieceMovesets: RawTypeGroup<() => PieceMoveset>): Vicinity {\n\tconst vicinity: Record<CoordsKey, RawType[]> = {};\n\n\t// For every type in the game...\n\tfor (const [rawTypeString, movesetFunc] of Object.entries(pieceMovesets)) {\n\t\tconst rawType = Number(rawTypeString) as RawType;\n\t\tconst individualMoves = movesetFunc().individual ?? [];\n\t\tindividualMoves.forEach((coords) => {\n\t\t\tconst coordsKey = coordutil.getKeyFromCoords(coords);\n\t\t\tif (!(coordsKey in vicinity)) vicinity[coordsKey] = []; // Make sure it's initialized\n\t\t\tvicinity[coordsKey]!.push(rawType); // Make sure the key contains the piece type that can capture from that distance\n\t\t});\n\t}\n\n\treturn vicinity;\n}\n\n/**\n * Calculates the area around you in which special pieces HAVE A CHANCE to capture you from that distance.\n * This is used for efficient calculating if a move would put you in check by a special piece.\n * If a special piece is found at any of these distances, their legal moves are calculated\n * to see if they would check you or not.\n * This saves us from having to iterate through every single\n * special piece in the game to see if they would check you.\n * @param variantCode - The variant code, or null for custom/pasted positions.\n * @param timestamp - The game's start timestamp in ms since epoch.\n * @param existingRawTypes\n * @returns The specialVicinity object, in the format: `{ '1,1': ['pawns'], '1,2': ['roses'], ... }`\n */\nfunction genSpecialVicinity(\n\tvariantCode: VariantCode | null,\n\ttimestamp: number,\n\texistingRawTypes: RawType[],\n): Vicinity {\n\tconst specialVicinityByPiece = variant.getSpecialVicinityOfVariant(variantCode, timestamp);\n\tconst vicinity = {} as Vicinity;\n\t// Object keys are strings, so we need to cast the type to a number\n\tfor (const [rawTypeString, pieceVicinity] of Object.entries(specialVicinityByPiece)) {\n\t\tconst rawType = Number(rawTypeString) as RawType;\n\t\tif (!existingRawTypes.includes(rawType)) continue; // This piece isn't present in our game\n\t\tpieceVicinity.forEach((coords) => {\n\t\t\tconst coordsKey = coordutil.getKeyFromCoords(coords as Coords);\n\t\t\t// typescript doesn't realize vicinity[coordsKey] is gauranteed to be defined\n\t\t\t// after this statement if we use (coordsKey in vicinity) for some reason\n\t\t\tif (!vicinity[coordsKey]) vicinity[coordsKey] = []; // Make sure it's initialized\n\t\t\tvicinity[coordsKey].push(rawType);\n\t\t});\n\t}\n\treturn vicinity;\n}\n\n/**\n * Gets the moveset of the type of piece specified.\n */\nfunction getPieceMoveset(boardsim: Board, pieceType: number): PieceMoveset {\n\tconst [rawType, player] = typeutil.splitType(pieceType); // Split the type into raw and color\n\tif (player === p.NEUTRAL) return { colinear: false }; // Neutral pieces CANNOT MOVE!\n\tconst movesetFunc = boardsim.pieceMovesets[rawType];\n\tif (!movesetFunc) return { colinear: false }; // Safety net.\n\treturn movesetFunc(); // Calling these parameters as a function returns their moveset.\n}\n\n/**\n * Return the piece move that's blocking function if it is specified, or the default otherwise.\n */\nfunction getBlockingFuncFromPieceMoveset(pieceMoveset: PieceMoveset): BlockingFunction {\n\treturn pieceMoveset.blocking || movesets.defaultBlockingFunction;\n}\n\n/**\n * Return the piece move ignore function if it is specified, or the default otherwise.\n */\nfunction getIgnoreFuncFromPieceMoveset(pieceMoveset: PieceMoveset): IgnoreFunction {\n\treturn pieceMoveset.ignore || movesets.defaultIgnoreFunction;\n}\n\n/**\n * Creates an empty LegalMoves object for a piece.\n * Should only be used outside of {@link calculateAll} when check doesn't matter or when you don't want special or calculated moves.\n * @param moveset the moveset belonging to the piece of the legalmoves\n * @returns the legal moves object\n */\nfunction getEmptyLegalMoves(moveset: PieceMoveset): LegalMoves {\n\treturn {\n\t\tindividual: [],\n\t\tsliding: {},\n\t\tignoreFunc: getIgnoreFuncFromPieceMoveset(moveset),\n\t\tcolinear: moveset.colinear,\n\t};\n}\n\n/**\n * Adds all POSSIBLE individual/sliding moves from the moveset provided.\n * Best used for calculating premoves.\n */\nfunction appendPotentialMoves(piece: Piece, moveset: PieceMoveset, legalmoves: LegalMoves): void {\n\t// Possible jumping/individual moves\n\tif (moveset.individual) {\n\t\tconst movesetIndividual = shiftIndividualMovesetByCoords(moveset.individual, piece.coords);\n\t\tlegalmoves.individual = legalmoves.individual.concat(movesetIndividual);\n\t}\n\t// Possible sliding moves\n\tif (moveset.sliding) {\n\t\tlegalmoves.sliding = {\n\t\t\t...moveset.sliding,\n\t\t};\n\t}\n}\n\n/**\n * Shifts/translates the individual/jumping portion\n * of a moveset by the coordinates of a piece.\n * @param indivMoveset - The list of individual/jumping moves this moveset has: `[[1,2],[2,1]]`\n */\nfunction shiftIndividualMovesetByCoords(indivMoveset: readonly Coords[], coords: Coords): Coords[] {\n\treturn indivMoveset.map((indivMove) => {\n\t\treturn [indivMove[0] + coords[0], indivMove[1] + coords[1]];\n\t});\n}\n\n/**\n * Adds any of the pieces movesets applicable special moves\n * @param gamefile\n * @param piece\n * @param moveset\n * @param legalmoves\n * @param premove - Default: false. SET TO TRUE when you need to calculate premoves, which allow all possible moves!\n */\nfunction appendSpecialMoves(\n\tgamefile: FullGame,\n\tpiece: Piece,\n\tmoveset: PieceMoveset,\n\tlegalmoves: LegalMoves,\n\tpremove: boolean,\n): void {\n\tconst color = typeutil.getColorFromType(piece.type);\n\tif (moveset.special)\n\t\tlegalmoves.individual.push(...moveset.special(gamefile, piece.coords, color, premove));\n}\n\n/**\n * Removes moves that either land on a friendly or void,\n * and adjusts slide limits based on the provided moveset's blocking function\n * and what pieces are in the way.\n *\n * Call BEFORE appending special moves.\n */\nfunction removeObstructedMoves(\n\tboardsim: Board,\n\tworldBorder: UnboundedRectangle | undefined,\n\tpiece: Piece,\n\tmoveset: PieceMoveset,\n\tlegalmoves: LegalMoves,\n\tpremove: boolean,\n): void {\n\tconst color = typeutil.getColorFromType(piece.type);\n\n\t// Remove obstructed jumping/individual moves\n\tremoveInvalidIndividualMoves(boardsim, worldBorder, legalmoves.individual, color, premove);\n\n\t// Block sliding moves according to obstructions\n\tif (moveset.sliding)\n\t\tremoveObstructedSlidingMoves(\n\t\t\tboardsim,\n\t\t\tworldBorder,\n\t\t\tpiece,\n\t\t\tmoveset,\n\t\t\tlegalmoves.sliding,\n\t\t\tcolor,\n\t\t\tpremove,\n\t\t);\n}\n\n/**\n * Accepts array of moves, returns new array with illegal moves removed due to pieces occupying.\n * MUTATES original array.\n */\nfunction removeInvalidIndividualMoves(\n\tboardsim: Board,\n\tworldBorder: UnboundedRectangle | undefined,\n\tindividualMoves: Coords[],\n\tcolor: Player,\n\tpremove: boolean,\n): Coords[] {\n\tfor (let i = individualMoves.length - 1; i >= 0; i--) {\n\t\tconst thisMove = individualMoves[i]!;\n\t\tconst moveValidity = testSquareValidity(\n\t\t\tboardsim,\n\t\t\tworldBorder,\n\t\t\tthisMove,\n\t\t\tcolor,\n\t\t\tpremove,\n\t\t\tfalse,\n\t\t);\n\t\tif (moveValidity === 2) individualMoves.splice(i, 1); // Not legal to land on\n\t}\n\n\treturn individualMoves;\n}\n\n/**\n * @param premove - If true, then only voids and world borders block movement.\n */\nfunction removeObstructedSlidingMoves(\n\tboardsim: Board,\n\tworldBorder: UnboundedRectangle | undefined,\n\tpiece: Piece,\n\tmoveset: PieceMoveset,\n\tslidingMoves: Record<Vec2Key, SlideLimits>,\n\tcolor: Player,\n\tpremove: boolean,\n): void {\n\tconst blockingFunc = getBlockingFuncFromPieceMoveset(moveset);\n\tfor (const [linekey, limits] of Object.entries(slidingMoves)) {\n\t\tconst lines = boardsim.pieces.lines.get(linekey as Vec2Key);\n\t\tif (lines === undefined) continue;\n\t\tconst line = coordutil.getCoordsFromKey(linekey as Vec2Key);\n\t\tconst key = organizedpieces.getKeyFromLine(line, piece.coords);\n\t\tconst piecesLine = lines.get(key);\n\t\tif (piecesLine === undefined) continue; // No pieces on this line, so no obstructions. Needed so dragarrows feature doesn't crash on empty lines.\n\t\tslidingMoves[linekey as Vec2Key] = slide_CalcLegalLimit(\n\t\t\tworldBorder,\n\t\t\tblockingFunc,\n\t\t\tboardsim.pieces,\n\t\t\tpiecesLine,\n\t\t\tline,\n\t\t\tlimits,\n\t\t\tpiece.coords,\n\t\t\tcolor,\n\t\t\tpremove,\n\t\t);\n\t}\n}\n\n/**\n * Tests whether the provided coordinates can POSSIBLY be landed on\n * (bar legality check), and whether they should block further movement.\n *\n * 0 => Allowed, and doesn't block further movement (empty square, or premove)\n * 1 => Allowed, but BLOCKS further movement (enemy piece)\n * 2 => Blocked, and BLOCKS further movement (friendly piece or void or outside border)\n *\n * @param premove - Exempts the `capturing` requirement from being fulfilled, and allows capturing friendlies.\n * @param capturing - Whether the move is required to be a capture (pawn diagonal move). Default: false. Setting this to false DOES NOT require the move to be non-capturing.\n */\nfunction testSquareValidity(\n\tboardsim: Board,\n\tworldBorder: UnboundedRectangle | undefined,\n\tcoords: Coords,\n\tfriendlyColor: Player,\n\tpremove: boolean,\n\tcapturing: boolean,\n): 0 | 1 | 2 {\n\t// Test whether the given square lies out of bounds of the position.\n\tif (worldBorder !== undefined && !bounds.boxContainsSquare(worldBorder, coords)) return 2;\n\n\tconst typeOnSquare = boardutil.getTypeFromCoords(boardsim.pieces, coords);\n\n\tif (typeOnSquare === undefined) {\n\t\tif (premove) return 0; // No piece, premove means capture could end up happening => legal move\n\t\tif (capturing) return 2; // Not a capture, yet capture is required => not legal\n\t\treturn 0; // No piece, in bounds => legal move\n\t}\n\n\treturn testCaptureValidity(friendlyColor, typeOnSquare, premove);\n}\n\n/**\n * Tests whether the provided piece type can POSSIBLY be captured\n * (bar legality check), and whether they should block further movement.\n *\n * 0 => Allowed, and doesn't block further movement (premove)\n * 1 => Allowed, but BLOCKS further movement (enemy piece)\n * 2 => Blocked, and BLOCKS further movement (friendly piece or void)\n *\n * @param premove - Allows capturing friendlies.\n */\nfunction testCaptureValidity(\n\tfriendlyColor: Player,\n\ttypeOnSquare: number,\n\tpremove: boolean,\n): 0 | 1 | 2 {\n\tconst rawType = typeutil.getRawType(typeOnSquare);\n\tif (rawType === r.VOID) return 2; // Void, NEVER legal\n\n\tif (premove) return 0; // There is a non-void piece, but we're premoving => legal move\n\n\tconst colorOfPiece = typeutil.getColorFromType(typeOnSquare);\n\tif (friendlyColor === colorOfPiece) return 2; // Friendly piece, not legal\n\n\treturn 1; // Enemy piece, legal move, but blocks further movement\n}\n\n/**\n * Calculates and generates all legal moves of a piece in the provided gamefile.\n * @param gamefile\n * @param piece\n * @returns The legal moves of that piece\n */\nfunction calculateAll(gamefile: FullGame, piece: Piece): LegalMoves {\n\tconst moveset = getPieceMoveset(gamefile.boardsim, piece.type);\n\tconst moves = getEmptyLegalMoves(moveset);\n\tappendPotentialMoves(piece, moveset, moves);\n\tremoveObstructedMoves(\n\t\tgamefile.boardsim,\n\t\tgamefile.basegame.gameRules.worldBorder,\n\t\tpiece,\n\t\tmoveset,\n\t\tmoves,\n\t\tfalse,\n\t);\n\tappendSpecialMoves(gamefile, piece, moveset, moves, false);\n\tcheckresolver.removeCheckInvalidMoves(gamefile, piece, moves);\n\treturn moves;\n}\n\n/**\n * Calculates all possible premoves of a piece in the provided gamefile.\n * * Jumps can't be obstructed.\n * * Slides can't be blocked.\n * * No check pruning is made.\n */\nfunction calculateAllPremoves(gamefile: FullGame, piece: Piece): LegalMoves {\n\tconst moveset = getPieceMoveset(gamefile.boardsim, piece.type);\n\tconst moves = getEmptyLegalMoves(moveset);\n\tappendPotentialMoves(piece, moveset, moves);\n\tremoveObstructedMoves(\n\t\tgamefile.boardsim,\n\t\tgamefile.basegame.gameRules.worldBorder,\n\t\tpiece,\n\t\tmoveset,\n\t\tmoves,\n\t\ttrue,\n\t); // true to only remove void and world border obstructions\n\tappendSpecialMoves(gamefile, piece, moveset, moves, true); // true to add all possible moves\n\t// SKIP removing check invalids!\n\treturn moves;\n}\n\n/**\n * Takes in specified organized list, direction of the slide, the current moveset...\n * Shortens the moveset by pieces that block it's path.\n * @param blockingFunc - The function that will check if each piece on the same line needs to block the piece\n * @param o\n * @param line - The list of pieces on this line\n * @param step - The direction of the line: `[dx,dy]`\n * @param slideMoveset - How far this piece can slide in this direction: `[left,right]`. If the line is vertical, this is `[bottom,top]`\n * @param coords - The coordinates of the piece with the specified slideMoveset.\n * @param color - The color of friendlies\n */\nfunction slide_CalcLegalLimit(\n\tworldBorder: UnboundedRectangle | undefined,\n\tblockingFunc: BlockingFunction,\n\to: OrganizedPieces,\n\tline: number[],\n\tstep: Vec2,\n\tslideMoveset: SlideLimits,\n\tcoords: Coords,\n\tcolor: Player,\n\tpremove: boolean,\n): SlideLimits {\n\t// The default slide is [null, null] (Infinity in both directions),\n\t// change that if there are any pieces blocking our path!\n\t// The first index is always negative if it's not null (Infinity)\n\n\t// For most we'll be comparing the x values, only exception is the vertical lines.\n\tconst axis = step[0] === 0n ? 1 : 0;\n\tconst limit = [...slideMoveset] as SlideLimits; // Makes a copy\n\n\t// First of all, if we're using a world border, immediately shorten our slide limit to not exceed it.\n\tenforceWorldBorderOnSlideLimit(worldBorder, limit, coords, step); // Mutating\n\t// else console.error(\"No world border set, skipping world border slide limit check.\");\n\n\t// Iterate through all pieces on same line\n\tfor (const idx of line) {\n\t\tconst thisPiece = boardutil.getPieceFromIdx(o, idx)!; // { type, coords }\n\n\t\t/**\n\t\t * 0 => Piece doesn't block\n\t\t * 1 => Blocked ON the square (enemy piece)\n\t\t * 2 => Blocked 1 before the square (friendly piece or void)\n\t\t */\n\t\tconst blockResult = blockingFunc(color, thisPiece, coords, premove);\n\t\tif (blockResult !== 0 && blockResult !== 1 && blockResult !== 2)\n\t\t\tthrow new Error(\n\t\t\t\t`slide_CalcLegalLimit() not built to handle block result of \"${blockResult}\"!`,\n\t\t\t);\n\n\t\tif (blockResult === 0) continue; // Not blocked.\n\n\t\t// It blocks movement...\n\n\t\t// Is the piece to the left of us or right of us?\n\t\tconst thisPieceSteps = (thisPiece.coords[axis] - coords[axis]) / step[axis]; // Can be negative\n\t\tif (thisPieceSteps < 0) {\n\t\t\t// To our left\n\n\t\t\t// What would our new left slide limit be? If it's an opponent, it's legal to capture it.\n\t\t\tconst newLeftSlideLimit = blockResult === 2 ? thisPieceSteps + 1n : thisPieceSteps;\n\t\t\t// If the piece x is closer to us than our current left slide limit, update it\n\t\t\tif (limit[0] === null || newLeftSlideLimit > limit[0]) limit[0] = newLeftSlideLimit;\n\t\t} else if (thisPieceSteps > 0) {\n\t\t\t// To our right\n\n\t\t\t// What would our new right slide limit be? If it's an opponent, it's legal to capture it.\n\t\t\tconst newRightSlideLimit = blockResult === 2 ? thisPieceSteps - 1n : thisPieceSteps;\n\t\t\t// If the piece x is closer to us than our current left slide limit, update it\n\t\t\tif (limit[1] === null || newRightSlideLimit < limit[1]) limit[1] = newRightSlideLimit;\n\t\t} // else this is us, don't do anything.\n\t}\n\treturn limit;\n}\n\n/** Modifies the provided slide limit in a single step direction (positive & negative) to not exceed the world border. */\nfunction enforceWorldBorderOnSlideLimit(\n\tworldBorder: UnboundedRectangle | undefined,\n\tlimit: SlideLimits,\n\tcoords: Coords,\n\tstep: Vec2,\n): void {\n\tif (!worldBorder) return; // No world border, skip\n\n\tif (!bounds.boxContainsSquare(worldBorder, coords)) {\n\t\t// This can legitimately happen when using the drag arrows feature\n\t\t// to drag an arrow's piece outside of the world border.\n\t\t// console.warn('Piece outside world border.'); // Doesn't crash game, but does yield strange legal move results.\n\t}\n\n\t// Helper to apply logic for a single border\n\tconst checkBound = (border: bigint | null, axis: 0 | 1, isMaxBound: boolean): void => {\n\t\tconst axisStep = step[axis];\n\t\tif (border === null || axisStep === 0n) return;\n\n\t\t// Takes advantage that bigints truncate towards zero when dividing.\n\t\t// The result is how many steps it would take to reach the border, but not exceed it.\n\t\tconst stepsToIntersect = (border - coords[axis]) / axisStep;\n\t\tconst movingTowards = isMaxBound ? axisStep > 0 : axisStep < 0;\n\n\t\tif (movingTowards) {\n\t\t\tif (limit[1] === null || stepsToIntersect < limit[1]) limit[1] = stepsToIntersect;\n\t\t} else {\n\t\t\tif (limit[0] === null || stepsToIntersect > limit[0]) limit[0] = stepsToIntersect;\n\t\t}\n\t};\n\n\t// X Axis\n\tcheckBound(worldBorder.left, 0, false); // Min bound\n\tcheckBound(worldBorder.right, 0, true); // Max bound\n\n\t// Y Axis\n\tcheckBound(worldBorder.bottom, 1, false); // Min bound\n\tcheckBound(worldBorder.top, 1, true); // Max bound\n\n\t// console.log('New limit for step ', step, 'after blocked by world border:', limit);\n}\n\n/**\n * Calculates how far a given piece can legally slide (ignoring ignore functions, and ignoring check respection)\n * on the given line of a specific slope.\n * @param boardsim\n * @param piece\n * @param slide\n * @param slideKey - The key `C|X` of the specific organized line we need to find out how far this piece can slide on\n * @param organizedLine - The organized line of the above key that our piece is on\n */\nfunction calcPiecesLegalSlideLimitOnSpecificLine(\n\tboardsim: Board,\n\tworldBorder: UnboundedRectangle | undefined,\n\tpiece: Piece,\n\tslide: Vec2,\n\tslideKey: Vec2Key,\n\torganizedLine: number[],\n): SlideLimits | undefined {\n\tconst thisPieceMoveset = getPieceMoveset(boardsim, piece.type); // Default piece moveset\n\tif (!thisPieceMoveset.sliding) return; // This piece can't slide at all\n\tif (!thisPieceMoveset.sliding[slideKey]) return; // This piece can't slide ALONG the provided line\n\t// This piece CAN slide along the provided line.\n\t// Calculate how far it can slide...\n\tconst blockingFunc = getBlockingFuncFromPieceMoveset(thisPieceMoveset);\n\tconst friendlyColor = typeutil.getColorFromType(piece.type);\n\treturn slide_CalcLegalLimit(\n\t\tworldBorder,\n\t\tblockingFunc,\n\t\tboardsim.pieces,\n\t\torganizedLine,\n\t\tslide,\n\t\tthisPieceMoveset.sliding[slideKey],\n\t\tpiece.coords,\n\t\tfriendlyColor,\n\t\tfalse,\n\t);\n}\n\n/**\n * Checks if the provided move start and end coords is one of the\n * legal moves in the provided legalMoves object.\n *\n * **This will modify** the provided endCoords to attach any special move tags.\n * @param gamefile\n * @param legalMoves - The legalmoves object with the properties `individual`, `horizontal`, `vertical`, `diagonalUp`, `diagonalDown`.\n * @param startCoords - The coordinates of the piece owning the legal moves\n * @param endCoords - The square to test if the piece can legally move to\n * @param colorOfFriendly - The player color owning the piece with the legal moves\n * @returns *true* if the provided legalMoves object contains the provided endCoords.\n */\nfunction checkIfMoveLegal(\n\tgamefile: FullGame,\n\tlegalMoves: LegalMoves,\n\tstartCoords: Coords,\n\tendCoords: CoordsTagged,\n\tcolorOfFriendly: Player,\n): boolean {\n\t// Do one of the individual moves match?\n\tconst individual = legalMoves.individual;\n\tconst length = !individual ? 0 : individual.length;\n\tfor (let i = 0; i < length; i++) {\n\t\tconst thisIndividual = individual[i]!;\n\t\tif (!coordutil.areCoordsEqual(endCoords, thisIndividual)) continue;\n\t\t// Subtle way of passing on the TAG of all special moves!\n\t\tspecialdetect.transferSpecialTags_FromCoordsToCoords(thisIndividual, endCoords);\n\t\treturn true;\n\t}\n\n\tif (!doSlideRangesContainSquare(legalMoves, startCoords, endCoords)) return false;\n\tif (legalMoves.brute) {\n\t\t// Don't allow the slide if it results in check\n\t\tconst moveTagged = { startCoords, endCoords };\n\t\tif (checkresolver.getSimulatedCheck(gamefile, moveTagged, colorOfFriendly).check)\n\t\t\treturn false; // The move results in check => not legal\n\t}\n\treturn true; // Move is legal\n}\n\n/**\n * Checks if the provided end coords are reachable via any slide in the provided legal moves.\n * @param legalMoves\n * @param startCoords - The coordinates of the piece owning the legal moves\n * @param endCoords - The square to test if the piece can slide to\n * @returns *true* if the endCoords lie within the sliding range.\n */\nfunction doSlideRangesContainSquare(\n\tlegalMoves: LegalMoves,\n\tstartCoords: Coords,\n\tendCoords: Coords,\n): boolean {\n\tif (coordutil.areCoordsEqual(startCoords, endCoords)) return false; // Can't slide to the square we're already on\n\n\tfor (const [strline, limits] of Object.entries(legalMoves.sliding)) {\n\t\tconst line = coordutil.getCoordsFromKey(strline as Vec2Key); // 'dx,dy'\n\n\t\tconst selectedPieceLine = organizedpieces.getKeyFromLine(line, startCoords);\n\t\tconst clickedCoordsLine = organizedpieces.getKeyFromLine(line, endCoords);\n\t\tif (selectedPieceLine !== clickedCoordsLine) continue; // Continue if they don't lie on the same line\n\n\t\t// prettier-ignore\n\t\tif (doesSlidingMovesetContainSquare(limits, line, startCoords, endCoords, legalMoves.ignoreFunc))\n\t\t\treturn true;\n\t}\n\treturn false;\n}\n\n/**\n * Tests if the piece's precalculated slideMoveset is able to reach the provided coords.\n * ASSUMES the coords are on the direction of travel!!!\n * @param slideMoveset - The distance the piece can move along this line: `[left,right]`. If the line is vertical, this will be `[bottom,top]`.\n * @param direction - The direction of the line: `[dx,dy]`\n * @param pieceCoords - The coordinates of the piece with the provided sliding net\n * @param coords - The coordinates we want to know if they can reach.\n * @param ignoreFunc - The ignore function.\n * @returns true if the piece is able to slide to the coordinates\n */\nfunction doesSlidingMovesetContainSquare(\n\tslideMoveset: SlideLimits,\n\tdirection: Vec2,\n\tpieceCoords: Coords,\n\tcoords: Coords,\n\tignoreFunc: IgnoreFunction,\n): boolean {\n\tconst axis = direction[0] === 0n ? 1 : 0;\n\tconst coord = coords[axis];\n\tconst min: bigint | null =\n\t\tslideMoveset[0] === null ? null : pieceCoords[axis] + direction[axis] * slideMoveset[0]; // No need to negate direction because slideMoveset[0] is always negative\n\tconst max: bigint | null =\n\t\tslideMoveset[1] === null ? null : pieceCoords[axis] + direction[axis] * slideMoveset[1];\n\treturn (\n\t\t(min === null || coord >= min) &&\n\t\t(max === null || coord <= max) &&\n\t\tignoreFunc(pieceCoords, coords)\n\t);\n}\n\n/**\n * Accepts the calculated legal moves, tests to see if there is at least one.\n * In the extreme case, when the `brute` flag is present for slides, and the\n * slide width exceeds {@link MAX_BRUTE_SIMULATIONS}, this may return true as\n * a safety measure to avoid hangs, even if there may not actually be a legal move.\n * @param moves\n * @param gamefile\n * @param piece - The piece that owns these legal moves\n */\nfunction hasAtleast1Move(moves: LegalMoves, gamefile: FullGame, piece: Piece): boolean {\n\tif (moves.individual.length > 0) return true;\n\tfor (const [lineKey, limits] of Object.entries(moves.sliding)) {\n\t\tif (slideHasAtLeast1LegalMove(lineKey as Vec2Key, limits)) return true;\n\t}\n\treturn false;\n\n\t/**\n\t * Checks whether a given slide range contains at least one legal move.\n\t * If the `brute` flag is present, up to {@link MAX_BRUTE_SIMULATIONS}\n\t * squares are simulated for legality before assuming there may be a\n\t * legal move and returning true for safety to avoid hangs.\n\t */\n\tfunction slideHasAtLeast1LegalMove(lineKey: Vec2Key, slide: SlideLimits): boolean {\n\t\tif (slide[0] === null || slide[1] === null) return true; // Infinite range\n\n\t\tconst rangeWidth = slide[1] - slide[0];\n\n\t\tconst offsetPositive = slide[0] > 0n; // Both limits positive\n\t\tconst offsetNegative = slide[1] < 0n; // Both limits negative\n\n\t\t// Any non-empty range means there is at least one legal move\n\t\tif (!moves.brute) {\n\t\t\t// EXCEPTION: Width can be 0 if there is an offset (not centered on the piece)\n\t\t\treturn rangeWidth > 0n || offsetPositive || offsetNegative;\n\t\t}\n\n\t\t// Brute flag is present... this will require simulating moves for check\n\n\t\t// If the range width is greater than our cap, just assume there's at least one legal move to avoid hangs.\n\t\tif (rangeWidth > MAX_BRUTE_SIMULATIONS) return true;\n\n\t\tconst step = coordutil.getCoordsFromKey(lineKey);\n\t\tconst color = typeutil.getColorFromType(piece.type);\n\n\t\t/** Simulates a single candidate step. Returns true if it's a legal move, false otherwise. */\n\t\tfunction tryStep(s: bigint): boolean {\n\t\t\tconst targetCoords: Coords = [\n\t\t\t\tpiece.coords[0] + step[0] * s,\n\t\t\t\tpiece.coords[1] + step[1] * s,\n\t\t\t];\n\t\t\tif (!moves.ignoreFunc(piece.coords, targetCoords)) return false; // Not a valid landing (e.g., not prime for Huygens)\n\t\t\tconst moveTagged: MoveTagged = { startCoords: piece.coords, endCoords: targetCoords };\n\t\t\treturn !checkresolver.getSimulatedCheck(gamefile, moveTagged, color).check;\n\t\t}\n\n\t\t// Positive side. Skip if range is entirely negative\n\t\tif (!offsetNegative) {\n\t\t\tfor (let s = bimath.max(1n, slide[0]); s <= slide[1]; s++) {\n\t\t\t\tif (tryStep(s)) return true;\n\t\t\t}\n\t\t}\n\t\t// Negative side. Skip if range is entirely positive\n\t\tif (!offsetPositive) {\n\t\t\tfor (let s = bimath.min(-1n, slide[1]); s >= slide[0]; s--) {\n\t\t\t\tif (tryStep(s)) return true;\n\t\t\t}\n\t\t}\n\n\t\treturn false; // No legal blocking move found in the bounded range\n\t}\n}\n\n// Exports ----------------------------------------------------------------\n\nexport type { LegalMoves, SlideLimits };\n\nexport default {\n\tgenVicinity,\n\tgenSpecialVicinity,\n\tgetPieceMoveset,\n\n\tgetBlockingFuncFromPieceMoveset,\n\tgetIgnoreFuncFromPieceMoveset,\n\n\tgetEmptyLegalMoves,\n\tappendPotentialMoves,\n\tremoveObstructedMoves,\n\tappendSpecialMoves,\n\ttestSquareValidity,\n\ttestCaptureValidity,\n\n\tcalculateAll,\n\tcalculateAllPremoves,\n\n\tslide_CalcLegalLimit,\n\tcalcPiecesLegalSlideLimitOnSpecificLine,\n\n\tcheckIfMoveLegal,\n\tdoSlideRangesContainSquare,\n\tdoesSlidingMovesetContainSquare,\n\thasAtleast1Move,\n};\n"
  },
  {
    "path": "src/shared/chess/logic/movepiece.ts",
    "content": "// src/shared/chess/logic/movepiece.ts\n\n/**\n * This script handles the logical side of moving pieces, nothing graphical.\n *\n * Both ends, client & server, should be able to use this script.\n */\n\nimport type { Piece } from '../util/boardutil.js';\nimport type { Coords } from '../util/coordutil.js';\nimport type { Change } from './boardchanges.js';\nimport type { MoveCoords } from './icn/icnconverter.js';\nimport type { MovePacket } from '../../types.js';\nimport type { GameConclusion } from '../util/winconutil.js';\nimport type { Board, FullGame } from './gamefile.js';\nimport type { EnPassant, MoveState } from './state.js';\n\nimport state from './state.js';\nimport bimath from '../../util/math/bimath.js';\nimport typeutil from '../util/typeutil.js';\nimport moveutil from '../util/moveutil.js';\nimport coordutil from '../util/coordutil.js';\nimport boardutil from '../util/boardutil.js';\nimport legalmoves from './legalmoves.js';\nimport boardchanges from './boardchanges.js';\nimport icnconverter from './icn/icnconverter.js';\nimport wincondition from './wincondition.js';\nimport specialdetect from './specialdetect.js';\nimport checkdetection from './checkdetection.js';\nimport movevalidation from './movevalidation.js';\nimport organizedpieces from './organizedpieces.js';\nimport { rawTypes as r } from '../util/typeutil.js';\n\n// Types --------------------------------------------------------------------------------------------------------------------------\n\n/** A special move tag name on {@link CoordsTagged}, both move tags and UI tags. */\ninterface SpecialTags extends MoveSpecialTags, UISpecialTags {}\n\n/**\n * A special move tag that is retained when transferring from {@link CoordsTagged} to a move.\n * This describes what actually happened during the move execution.\n */\ninterface MoveSpecialTags {\n\t/** Special move tag that, when present, making the move will create an enpassant state on the gamefile. */\n\tenpassantCreate: EnPassant;\n\t/**\n\t * A special move tag for enpassant capture.\n\t *\n\t * If true, the specialMove function for pawns will read the gamefile's\n\t * enpassant property to figure out where the pawn to capture is.\n\t * After that, the captured piece is appended to the move's changes list,\n\t * so we don't actually need to store more information in here.\n\t */\n\tenpassant: true;\n\t/** A special move tag for pawn promotion. This is the integer type of the piece promoted to. */\n\tpromotion: number;\n\t/** A special move tag for castling. */\n\tcastle: {\n\t\t/** 1 => King castled right   -1 => King castled left */\n\t\tdir: 1n | -1n;\n\t\t/** The coordinate of the piece the king castled with, usually a rook. */\n\t\tcoord: Coords;\n\t};\n\t/**\n\t * A special move tag that stores a list of all the waypoints along\n\t * the travel path of a piece. Inclusive to start and end.\n\t *\n\t * Used for Rose piece.\n\t */\n\tpath: Coords[];\n}\n\n/**\n * A special move tag that is UI-only. It is present on {@link CoordsTagged}\n * to signal something to the UI (e.g. open the promotion picker), and is\n * consumed and removed BEFORE the move is executed — never transferred to a move.\n */\ninterface UISpecialTags {\n\t/**\n\t * A special move tag that, when the move is attempted to be made should\n\t * trigger the promotion UI to open. The special detect functions are in\n\t * charge of adding this. selection.ts will delete it and open the promotion UI.\n\t */\n\tpromoteTrigger: boolean;\n}\n\n/**\n * A pair of coordinates, WITH attached special move information.\n * This usually denotes a legal square you can move to that will\n * activate said special move.\n */\ntype CoordsTagged = Coords & Partial<SpecialTags>;\n\n/** A move as stored in the base game. Does not need a lot of details. */\ninterface MoveRecord extends MoveCoords {\n\t/**\n\t * How much time the player had left after they made their move, in millis.\n\t *\n\t * Server is always boss, we cannot set this until after the\n\t * server responds back with the updated clock information.\n\t */\n\tclockStamp?: number;\n\t/** The move in most compact notation: `8,7>8,8=Q` */\n\ttoken: string;\n}\n\n/** A {@link MoveCoords} move with all special tags attached. */\ntype MoveTagged = MoveCoords & Partial<MoveSpecialTags>;\n\n/** Information about some change on the chessboard, either by a move or some other property change (e.g. as used in the board editor) */\ninterface Edit {\n\t/** A list of changes the move made to the board, whether it moved a piece, captured a piece, added a piece, etc. */\n\tchanges: Array<Change>;\n\t/** The state of the move is used to know how to modify specific gamefile\n\t * properties when forwarding/rewinding this move. */\n\tstate: MoveState;\n}\n\n/**\n * All properties of a move needed to apply/unapply it\n * to/from the board state, along with other useful flags.\n */\ninterface MoveFull extends Edit, MoveTagged, MoveRecord {\n\t/** The type of piece moved */\n\ttype: number;\n\t/** The index this move was generated for. This can act as a safety net\n\t * so we don't accidentally make the move on the wrong index of the game. */\n\tgenerateIndex: number;\n\tflags: {\n\t\t/** Whether the move delivered check. */\n\t\tcheck: boolean;\n\t\t/** Whether the move delivered mate (or the killing move). */\n\t\tmate: boolean;\n\t\t/** Whether the move caused a capture */\n\t\tcapture: boolean;\n\t};\n\t/**\n\t * Any comment made on the move, specified in the ICN.\n\t * These will go back into the ICN when copying the game.\n\t */\n\tcomment?: string;\n}\n\n// Constants -------------------------------------------------------------------------------------------------------\n\n/**\n * All special move tag names that are retained when transferring from {@link CoordsTagged}\n * to a move. These describe what actually happened during the move execution.\n */\nconst MOVE_SPECIAL_TAGS = [\n\t'enpassantCreate',\n\t'enpassant',\n\t'promotion',\n\t'castle',\n\t'path',\n] satisfies ReadonlyArray<keyof MoveSpecialTags>;\n\n/**\n * All special move tag names that are UI-only. They are present on {@link CoordsTagged}\n * to signal something to the UI (e.g. open the promotion picker), and are\n * consumed and removed BEFORE the move is executed — never transferred to a move.\n */\nconst UI_SPECIAL_TAGS = ['promoteTrigger'] satisfies ReadonlyArray<keyof UISpecialTags>;\n\n/** All special move tags names on {@link CoordsTagged}, both move tags and UI tags. */\nconst SPECIAL_TAGS = [...MOVE_SPECIAL_TAGS, ...UI_SPECIAL_TAGS] satisfies ReadonlyArray<\n\tkeyof SpecialTags\n>;\n\n// Move Generating --------------------------------------------------------------------------------------------------\n\n/**\n * Generates a full MoveFull from a MoveTagged, then immediately applies it to the gamefile.\n * @returns The generated MoveFull object\n */\nfunction generateAndMakeMove(gamefile: FullGame, moveTagged: MoveTagged): MoveFull {\n\tconst move = generateMove(gamefile, moveTagged);\n\tmakeMove(gamefile, move);\n\treturn move;\n}\n\n/**\n * Generates a full MoveFull object from a MoveTagged,\n * calculating and appending its board changes to its Changes list,\n * and queueing its gamefile StateChanges.\n */\nfunction generateMove(gamefile: FullGame, moveTagged: MoveTagged): MoveFull {\n\tconst { boardsim } = gamefile;\n\tconst piece = boardutil.getPieceFromCoords(boardsim.pieces, moveTagged.startCoords);\n\tif (!piece)\n\t\tthrow Error(\n\t\t\t`Cannot make move because no piece exists at coords ${coordutil.stringifyCoords(moveTagged.startCoords)}.`,\n\t\t);\n\n\t// Construct the full MoveFull object\n\t// Initialize the state, and change list, as empty for now.\n\tconst move: MoveFull = {\n\t\t...moveTagged,\n\t\ttype: piece.type,\n\t\tchanges: [],\n\t\tgenerateIndex: boardsim.state.local.moveIndex + 1,\n\t\tstate: { local: [], global: [] },\n\t\ttoken: icnconverter.getCompactMoveFromDraft(moveTagged),\n\t\tflags: {\n\t\t\t// These will be set later, but we need a default value\n\t\t\tcheck: false,\n\t\t\tmate: false,\n\t\t\tcapture: false,\n\t\t},\n\t};\n\n\t/**\n\t * Delete the current enpassant state.\n\t * If any specialMove function adds a new EnPassant state,\n\t * this one's future value will be overwritten\n\t */\n\tstate.createEnPassantState(move, boardsim.state.global.enpassant, undefined);\n\n\tconst rawType = typeutil.getRawType(move.type);\n\tlet specialMoveMade: boolean = false;\n\t// If a special move function exists for this piece type, run it.\n\t// The actual function will return whether a special move was actually made or not.\n\t// If a special move IS made, we skip the normal move piece method.\n\tif (rawType in boardsim.specialMoves)\n\t\tspecialMoveMade = boardsim.specialMoves[rawType]!(boardsim, piece, move);\n\tif (!specialMoveMade) calcMovesChanges(boardsim, piece, moveTagged, move); // Move piece regularly (no special tag)\n\n\t// Must be set before calling queueIncrementMoveRuleStateChange()\n\tmove.flags.capture = boardchanges.wasACapture(move);\n\n\t// Delete all special rights that should be revoked from the move.\n\tqueueSpecialRightDeletionStateChanges(boardsim, move);\n\n\tqueueIncrementMoveRuleStateChange(gamefile, move);\n\n\treturn move;\n}\n\n/**\n * Calculates all of a move's board changes, and \"queues\" them,\n * adding them to the move's Changes list.\n *\n * This should NOT be used if the move is a special move.\n * @param boardsim - The board\n * @param piece - The piece that's being moved\n * @param move - The move that's being made\n */\nfunction calcMovesChanges(boardsim: Board, piece: Piece, moveCoords: MoveCoords, edit: Edit): void {\n\tconst capturedPiece = boardutil.getPieceFromCoords(boardsim.pieces, moveCoords.endCoords);\n\n\tif (capturedPiece) boardchanges.queueCapture(edit.changes, true, capturedPiece);\n\tboardchanges.queueMovePiece(edit.changes, true, piece, moveCoords.endCoords);\n}\n\n/**\n * Queues gamefile state changes to delete all\n * special rights that should have been revoked from the move.\n * This includes the startCoords and endCoords of all move actions.\n */\nfunction queueSpecialRightDeletionStateChanges(boardsim: Board, edit: Edit): void {\n\tedit.changes.forEach((change) => {\n\t\tif (change.action === 'move' || change.action === 'capture' || change.action === 'delete') {\n\t\t\t// Delete any special rights off the (start coords / captured piece coords / deleted piece coords).\n\t\t\tcascadeDeleteSpecialRights(boardsim, change.piece.coords, edit);\n\t\t}\n\t});\n}\n\n/**\n * Helper for, when one square's special rights is deleted,\n * scanning the entire row for all kings and rooks that can\n * no longer have a valid castling partner because of it,\n * and deleting their special rights too.\n */\nfunction cascadeDeleteSpecialRights(boardsim: Board, coords: Coords, edit: Edit): void {\n\tconst coordsKey = coordutil.getKeyFromCoords(coords);\n\tconst hasSpecialRight = boardsim.state.global.specialRights.has(coordsKey);\n\tif (!hasSpecialRight) return; // No special right existed on square in first place.\n\n\t// 1. Delete the rights of the specific piece that moved/died\n\tstate.createSpecialRightsState(edit, coordsKey, hasSpecialRight, false);\n\n\tconst piece = boardutil.getPieceFromCoords(boardsim.pieces, coords)!;\n\tconst [rawType, player] = typeutil.splitType(piece.type);\n\n\tif (rawType === r.PAWN) return; // Pawns cannot castle, them losing their special right doesn't affect others.\n\n\tconst isTrigger: boolean = typeutil.jumpingRoyals.includes(rawType); // Royals are the castling triggers\n\n\tconst key = organizedpieces.getKeyFromLine([1n, 0n], coords);\n\tconst row = boardsim.pieces.lines.get('1,0')!.get(key)!;\n\n\t// 2. Iterate through all pieces on this rank.\n\t// If they can no longer castle with any valid partner, delete their special right too.\n\tfor (const idx of row) {\n\t\tconst candidate = boardutil.getDefinedPieceFromIdx(boardsim.pieces, idx);\n\t\tconst [candRawType, candPlayer] = typeutil.splitType(candidate.type);\n\n\t\t// Basic Validity Checks\n\t\tif (candPlayer !== player) continue; // Affects friends only\n\t\tif (candRawType === r.PAWN) continue; // Pawns don't have castling rights\n\n\t\tconst candCoordsKey = coordutil.getKeyFromCoords(candidate.coords);\n\t\tif (!boardsim.state.global.specialRights.has(candCoordsKey)) continue; // Already has no rights\n\n\t\t// Optimization: If the piece being checked is the same \"Role\" as the piece that triggered this event,\n\t\t// it is unaffected. (e.g. Left Rook moving doesn't stop Right Rook from *potential* castling).\n\t\tconst candidateIsTrigger = typeutil.jumpingRoyals.includes(candRawType); // Royals are the castling triggers\n\t\tif (candidateIsTrigger === isTrigger) continue;\n\n\t\t// 3. Search: Does this candidate have ANY valid partner remaining?\n\t\tconst hasValidPartner = hasCastlingPartner(\n\t\t\tboardsim,\n\t\t\tcandidate,\n\t\t\t// Additional constraint: The partner cannot be the piece that just moved/died\n\t\t\t(partner: Piece) => !coordutil.areCoordsEqual(partner.coords, coords),\n\t\t);\n\n\t\t// If no partners were found, this piece is now impotent. Revoke its rights.\n\t\tif (!hasValidPartner) state.createSpecialRightsState(edit, candCoordsKey, true, false);\n\t}\n}\n\n/**\n * Determines whether a piece has any valid castling partner on the board.\n * @param boardsim\n * @param candidate - A candidate piece for castling. MUST NOT be a pawn.\n * @param partnerConstraint - An optional function, run for each partner, that must return true for them to be considered valid.\n */\nfunction hasCastlingPartner(\n\tboardsim: Board,\n\tcandidate: Piece,\n\tpartnerConstraint?: (partner: Piece) => boolean,\n): boolean {\n\tconst [candRawType, candPlayer] = typeutil.splitType(candidate.type);\n\n\t// Basic Validity Checks\n\tif (candRawType === r.PAWN) throw new Error('Cannot test if pawn has valid castling partner.'); // Safety, this could be easy to accidentally pass in.\n\n\tconst candidateIsTrigger = typeutil.jumpingRoyals.includes(candRawType); // Royals are the castling triggers\n\n\tconst key = organizedpieces.getKeyFromLine([1n, 0n], candidate.coords);\n\tconst row = boardsim.pieces.lines.get('1,0')!.get(key)!;\n\n\t// Search: Does this candidate have ANY valid castling partner?\n\tconst hasValidPartner = row.some((partnerIdx) => {\n\t\tconst partner = boardutil.getDefinedPieceFromIdx(boardsim.pieces, partnerIdx);\n\t\tconst [partnerRawType, partnerPlayer] = typeutil.splitType(partner.type);\n\n\t\t// Partner Validation\n\t\tif (partnerPlayer !== candPlayer) return false; // Affects friends only\n\t\tif (partnerRawType === r.PAWN) return false; // Pawns don't have castling rights\n\n\t\tconst partnerCoordsKey = coordutil.getKeyFromCoords(partner.coords);\n\t\tif (!boardsim.state.global.specialRights.has(partnerCoordsKey)) return false; // Partner must have rights\n\n\t\t// A valid partner must be the OPPOSITE role (King needs Rook, Rook needs King)\n\t\tconst partnerIsTrigger = typeutil.jumpingRoyals.includes(partnerRawType);\n\t\tif (partnerIsTrigger === candidateIsTrigger) return false;\n\n\t\t// Distance Check: Must be at least 3 spaces away\n\t\tconst dist = bimath.abs(candidate.coords[0] - partner.coords[0]);\n\t\tif (dist < 3n) return false;\n\n\t\t// Additional optional constraint checks\n\t\tif (partnerConstraint && !partnerConstraint(partner)) return false;\n\n\t\treturn true; // Found a valid partner!\n\t});\n\n\treturn hasValidPartner;\n}\n\n/**\n * Increments the gamefile's moveRuleStatus property, if the move-rule is in use.\n */\nfunction queueIncrementMoveRuleStateChange({ basegame, boardsim }: FullGame, move: MoveFull): void {\n\tif (!basegame.gameRules.moveRule) return; // Not using the move-rule\n\n\t// Reset if it was a capture or pawn movement\n\tconst newMoveRule =\n\t\t!move.flags.capture && typeutil.getRawType(move.type) !== r.PAWN\n\t\t\t? boardsim.state.global.moveRuleState! + 1\n\t\t\t: 0;\n\tstate.createMoveRuleState(move, boardsim.state.global.moveRuleState!, newMoveRule);\n}\n\n// Forwarding -------------------------------------------------------------------------------------------------------\n\n/**\n * Executes all the logical board changes of a global forward move in the game, no graphical changes.\n */\nfunction makeMove(gamefile: FullGame, move: MoveFull): void {\n\tgamefile.boardsim.moves.push(move);\n\tgamefile.basegame.moves.push({\n\t\tstartCoords: move.startCoords,\n\t\tendCoords: move.endCoords,\n\t\tpromotion: move.promotion,\n\t\ttoken: move.token,\n\t\t// Propogate the clockStamp if already set. REQUIRED for server-side move\n\t\t// validated games to persist their clock information over server restarts!\n\t\tclockStamp: move.clockStamp,\n\t});\n\n\tapplyMove(gamefile, move, true, { global: true }); // Apply the logical boardsim changes.\n\n\t// This needs to be after the moveIndex is updated\n\tupdateTurn(gamefile);\n\n\t// Now we can test for check, and modify the state of the gamefile if it is.\n\tcreateCheckState(gamefile, move);\n\tif (gamefile.boardsim.state.local.inCheck) move.flags.check = true;\n\t// The \"mate\" property of the move will be added after our game conclusion checks...\n}\n\n/**\n * Applies a move's board changes to the gamefile, and updates moveIndex.\n * No graphical changes.\n * @param gamefile\n * @param move\n * @param forward - Whether the move's board changes should be applied forward or backward.\n * @param [options.global] - If true, we will also apply this move's global state changes to the gamefile\n */\nfunction applyMove(\n\tgamefile: FullGame,\n\tmove: MoveFull,\n\tforward = true,\n\t{ global = false } = {},\n): void {\n\tgamefile.boardsim.state.local.moveIndex += forward ? 1 : -1; // Update the gamefile moveIndex\n\n\t// Stops stupid missing piece errors\n\tconst indexToApply = gamefile.boardsim.state.local.moveIndex + Number(!forward);\n\tif (indexToApply !== move.generateIndex)\n\t\tthrow new Error(\n\t\t\t`Move was expected at index ${move.generateIndex} but applied at ${indexToApply} (forward: ${forward}).`,\n\t\t);\n\n\tapplyEdit(gamefile, move, forward, global); // Apply the board changes\n}\n\n/**\n * Applies a edits board changes to the gamefile.\n * If we're applying a board editor's move's edits, then global should be true.\n * @param gamefile - The gamefile to apply the edit to.\n * @param edit - The edit to apply, which contains the changes and state of the move.\n * @param global - If true, we will also apply this move's global state changes to the gamefile. Should be true if the edit is from a board editor move.\n * @param forward - Whether the move's board changes should be applied forward or backward.\n */\nfunction applyEdit(gamefile: FullGame, edit: Edit, forward: boolean, global: boolean): void {\n\tstate.applyMove(gamefile.boardsim.state, edit.state, forward, { globalChange: global }); // Apply the State of the move\n\tboardchanges.runChanges(gamefile, edit.changes, boardchanges.changeFuncs, forward); // Logical board changes\n}\n\n/**\n * Updates the `whosTurn` property of the gamefile, according to the move index we're on.\n */\nfunction updateTurn(gamefile: FullGame): void {\n\tgamefile.basegame.whosTurn = moveutil.getWhosTurnAtMoveIndex(\n\t\tgamefile.basegame,\n\t\tgamefile.boardsim.state.local.moveIndex,\n\t);\n}\n\n/**\n * Tests if the gamefile is currently in check,\n * then creates and set's the game state to reflect that.\n */\nfunction createCheckState(gamefile: FullGame, move: MoveFull): void {\n\tconst { boardsim, basegame } = gamefile;\n\tconst whosTurnItWasAtMoveIndex = moveutil.getWhosTurnAtMoveIndex(\n\t\tbasegame,\n\t\tboardsim.state.local.moveIndex,\n\t);\n\tconst oppositeColor = typeutil.invertPlayer(whosTurnItWasAtMoveIndex)!;\n\t// Only track checks if we're using checkmate win condition.\n\tconst trackChecks = basegame.gameRules.winConditions[oppositeColor]!.includes('checkmate');\n\n\tconst checkResults = checkdetection.detectCheck(\n\t\tgamefile,\n\t\twhosTurnItWasAtMoveIndex,\n\t\ttrackChecks,\n\t); // { check: boolean, royalsInCheck: Coords[], checks?: CheckInfo[] }\n\tconst futureInCheck = checkResults.check === false ? false : checkResults.royalsInCheck;\n\t// Passing in the gamefile into this method tells state.ts to immediately apply the state change.\n\tstate.createCheckState(move, boardsim.state.local.inCheck, futureInCheck, boardsim.state); // Passes in the gamefile as an argument\n\tstate.createChecksState(\n\t\tmove,\n\t\tboardsim.state.local.checks,\n\t\tcheckResults.checks ?? [],\n\t\tboardsim.state,\n\t); // Erase the check pairs calculated from previous turn and pass in new ones\n}\n\n/**\n * Accepts a move list in the most comapact form: `['1,2>3,4','10,7>10,8Q']`,\n * reconstructs each move's properties, INCLUDING special tags, and makes that move\n * in the game. At each step it has to calculate what legal special\n * moves are possible, so it can pass on those flags.\n *\n * **THROWS AN ERROR** if any move during the process is in an invalid format.\n * @param gamefile - The gamefile\n * @param moves - The list of moves to add to the game, each in the most compact format: `['1,2>3,4','10,7>10,8Q']`\n * @param validateMoves - If true, throws an error if any move is illegal.\n */\nfunction makeAllMovesInGame(\n\tgamefile: FullGame,\n\tmoves: MovePacket[],\n\tvalidateMoves?: boolean,\n): void {\n\tif (gamefile.boardsim.moves.length > 0)\n\t\tthrow Error('Cannot make all moves in game when there are already moves played.');\n\n\tfor (let i = 0; i < moves.length; i++) {\n\t\tconst shortmove = moves[i]!;\n\n\t\t// If validateMoves flag is true, check if the move is actually legal!\n\t\tif (validateMoves) {\n\t\t\tconst validationResult = movevalidation.isTokenMoveLegal(gamefile, shortmove.token);\n\t\t\tif (!validationResult.valid) {\n\t\t\t\tthrow Error(\n\t\t\t\t\t`Move ${i + 1} is illegal: ${shortmove.token}. Reason: ${validationResult.reason}`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tconst move: MoveFull = calculateMoveFromPacket(gamefile, shortmove);\n\t\tmakeMove(gamefile, move);\n\n\t\t// Also if validateMoves flag is true, any move that comes AFTER\n\t\t// when the game should have ended already is considered illegal!\n\t\tconst isLastIteration = i === moves.length - 1;\n\t\tif (validateMoves && !isLastIteration) {\n\t\t\tconst conclusion = wincondition.getGameConclusion(gamefile);\n\t\t\tif (conclusion)\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Moves cannot come after game ends. Move ${i + 1} should have concluded game by ${JSON.stringify(conclusion)}.`,\n\t\t\t\t);\n\t\t}\n\t}\n}\n\n/**\n * Accepts a move in the most compact short form, and constructs the whole MoveFull object.\n * This has to calculate the piece's legal special\n * moves to be able to deduce if the move was a special move.\n *\n * **Returns undefined** if there was an error anywhere in the conversion.\n *\n * This does NOT perform legality checks, so still do that afterward.\n */\nfunction calculateMoveFromPacket(gamefile: FullGame, movePacket: MovePacket): MoveFull {\n\tif (!moveutil.areWeViewingLatestMove(gamefile.boardsim))\n\t\tthrow Error(\n\t\t\t\"Cannot calculate MoveFull object from shortmove when we're not viewing the most recently played move.\",\n\t\t);\n\n\t// Reconstruct the startCoords, endCoords, and special move properties of the MoveTagged\n\n\tlet moveTagged: MoveTagged;\n\ttry {\n\t\tmoveTagged = icnconverter.parseTokenMove(movePacket.token);\n\t} catch (error) {\n\t\tconsole.error(error);\n\t\tthrow Error(\n\t\t\t`Failed to calculate Move from shortmove because it's in an incorrect format: ${movePacket.token}`,\n\t\t);\n\t}\n\n\t// Reconstruct the special move properties by calculating what legal\n\t// special moves this piece can make, comparing them to the move's endCoords,\n\t// and if there's a match, pass on the special move flag.\n\n\tconst piece = boardutil.getPieceFromCoords(gamefile.boardsim.pieces, moveTagged.startCoords);\n\tif (!piece) {\n\t\t// No piece on start coordinates, can't calculate Move, because it's illegal\n\t\tthrow Error(\n\t\t\t`Failed to calculate Move from shortmove because there's no piece on the start coords: ${movePacket.token}`,\n\t\t);\n\t}\n\n\tconst moveset = legalmoves.getPieceMoveset(gamefile.boardsim, piece.type);\n\tconst legalSpecialMoves = legalmoves.getEmptyLegalMoves(moveset);\n\tlegalmoves.appendSpecialMoves(gamefile, piece, moveset, legalSpecialMoves, false);\n\tfor (const thisCoord of legalSpecialMoves.individual) {\n\t\tif (!coordutil.areCoordsEqual(thisCoord, moveTagged.endCoords)) continue;\n\t\t// Matched coordinates! Transfer any special move tags\n\t\tspecialdetect.transferSpecialTags_FromCoordsToMove(thisCoord, moveTagged);\n\t\tbreak;\n\t}\n\n\tconst move = generateMove(gamefile, moveTagged);\n\tif (movePacket.clockStamp !== undefined) move.clockStamp = movePacket.clockStamp;\n\treturn move;\n}\n\n// Rewinding -------------------------------------------------------------------------------------------------------\n\n/**\n * Executes all the logical board changes of a global REWIND move in the game, no graphical changes.\n */\nfunction rewindMove(gamefile: FullGame): void {\n\t// console.error(\"Rewinding move\");\n\tconst move = moveutil.getMoveFromIndex(\n\t\tgamefile.boardsim.moves,\n\t\tgamefile.boardsim.state.local.moveIndex,\n\t);\n\n\tapplyMove(gamefile, move, false, { global: true });\n\n\t// Delete the move off the end of our moves list\n\tgamefile.boardsim.moves.pop();\n\tgamefile.basegame.moves.pop();\n\n\tupdateTurn(gamefile);\n}\n\n// Dynamic -------------------------------------------------------------------------------------------------------\n\n/**\n * Iterates to a certain move index, performing a callback function on each move.\n * The callback should be a move application function, either {@link applyMove}, or movesequence.viewMove(),\n * depending on if each move should make graphical changes or not. Both methods make logical board changes.\n * @param {gamefile} gamefile\n * @param {number} index\n * @param {CallableFunction} callback - Either {@link applyMove}, or movesequence.viewMove()\n */\nfunction goToMove(boardsim: Board, index: number, callback: (_move: MoveFull) => void): void {\n\tif (index === boardsim.state.local.moveIndex) return;\n\n\tconst forwards = index >= boardsim.state.local.moveIndex;\n\tconst offset = forwards ? 0 : 1;\n\tlet i = boardsim.state.local.moveIndex;\n\n\tif (boardsim.moves.length <= index + offset || index + offset < 0)\n\t\tthrow Error('Target index is outside of the movelist!');\n\n\twhile (i !== index) {\n\t\ti = moveTowards(i, index, 1);\n\t\tconst move = boardsim.moves[i + offset];\n\t\tif (move === undefined) throw Error(`Undefined move in goToMove()! ${i}, ${index}`);\n\t\tcallback(move);\n\t}\n}\n\n/**\n * Starts with `s`, steps it by +-`progress` towards `e`, then returns that number.\n */\nfunction moveTowards(s: number, e: number, progress: number): number {\n\treturn s + Math.sign(e - s) * Math.min(Math.abs(e - s), progress);\n}\n\n// Move Wrappers ----------------------------------------------------------------------------------------------------\n\n/**\n * Wraps a function in a simulated move.\n * The callback may be used to obtain whatever\n * property of the gamefile we want after the move is made.\n * The move is automatically rewound when it's done.\n * @returns Whatever is returned by the callback\n */\nfunction simulateMoveWrapper<R>(gamefile: FullGame, moveTagged: MoveTagged, callback: () => R): R {\n\tgenerateAndMakeMove(gamefile, moveTagged);\n\t// What info can we pull from the game after simulating this move?\n\tconst info = callback();\n\trewindMove(gamefile);\n\treturn info;\n}\n\n/**\n * Simulates a move to get the gameConclusion\n * @returns the gameConclusion\n */\nfunction getSimulatedConclusion(\n\tgamefile: FullGame,\n\tmoveTagged: MoveTagged,\n): GameConclusion | undefined {\n\treturn simulateMoveWrapper(gamefile, moveTagged, () =>\n\t\twincondition.getGameConclusion(gamefile),\n\t);\n}\n\n// ---------------------------------------------------------------------------------------------------------------------\n\nexport type { MoveFull, Edit, MoveRecord, MoveTagged, SpecialTags, MoveSpecialTags, CoordsTagged };\n\nexport default {\n\t// Constants\n\tMOVE_SPECIAL_TAGS,\n\tSPECIAL_TAGS,\n\t// Functions\n\tgenerateMove,\n\tcalcMovesChanges,\n\tqueueSpecialRightDeletionStateChanges,\n\thasCastlingPartner,\n\tmakeMove,\n\tgenerateAndMakeMove,\n\tupdateTurn,\n\tgoToMove,\n\tmakeAllMovesInGame,\n\tapplyMove,\n\tapplyEdit,\n\trewindMove,\n\tsimulateMoveWrapper,\n\tgetSimulatedConclusion,\n};\n"
  },
  {
    "path": "src/shared/chess/logic/movesets.ts",
    "content": "// src/shared/chess/logic/movesets.ts\n\n/**\n * This script contains the default movesets for all pieces except specials (pawns, castling)\n */\n\nimport type { Piece } from '../util/boardutil.js';\nimport type { Coords } from '../util/coordutil.js';\nimport type { FullGame } from './gamefile.js';\nimport type { CoordsTagged } from './movepiece.js';\nimport type { Vec2, Vec2Key } from '../../util/math/vectors.js';\nimport type { RawTypeGroup, Player, RawType } from '../util/typeutil.js';\n\nimport bimath from '../../util/math/bimath.js';\nimport vectors from '../../util/math/vectors.js';\nimport legalmoves from './legalmoves.js';\nimport specialdetect from './specialdetect.js';\nimport { primalityTest } from '../../util/isprime.js';\nimport { rawTypes as r } from '../util/typeutil.js';\n\n/** A Movesets object containing the movesets for every piece type in a game */\ntype Movesets = RawTypeGroup<PieceMoveset>;\n\n/** {@link Movesets} but without the auto-generated colinear properties. */\ntype RawMovesets = RawTypeGroup<RawPieceMoveset>;\n\n/** {@link PieceMoveset} but without the auto-generated colinear property. */\ninterface RawPieceMoveset {\n\t/**\n\t * Jumping moves immediately surrounding the piece where it can move to.\n\t *\n\t * TODO: Separate moving-moves from capturing-moves.\n\t */\n\tindividual?: Coords[];\n\t/**\n\t * Sliding moves the piece can make.\n\t *\n\t * `\"1,0\": [null,null]` => Lets the piece slide horizontally infinitely in both directions.\n\t *\n\t * The *key* is the step amount of each skip, and the *value* is the skip limit in the -x and +x directions (-y and +y if it's vertical).\n\t *\n\t * THE X-KEY SHOULD NEVER BE NEGATIVE!!! And if it's 0, then Y should be positive.\n\t */\n\tsliding?: SlidingMoves;\n\t/**\n\t * The initial function that determines how far a piece is legally able to slide\n\t * according to what pieces block it.\n\t *\n\t * This should be provided if we're not using the default.\n\t */\n\tblocking?: BlockingFunction;\n\t/**\n\t * The secondary function that *actually* determines whether each individual\n\t * square in a slide is legal to move to.\n\t *\n\t * This should be provided if we're not using the default.\n\t */\n\tignore?: IgnoreFunction;\n\t/**\n\t * If present, the function to call for calculating legal special moves.\n\t */\n\tspecial?: SpecialFunction;\n}\n\n/** A moveset for an single piece type in a game */\ninterface PieceMoveset extends RawPieceMoveset {\n\t/** Whether this moveset involves colinear sliding moves. Auto-generated property. */\n\tcolinear: boolean;\n}\n\n/**\n * Sliding moves the piece can make.\n *\n * `\"1,0\": [-5,null]` => Lets the piece slide 5 squares in the negative vector direction, or infinitely in the positive.\n *\n * The *key* is the step amount of each skip, and the *value* is the skip limit in the -x and +x directions (-y and +y if it's vertical).\n *\n * THE 0-INDEX KEY SHOULD ALWAYS BE NEGATIVE!!!\n */\ntype SlidingMoves = {\n\t[slideDirection: Vec2Key]: [bigint | null, bigint | null];\n};\n\n/**\n * This runs once for every square you can slide to that's visible on the screen.\n * It returns true if the square is legal to move to, false otherwise.\n *\n * If no ignore function is specified, the default ignore function that every piece\n * has by default always returns *true*.\n *\n * The start and end coords arguments are useful for the Huygen, as it can\n * calculate the distance traveled, and then test if it's prime.\n *\n * The gamefile and detectCheck method may be used for the Royal Queen,\n * as it can test if the squares are check for positive.\n */\ntype IgnoreFunction = (_startCoords: Coords, _endCoords: Coords) => boolean;\n\n/**\n * This runs once for every piece on the same line of the selected piece.\n *\n * 0 => Piece doesn't block\n * 1 => Blocked ON the square (enemy piece)\n * 2 => Blocked 1 before the square (friendly piece or void)\n *\n * The return value of 0 will be useful in the future for allowing pieces\n * to *phase* through other pieces.\n * An example of this would be the \"witch\", which makes all adjacent friendly\n * pieces \"transparent\", allowing friendly pieces to phase through them.\n */\ntype BlockingFunction = (\n\t_friendlyColor: Player,\n\t_blockingPiece: Piece,\n\t_coords: Coords,\n\t_premove: boolean,\n) => 0 | 1 | 2;\n/**\n * A function that returns an array of any legal special individual moves for the piece,\n * each of the coords will have a special property attached to it. castle/promote/enpassant\n */\ntype SpecialFunction = (\n\t_gamefile: FullGame,\n\t_coords: Coords,\n\t_color: Player,\n\t_premove: boolean,\n) => CoordsTagged[];\n\n// /** The direction a given player color is facing (which way their pawns move). */\n// type PlayerFacingDirection = {\n// \t/** 1 -> Pawns move vertically. 0 -> Pawns move horizontally. */\n// \taxis: 0 | 1;\n// \tparity: 1n | -1n;\n// };\n\n/** The default blocking function of each piece's sliding moves, if not specified. */\nfunction defaultBlockingFunction(\n\tfriendlyColor: Player,\n\tblockingPiece: Piece,\n\tcoords: Coords,\n\tpremove: boolean,\n): 0 | 1 | 2 {\n\treturn legalmoves.testCaptureValidity(friendlyColor, blockingPiece.type, premove);\n}\n\n/** The default ignore function of each piece's sliding moves, if not specified. */\nfunction defaultIgnoreFunction(): boolean {\n\treturn true; // Square allowed\n}\n\n/**\n * Generates all orthogonal/diagonal moves on the perimeter of a square with a given radius (king, hawk).\n */\nfunction generateCompassMoves(distance: bigint): Coords[] {\n\t// prettier-ignore\n\treturn [\n\t\t[-distance, distance], [0n, distance], [distance, distance],\n\t\t[-distance, 0n], /*[0n,0n],*/ [distance, 0n],\n\t\t[-distance, -distance], [0n, -distance], [distance, -distance]\n\t];\n}\n\n/**\n * Generates the 8 moves for an (m,n) leaper piece (knight, camel, zebra, giraffe).\n * It creates all permutations of (±m, ±n) and (±n, ±m).\n */\nfunction generateLeaperMoves(m: bigint, n: bigint): Coords[] {\n\t// prettier-ignore\n\treturn [\n\t\t// Positive second coordinate (\"up\" on a board)\n\t\t[-n, m], [-m, n], [m, n], [n, m],\n\t\t// Negative second coordinate (\"down\" on a board)\n\t\t[-n, -m], [-m, -n], [m, -n], [n, -m],\n\t];\n}\n\n/**\n * Returns the movesets of all the pieces, modified according to the specified slideLimit gamerule.\n *\n * These movesets are called as functions so that they return brand\n * new copies of each moveset so there's no risk of accidentally modifying the originals.\n * @param [slideLimit] Optional. The slideLimit gamerule value.\n * @returns Object containing the movesets of all pieces except pawns.\n */\nfunction getPieceDefaultMovesets(slideLimit: bigint | null = null): Movesets {\n\tif (typeof slideLimit !== 'bigint' && slideLimit !== null)\n\t\tthrow new Error('slideLimit gamerule is in an unsupported value.');\n\n\t// Slide limits of all pieces. Negative the first index.\n\tconst slideLimits: [bigint | null, bigint | null] = [\n\t\tslideLimit === null ? null : -slideLimit,\n\t\tslideLimit,\n\t];\n\n\t// Define common movesets to reduce duplication\n\tconst kingMoves: Coords[] = generateCompassMoves(1n);\n\tconst knightMoves = generateLeaperMoves(1n, 2n);\n\tconst rookMoves: SlidingMoves = {\n\t\t'1,0': slideLimits,\n\t\t'0,1': slideLimits,\n\t};\n\tconst bishopMoves: SlidingMoves = {\n\t\t'1,1': slideLimits,\n\t\t'1,-1': slideLimits,\n\t};\n\n\tconst rawMovesets: RawMovesets = {\n\t\t// Finitely moving\n\t\t[r.PAWN]: {\n\t\t\tspecial: specialdetect.pawns,\n\t\t},\n\t\t[r.KNIGHT]: {\n\t\t\tindividual: knightMoves,\n\t\t},\n\t\t[r.HAWK]: {\n\t\t\tindividual: [...generateCompassMoves(2n), ...generateCompassMoves(3n)],\n\t\t},\n\t\t[r.KING]: {\n\t\t\tindividual: kingMoves,\n\t\t\tspecial: specialdetect.kings,\n\t\t},\n\t\t[r.GUARD]: {\n\t\t\tindividual: kingMoves,\n\t\t},\n\t\t// Infinitely moving\n\t\t[r.ROOK]: {\n\t\t\tsliding: rookMoves,\n\t\t},\n\t\t[r.BISHOP]: {\n\t\t\tsliding: bishopMoves,\n\t\t},\n\t\t[r.QUEEN]: {\n\t\t\tsliding: {\n\t\t\t\t...rookMoves,\n\t\t\t\t...bishopMoves,\n\t\t\t},\n\t\t},\n\t\t[r.ROYALQUEEN]: {\n\t\t\tsliding: {\n\t\t\t\t...rookMoves,\n\t\t\t\t...bishopMoves,\n\t\t\t},\n\t\t},\n\t\t[r.CHANCELLOR]: {\n\t\t\tindividual: knightMoves,\n\t\t\tsliding: rookMoves,\n\t\t},\n\t\t[r.ARCHBISHOP]: {\n\t\t\tindividual: knightMoves,\n\t\t\tsliding: bishopMoves,\n\t\t},\n\t\t[r.AMAZON]: {\n\t\t\tindividual: knightMoves,\n\t\t\tsliding: {\n\t\t\t\t...rookMoves,\n\t\t\t\t...bishopMoves,\n\t\t\t},\n\t\t},\n\t\t[r.CAMEL]: {\n\t\t\tindividual: generateLeaperMoves(1n, 3n),\n\t\t},\n\t\t[r.GIRAFFE]: {\n\t\t\tindividual: generateLeaperMoves(1n, 4n),\n\t\t},\n\t\t[r.ZEBRA]: {\n\t\t\tindividual: generateLeaperMoves(2n, 3n),\n\t\t},\n\t\t[r.KNIGHTRIDER]: {\n\t\t\tsliding: {\n\t\t\t\t'1,2': slideLimits,\n\t\t\t\t'1,-2': slideLimits,\n\t\t\t\t'2,1': slideLimits,\n\t\t\t\t'2,-1': slideLimits,\n\t\t\t},\n\t\t},\n\t\t[r.CENTAUR]: {\n\t\t\tindividual: [...kingMoves, ...knightMoves],\n\t\t},\n\t\t[r.ROYALCENTAUR]: {\n\t\t\tindividual: [...kingMoves, ...knightMoves],\n\t\t\tspecial: specialdetect.kings,\n\t\t},\n\t\t[r.HUYGEN]: {\n\t\t\tsliding: rookMoves,\n\t\t\tblocking: (\n\t\t\t\tfriendlyColor: Player,\n\t\t\t\tblockingPiece: Piece,\n\t\t\t\tcoords: Coords,\n\t\t\t\tpremove: boolean,\n\t\t\t): 0 | 1 | 2 => {\n\t\t\t\tconst distance = vectors.chebyshevDistance(coords, blockingPiece.coords);\n\t\t\t\tconst isPrime = primalityTest(distance);\n\t\t\t\tif (!isPrime) return 0; // Doesn't block, not even if it's a void. It hops over it!\n\t\t\t\treturn legalmoves.testCaptureValidity(friendlyColor, blockingPiece.type, premove);\n\t\t\t},\n\t\t\tignore: (startCoords: Coords, endCoords: Coords): boolean => {\n\t\t\t\tconst distance = vectors.chebyshevDistance(startCoords, endCoords);\n\t\t\t\tconst isPrime = primalityTest(distance);\n\t\t\t\treturn isPrime;\n\t\t\t},\n\t\t},\n\t\t[r.ROSE]: {\n\t\t\tspecial: specialdetect.roses,\n\t\t},\n\t};\n\n\treturn convertRawMovesetsToPieceMovesets(rawMovesets);\n}\n\n/**\n * Calculates all possible slides that should be possible in the provided game,\n * based on the provided movesets.\n * @param pieceMovesets - MUST BE TRIMMED beforehand to not include movesets of types not present in the game!!!!!\n */\nfunction getPossibleSlides(pieceMovesets: RawTypeGroup<() => PieceMoveset>): Vec2[] {\n\tconst slides = new Set<Vec2Key>(['1,0']); // '1,0' is required if castling is enabled.\n\tfor (const rawtype in pieceMovesets) {\n\t\tconst moveset = pieceMovesets[Number(rawtype) as RawType]!();\n\t\tif (!moveset.sliding) continue;\n\t\tObject.keys(moveset.sliding).forEach((slide) => slides.add(slide as Vec2Key));\n\t}\n\treturn Array.from(slides, vectors.getVec2FromKey);\n}\n\n/** Converts raw movesets into final piece movesets by auto adding the colinear property. */\nfunction convertRawMovesetsToPieceMovesets(pieceMovesets: RawTypeGroup<RawPieceMoveset>): Movesets {\n\t// Now, auto add in the colinear property to each piece moveset\n\tconst finalMovesets: Movesets = {};\n\tfor (const [rawtype, moveset] of Object.entries(pieceMovesets)) {\n\t\tfinalMovesets[Number(rawtype) as RawType] = {\n\t\t\t...moveset,\n\t\t\tcolinear: isMovesetColinear(moveset),\n\t\t};\n\t}\n\treturn finalMovesets;\n}\n\n/** Tests whether the provided moveset involves colinear sliding moves. */\nfunction isMovesetColinear(moveset: RawPieceMoveset): boolean {\n\t/**\n\t * Colinears are present if an ignore/blocking function override is present (which can simulate non-primitive vectors).\n\t * We cannot predict if the piece will not cause colinears.\n\t * A custom blocking function may trigger crazy checkmate colinear shenanigans because it can allow opponent pieces to phase through your pieces, so pinning works differently.\n\t */\n\tif (moveset.blocking || moveset.ignore) return true; // This type has a custom ignore/blocking function being used (colinears may be present).\n\n\t/**\n\t * Colinears are present if any vector is NOT a primitive vector.\n\t * This is because if a vector is not primitive, multiple simpler vectors can be combined to make it.\n\t * For example, [2,0] can be made by combining [1,0] and [1,0].\n\t * In a real game, you could have two [2,0] sliders, offset by 1 tile, and their lines would be colinear, yet not intersecting.\n\t * A vector is considered primitive if the greatest common divisor (GCD) of its components is 1.\n\t */\n\tif (moveset.sliding) {\n\t\tconst slides: Vec2[] = (Object.keys(moveset.sliding) as Vec2Key[]).map((s) =>\n\t\t\tvectors.getVec2FromKey(s),\n\t\t);\n\t\tif (slides.some((s) => isVectorColinear(s))) return true; // Colinear\n\t}\n\n\treturn false;\n}\n\n/** Tests whether the provided slide vector is colinear (not a primitive vector). */\nfunction isVectorColinear(vector: Vec2): boolean {\n\treturn bimath.GCD(vector[0], vector[1]) !== 1n;\n}\n\n// /**\n//  * Returns the normalized vector direction a given player's pawns travel.\n//  * `axis` = 0 -> pawn moves horizontal. `axis` = 1 -> pawn moves vertical.\n//  *\n//  * @throws If player neutral is passed\n//  */\n// function determinePlayerFacingDirection(player: Player): PlayerFacingDirection {\n// \tif (player === p.WHITE) return { axis: 1, parity: 1n };\n// \telse if (player === p.BLACK) return { axis: 1, parity: -1n };\n// \t// 4 Player colors\n// \telse if (player === p.RED) return { axis: 1, parity: 1n };\n// \telse if (player === p.BLUE) return { axis: 0, parity: 1n };\n// \telse if (player === p.YELLOW) return { axis: 1, parity: -1n };\n// \telse if (player === p.GREEN) return { axis: 0, parity: -1n };\n// \telse throw Error(`Cannot determine player facing direction of player ${player}!`);\n// }\n\nexport default {\n\tdefaultBlockingFunction,\n\tdefaultIgnoreFunction,\n\tgetPieceDefaultMovesets,\n\tgetPossibleSlides,\n\tconvertRawMovesetsToPieceMovesets,\n\tisVectorColinear,\n\t// determinePlayerFacingDirection,\n};\n\nexport type { Movesets, RawMovesets, PieceMoveset, BlockingFunction, IgnoreFunction };\n"
  },
  {
    "path": "src/shared/chess/logic/movevalidation.ts",
    "content": "// src/shared/chess/logic/movevalidation.ts\n\nimport type { FullGame } from './gamefile.js';\nimport type { GameConclusion } from '../util/winconutil.js';\n\nimport jsutil from '../../util/jsutil.js';\nimport winconutil from '../util/winconutil.js';\nimport legalmoves from './legalmoves.js';\nimport checkresolver from './checkresolver.js';\nimport specialdetect from './specialdetect.js';\nimport boardutil, { Piece } from '../util/boardutil.js';\nimport icnconverter, { MoveCoords } from './icn/icnconverter.js';\nimport movepiece, { CoordsTagged, MoveTagged } from './movepiece.js';\nimport typeutil, { Player, RawType, rawTypes as r } from '../util/typeutil.js';\n\n// Types -----------------------------------------------------------------------\n\nexport type MoveValidationResult =\n\t| {\n\t\t\tvalid: true;\n\t\t\t/** The move draft with any special tags attached, derived from its end coords. */\n\t\t\ttagged: MoveTagged;\n\t  }\n\t| {\n\t\t\tvalid: false;\n\t\t\t/** The reason the move is illegal. */\n\t\t\treason: string;\n\t  };\ntype ConclusionValidityResult = { valid: true } | { valid: false; reason: string };\n\n// Functions -------------------------------------------------------------------\n\n/**\n * UTILITY: Runs a specific validation action while the game is temporarily\n * fast-forwarded to the latest move. Afterwards restoring the game to its original state.\n * @param gamefile - The gamefile\n * @param action - The action to run while at the front of the game\n * @returns The result of the action\n */\nfunction runActionAtGameFront<T>(gamefile: FullGame, action: () => T): T {\n\tconst { boardsim } = gamefile;\n\tconst originalMoveIndex = boardsim.state.local.moveIndex;\n\n\t// Fast Forward to the latest move (graphical updates skipped since we will return afterwards)\n\tmovepiece.goToMove(boardsim, boardsim.moves.length - 1, (move) =>\n\t\tmovepiece.applyMove(gamefile, move, true),\n\t);\n\n\t// Run the specific logic (move validation, conclusion check, etc)\n\tconst result = action();\n\n\t// Rewind to original state\n\tmovepiece.goToMove(boardsim, originalMoveIndex, (move) =>\n\t\tmovepiece.applyMove(gamefile, move, false),\n\t);\n\n\treturn result;\n}\n\n/**\n * Tests if the provided move is legal to play in this game,\n * including whether the claimed game conclusion is correct.\n * Also attaches and special move tags to the move coords.\n * @param gamefile - The gamefile\n * @param moveCoords - The move. Special move tags will be attached to them if it is legal.\n * @param claimedGameConclusion - The opponent's claimed game conclusion\n * @returns An object containing either:\n * - `valid: true` and the `draft` of the move with any special tags attached.\n * - `valid: false` and a `reason` string explaining why it is illegal.\n */\nfunction isOpponentsMoveLegal(\n\tgamefile: FullGame,\n\tmoveCoords: MoveCoords,\n\tclaimedGameConclusion: GameConclusion | undefined,\n): MoveValidationResult {\n\t// We run both move and conclusion checks when at the front of the game\n\treturn runActionAtGameFront(gamefile, () => {\n\t\t// 1. Check Move Legality\n\t\tconst moveResult = validateMove(gamefile, moveCoords);\n\t\tif (!moveResult.valid) return moveResult;\n\n\t\t// 2. Check Conclusion Validity (using the draft with special tags attached)\n\t\tconst conclusionResult = validateConclusion(\n\t\t\tgamefile,\n\t\t\tmoveResult.tagged,\n\t\t\tclaimedGameConclusion,\n\t\t);\n\n\t\tif (!conclusionResult.valid) return conclusionResult;\n\n\t\t// At this stage, both move and conclusion are valid!\n\t\treturn moveResult;\n\t});\n}\n\n/**\n * Tests if the provided compact move string is legal to play.\n * @param gamefile - The gamefile\n * @param tokenMove - The move that SHOULD be in compact string format (e.g. \"x,y>x,y=Q\"), but we can't trust all enginess response contents.\n * @returns An object containing either:\n * - `valid: true` and the `draft` of the move with any special tags attached.\n * - `valid: false` and a `reason` string explaining why it is illegal.\n */\nfunction isTokenMoveLegal(gamefile: FullGame, tokenMove: unknown): MoveValidationResult {\n\tif (typeof tokenMove !== 'string') return { valid: false, reason: 'Not a string.' };\n\n\t// Convert the move from compact short format \"x,y>x,y=N\" to JSON format\n\tlet moveCoords: MoveCoords;\n\ttry {\n\t\tmoveCoords = icnconverter.parseTokenMove(tokenMove);\n\t} catch (error: unknown) {\n\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\tconsole.error(`Invalid format error when parsing compact move \"${tokenMove}\": ${msg}`);\n\t\t// Return generic invalid reason\n\t\treturn { valid: false, reason: 'Incorrect format.' };\n\t}\n\n\treturn runActionAtGameFront(gamefile, () => {\n\t\treturn validateMove(gamefile, moveCoords);\n\t});\n}\n\n/**\n * CORE LOGIC: Checks validity of a move.\n * REQUIRES you to be viewing the head of the game.\n * Also attaches and special move tags to the move coords.\n * @param gamefile - The gamefile\n * @param moveCoords - The move to validate. Special move tags will be attached to them if it is legal.\n * @returns An object containing either:\n * - `valid: true` and the `draft` of the move with any special tags attached.\n * - `valid: false` and a `reason` string explaining why it is illegal.\n */\nfunction validateMove(gamefile: FullGame, moveCoords: MoveCoords): MoveValidationResult {\n\tconst { boardsim, basegame } = gamefile;\n\n\tconst piecemoved: Piece | undefined = boardutil.getPieceFromCoords(\n\t\tboardsim.pieces,\n\t\tmoveCoords.startCoords,\n\t);\n\n\t// Make sure a piece exists on the start coords\n\tif (!piecemoved) return { valid: false, reason: 'No piece at start coords.' };\n\n\t// Make sure it matches the color of whos turn it is.\n\tconst colorOfPieceMoved: Player = typeutil.getColorFromType(piecemoved.type);\n\tif (colorOfPieceMoved !== basegame.whosTurn)\n\t\treturn { valid: false, reason: 'Incorrect color.' };\n\n\tconst rawTypeMoved = typeutil.getRawType(piecemoved.type);\n\n\tpromotion: if (moveCoords.promotion !== undefined) {\n\t\t// User IS promoting\n\t\tif (!basegame.gameRules.promotionRanks)\n\t\t\treturn { valid: false, reason: 'Game has no promotion ranks.' };\n\t\tif (rawTypeMoved !== r.PAWN) return { valid: false, reason: \"Can't promote non-pawn.\" };\n\n\t\tconst promotionRanks: bigint[] | undefined =\n\t\t\tbasegame.gameRules.promotionRanks[colorOfPieceMoved];\n\t\tif (!promotionRanks) return { valid: false, reason: 'Color has no promotion ranks.' };\n\n\t\tif (!promotionRanks.includes(moveCoords.endCoords[1]))\n\t\t\treturn { valid: false, reason: 'No promotion rank at end coords.' };\n\n\t\tconst colorPromotedTo: Player = typeutil.getColorFromType(moveCoords.promotion);\n\t\tif (basegame.whosTurn !== colorPromotedTo)\n\t\t\treturn { valid: false, reason: 'Incorrect promotion color.' };\n\n\t\tif (!basegame.gameRules.promotionsAllowed)\n\t\t\treturn { valid: false, reason: 'Game has no promotions allowed.' };\n\n\t\tconst promotionsAllowed: RawType[] | undefined =\n\t\t\tbasegame.gameRules.promotionsAllowed[colorOfPieceMoved];\n\t\tif (!promotionsAllowed) return { valid: false, reason: 'Color has no promotions allowed.' };\n\n\t\tconst rawPromotion: RawType = typeutil.getRawType(moveCoords.promotion);\n\t\tif (!promotionsAllowed.includes(rawPromotion))\n\t\t\treturn { valid: false, reason: 'Illegal promotion type.' };\n\t} else {\n\t\t// User is NOT promoting\n\t\t// Make sure they aren't moving to a promotion rank WITHOUT promoting! That's also illegal.\n\t\tif (!basegame.gameRules.promotionRanks) break promotion; // This game doesn't have promotion.\n\n\t\tif (rawTypeMoved !== r.PAWN) break promotion; // Not a pawn, not forced to promote.\n\n\t\tconst promotionRanks: bigint[] | undefined =\n\t\t\tbasegame.gameRules.promotionRanks[colorOfPieceMoved];\n\t\tif (!promotionRanks) break promotion; // This color doesn't have promotion ranks, not forced to promote.\n\n\t\tif (!promotionRanks.includes(moveCoords.endCoords[1])) break promotion; // Not on a promotion rank, not forced to promote.\n\n\t\t// If we are here: They moved a pawn to a promotion rank but didn't promote.\n\t\treturn { valid: false, reason: 'Did not promote.' };\n\t}\n\n\t// Test if that piece's legal moves contain the destination coords...\n\n\tconst endCoordsToAppendTagsTo: CoordsTagged = jsutil.deepCopyObject(moveCoords.endCoords);\n\n\t// This logic is pulled out of legalmoves.calculateAll(), so we can observe\n\t// it at each step to find the earliest illegality point of the move submission.\n\n\tconst moveset = legalmoves.getPieceMoveset(gamefile.boardsim, piecemoved.type);\n\tconst legalMoves = legalmoves.getEmptyLegalMoves(moveset);\n\tlegalmoves.appendPotentialMoves(piecemoved, moveset, legalMoves);\n\tlegalmoves.removeObstructedMoves(\n\t\tgamefile.boardsim,\n\t\tgamefile.basegame.gameRules.worldBorder,\n\t\tpiecemoved,\n\t\tmoveset,\n\t\tlegalMoves,\n\t\tfalse,\n\t);\n\tlegalmoves.appendSpecialMoves(gamefile, piecemoved, moveset, legalMoves, false);\n\n\t// Check if even the non-check-respecting move is legal first\n\t// This should pass on any special moves tags to endCoordsToAppendSpecialsTo at the same time.\n\tif (\n\t\t!legalmoves.checkIfMoveLegal(\n\t\t\tgamefile,\n\t\t\tlegalMoves,\n\t\t\tpiecemoved.coords,\n\t\t\tendCoordsToAppendTagsTo,\n\t\t\tcolorOfPieceMoved,\n\t\t)\n\t) {\n\t\treturn { valid: false, reason: 'Invalid destination coords.' };\n\t}\n\n\tcheckresolver.removeCheckInvalidMoves(gamefile, piecemoved, legalMoves);\n\n\t// Now check if the check-respecting move is legal\n\tif (\n\t\t!legalmoves.checkIfMoveLegal(\n\t\t\tgamefile,\n\t\t\tlegalMoves,\n\t\t\tpiecemoved.coords,\n\t\t\tendCoordsToAppendTagsTo,\n\t\t\tcolorOfPieceMoved,\n\t\t)\n\t) {\n\t\treturn { valid: false, reason: 'Puts self in check.' };\n\t}\n\n\t// Now transfer the special move tags from the coords to the move draft\n\tspecialdetect.transferSpecialTags_FromCoordsToMove(endCoordsToAppendTagsTo, moveCoords);\n\n\t// If we reach here, the move is valid!\n\treturn { valid: true, tagged: moveCoords };\n}\n\n/**\n * Determines whether the opponent's claimed conclusion matches what we calculate from the position.\n * @param gamefile - The gamefile\n * @param moveTagged - The move draft, WITH special tags attached!\n * @param claimedGameConclusion - The opponent's claimed game conclusion\n * @returns An object containing either:\n * - `valid: true`\n * - `valid: false` and a `reason` string explaining why it is illegal.\n */\nfunction validateConclusion(\n\tgamefile: FullGame,\n\tmoveTagged: MoveTagged,\n\tclaimedGameConclusion: GameConclusion | undefined,\n): ConclusionValidityResult {\n\tif (\n\t\tclaimedGameConclusion !== undefined &&\n\t\t!winconutil.isConclusionMoveTriggered(claimedGameConclusion.condition)\n\t) {\n\t\t// Non-move-triggered (e.g. resignation, time, abort) conclusions are always valid since the server handles those.\n\t\treturn { valid: true };\n\t}\n\n\tconst moveTaggedCopy = jsutil.deepCopyObject(moveTagged);\n\tconst simulatedConclusion = movepiece.getSimulatedConclusion(gamefile, moveTaggedCopy);\n\n\tif (\n\t\tsimulatedConclusion?.condition !== claimedGameConclusion?.condition ||\n\t\tsimulatedConclusion?.victor !== claimedGameConclusion?.victor\n\t) {\n\t\tconsole.error(\n\t\t\t`Conclusion mismatch! Simulated: ${JSON.stringify(simulatedConclusion)}, Claimed: ${JSON.stringify(claimedGameConclusion)}`,\n\t\t);\n\t\treturn { valid: false, reason: 'Wrong conclusion.' };\n\t}\n\n\t// If we reach here, the claimed conclusion is valid!\n\treturn { valid: true };\n}\n\nexport default {\n\tisTokenMoveLegal,\n\tisOpponentsMoveLegal,\n\tvalidateMove,\n};\n"
  },
  {
    "path": "src/shared/chess/logic/organizedpieces.ts",
    "content": "// src/shared/chess/logic/organizedpieces.ts\n\n/**\n * This script generates and manages the organized pieces of a game.\n *\n * The pieces are organized in many different ways to optimize for different accessing methods.\n *\n * Ways to access the pieces:\n * - By index\n * - By coordinate\n * - By line\n */\n\nimport type { PieceMoveset } from './movesets.js';\nimport type { Coords, CoordsKey } from '../util/coordutil.js';\nimport type { Player, PlayerGroup, RawType, TypeGroup, RawTypeGroup } from '../util/typeutil.js';\n\nimport bimath from '../../util/math/bimath.js';\nimport movesets from './movesets.js';\nimport coordutil from '../util/coordutil.js';\nimport vectors, { Vec2, Vec2Key } from '../../util/math/vectors.js';\nimport typeutil, { ext, players as p, rawTypes, neutralRawTypes } from '../util/typeutil.js';\n\n// Types ---------------------------------------------------------------------------\n\n/**\n * An object that stores the pieces on the board in several different organized ways.\n * This way we can quickly access the pieces when we are given different information.\n *\n * - By index\n * - By coordinate\n * - By line\n *\n * Also stores variables for all possible slide lines in the game,\n * and whether there are any hippogonal riders present.\n */\ninterface OrganizedPieces {\n\t/** The X position of all pieces. Undefined pieces are set to 0. */\n\tXPositions: bigint[];\n\t/** The Y position of all pieces. Undefined pieces are set to 0. */\n\tYPositions: bigint[];\n\t/**\n\t * The type of all pieces. Undefined pieces retain the type of the type range they are in.\n\t *\n\t * Uint8Array range: 0-255. There are 22 total types currently, potentially 4 unique players/players in a game ==> 88 posible types.\n\t */\n\ttypes: Uint8Array;\n\t/** Contains start and end indices for where each type of piece begins and ends in the types array. */\n\ttypeRanges: TypeRanges;\n\t/**\n\t * Pieces organized by coordinate\n\t * 'x,y' => idx\n\t */\n\tcoords: Map<CoordsKey, number>;\n\t/**\n\t * Pieces organized by line (rank/file/diagonal)\n\t * Map{ 'dx,dy' => Map { 'yint|xafter0' => [idx, idx, idx...] }}\n\t * dx is never negative. If dx is 0, dy cannot be negative either.\n\t */\n\tlines: Map<Vec2Key, Map<LineKey, number[]>>;\n\t/** All slide directions possible in the game. [1,0] guaranteed for castling to work. */\n\tslides: Vec2[];\n\t/** Whether there are any hippogonal riders in the game (knightriders). */\n\thippogonalsPresent: boolean;\n\t/**\n\t * If this flag is present, it means the pieces have been regenerated\n\t * to add more undefineds to the type ranges.\n\t * movesequence should see this and immediately regenerate the piece models!\n\t */\n\tnewlyRegenerated?: true;\n}\n\n/** Contains start and end indices for where each type of piece begins and ends in the types array. */\ntype TypeRanges = Map<number, TypeRange>;\n\n/** Contains the start and end indices for where a single piece type begins and ends in the types array. */\ninterface TypeRange {\n\t/** Inclusive */\n\tstart: number;\n\t/** Exclusive */\n\tend: number;\n\t/** Each number in this array is the index of the undefined in the large XYPositions arrays. This array is also sorted. */\n\tundefineds: Array<number>;\n}\n\n/** A unique identifier for a single line of pieces. `C|X` */\ntype LineKey = `${bigint}|${bigint}`;\n\n// Constants ---------------------------------------------------------------------------\n\n/** How many extra undefined placeholders each type range should have.\n * When these are all exhausted, the large piece lists must be regenerated. */\nconst listExtras = 10;\n/** EDITOR-MODE-SPECIFIC {@link listExtras} */\nconst listExtras_Editor = 50;\n\n// Main Functions ---------------------------------------------------------------------\n\n/**\n * Takes the source Position for the variant, and constructs the entire\n * organized pieces object, and returns other information inherited from it.\n *\n * Mutates pieceMovesets to remove useless movesets\n */\nfunction processInitialPosition(\n\tposition: Map<CoordsKey, number>,\n\tpieceMovesets: RawTypeGroup<() => PieceMoveset>,\n\tturnOrder: Player[],\n\teditor: boolean,\n\tpromotionsAllowed?: PlayerGroup<RawType[]>,\n): {\n\tpieces: OrganizedPieces;\n\t/**\n\t * All existing types in the game, with their color information.\n\t * This may include pieces not in the starting position,\n\t * such as those that can be promoted to.\n\t */\n\texistingTypes: number[];\n\t/** All raw existing types in the game. */\n\texistingRawTypes: RawType[];\n} {\n\t// Organize the pieces by type\n\n\tconst piecesByType: Map<number, Coords[]> = new Map();\n\tconst existingTypesSet = new Set<number>();\n\tif (!(position instanceof Map)) throw Error(`Position is not a map! (${typeof position})`);\n\tfor (const [coordsKey, type] of position) {\n\t\tif (typeof type !== 'number')\n\t\t\tthrow Error(`Type inside Position is not a number! ${type} ${coordsKey}`); // Bug catcher\n\t\tconst coords = coordutil.getCoordsFromKey(coordsKey as CoordsKey);\n\t\texistingTypesSet.add(type);\n\t\tif (!piecesByType.has(type)) piecesByType.set(type, []);\n\t\tpiecesByType.get(type)!.push(coords); // Push the coords\n\t}\n\n\t// Calculate the possible types\n\n\tconst { existingTypes, existingRawTypes } = calcRemainingExistingTypes(\n\t\texistingTypesSet,\n\t\tturnOrder,\n\t\teditor,\n\t\tpromotionsAllowed,\n\t);\n\n\t// Determine how many undefineds each type needs\n\n\tconst listExtrasByType: TypeGroup<number> = {};\n\tfor (const type of existingTypes) {\n\t\tconst numOfPieceInStartingPos = piecesByType.get(type)?.length ?? 0;\n\t\tlistExtrasByType[type] = getListExtrasOfType(\n\t\t\ttype,\n\t\t\tnumOfPieceInStartingPos,\n\t\t\teditor,\n\t\t\tpromotionsAllowed,\n\t\t);\n\t}\n\n\t// console.log(\"List extras by type:\");\n\t// console.log(listExtrasByType);\n\n\t/**\n\t * Trim the pieceMovesets to only include movesets for types in the game\n\t * This is REQUIRED for possible slides to be calculated correctly!!\n\t */\n\ttypeutil.deleteUnusedFromRawTypeGroup(existingRawTypes, pieceMovesets);\n\n\t// We can get the possible slides now that the movesets are trimmed to only include the types in the game.\n\tconst slides = movesets.getPossibleSlides(pieceMovesets);\n\n\t// Allocate the space needed for the XPositions, YPositions, and types arrays\n\n\tconst totalSlotsNeeded =\n\t\tposition.size + Object.values(listExtrasByType).reduce((a, b) => a + b, 0);\n\t// console.log(\"Total piece count: \" + pieceCount);\n\t// console.log(`Total slots needed: ${totalSlotsNeeded}`);\n\n\t// This way we save on RAM since we don't have to construct normal arrays first and transfer the data after.\n\tconst XPositions = new Array<bigint>(totalSlotsNeeded);\n\tconst YPositions = new Array<bigint>(totalSlotsNeeded);\n\tconst types = new Uint8Array(totalSlotsNeeded);\n\n\t// Initialize the organized lines\n\n\tconst lines = new Map<Vec2Key, Map<LineKey, number[]>>();\n\tfor (const line of slides) {\n\t\tconst strline = vectors.getKeyFromVec2(line);\n\t\tlines.set(strline, new Map());\n\t}\n\n\t// Fill the lists and Construct the type ranges, coords, and lines!\n\n\tconst partialPieces = {\n\t\tXPositions,\n\t\tYPositions,\n\t\tcoords: new Map<CoordsKey, number>(),\n\t\tlines,\n\t};\n\n\tlet start = 0; // The next range start\n\tlet pointer = 0; // The index within the XPositions, YPosition, and types, we are currently setting.\n\tconst ranges: TypeRanges = new Map();\n\tfor (const type of existingTypes) {\n\t\tconst pieces = piecesByType.get(type) ?? []; // It will be empty if there are no pieces of this type in the starting position. Those may be acquired via promotion / board editor.\n\n\t\t// Set the pieces X, Y, and type, and register in space\n\t\tfor (let i = 0; i < pieces.length; i++) {\n\t\t\tXPositions[pointer] = pieces[i]![0];\n\t\t\tYPositions[pointer] = pieces[i]![1];\n\t\t\ttypes[pointer] = Number(type);\n\t\t\tregisterPieceInSpace(pointer, partialPieces);\n\t\t\tpointer++;\n\t\t}\n\n\t\t// Create the undefineds list\n\t\tconst undefineds: number[] = [];\n\t\tfor (let i = 0; i < listExtrasByType[type]!; i++) {\n\t\t\t// The XPositions and YPositions are initialized to 0, so we don't need to set them here.\n\t\t\ttypes[pointer] = Number(type); // The undefined is still in the same type range, though, so we do need to set this.\n\t\t\tundefineds.push(pointer);\n\t\t\tpointer++;\n\t\t}\n\n\t\t// Set the range\n\t\tranges.set(type, {\n\t\t\tstart,\n\t\t\tend: pointer,\n\t\t\tundefineds,\n\t\t});\n\n\t\t// console.log(\"Set type range for type \" + typeutil.debugType(type) + \":\");\n\t\t// console.log(ranges.get(type));\n\n\t\tstart = pointer;\n\t}\n\n\t// Construct the OrganizedPieces object\n\n\treturn {\n\t\tpieces: {\n\t\t\tXPositions,\n\t\t\tYPositions,\n\t\t\ttypes,\n\t\t\ttypeRanges: ranges,\n\t\t\tcoords: partialPieces.coords,\n\t\t\tlines: partialPieces.lines,\n\t\t\tslides,\n\t\t\thippogonalsPresent: areHippogonalsPresentInGame(slides),\n\t\t},\n\t\texistingTypes,\n\t\texistingRawTypes,\n\t};\n}\n\n/**\n * Resizes the piece arrays and updates type ranges to ensure minimum undefined slots.\n * Afterward, flags the pieces as newly regenerated. movesequence may\n * watch for that to know when to regenerate the piece models.\n */\nfunction regenerateLists(\n\to: OrganizedPieces,\n\teditor: boolean,\n\tpromotionsAllowed?: PlayerGroup<RawType[]>,\n): void {\n\tconst additionalUndefinedsNeeded: Map<number, number> = new Map();\n\tconst typeOffsets: Map<number, number> = new Map();\n\tconst modifiedTypes: number[] = []; // A list of all type ranges that changed in size.\n\tlet totalAdditionalSlots = 0;\n\tlet currentCumulativeOffset = 0;\n\n\t// 1. Calculate needed slots, offsets, and track modified types\n\t// for (const [type, range] of typesAndRanges) {\n\tfor (const [type, range] of o.typeRanges) {\n\t\tconst pieceTypeCount = range.end - range.start - range.undefineds.length; // The type of this piece, excluding undefineds\n\t\tconst targetUndefineds = getListExtrasOfType(\n\t\t\ttype,\n\t\t\tpieceTypeCount,\n\t\t\teditor,\n\t\t\tpromotionsAllowed,\n\t\t);\n\t\tconst needed = Math.max(0, targetUndefineds - range.undefineds.length);\n\n\t\tadditionalUndefinedsNeeded.set(type, needed);\n\t\ttypeOffsets.set(type, currentCumulativeOffset);\n\n\t\tif (needed > 0) {\n\t\t\t// Only track if modification occurred\n\t\t\tmodifiedTypes.push(type);\n\t\t\ttotalAdditionalSlots += needed;\n\t\t}\n\n\t\tcurrentCumulativeOffset += needed;\n\t}\n\n\t// --- Early exit if no changes are needed ---\n\tif (totalAdditionalSlots === 0) {\n\t\tconsole.warn('regenerateLists() called but no additional slots were needed.');\n\t\treturn; // Return (no type ranges modified)\n\t}\n\n\tconsole.log(\n\t\t`Regenerating lists: Adding ${totalAdditionalSlots} more total slots for types: ${modifiedTypes.map(typeutil.debugType).join(', ')}.`,\n\t);\n\n\t// --- Prepare for copy ---\n\tconst oldSize = o.XPositions.length;\n\tconst newSize = oldSize + totalAdditionalSlots;\n\n\t// 2. Allocate new, larger arrays\n\tconst newXPositions = new Array<bigint>(newSize);\n\tconst newYPositions = new Array<bigint>(newSize);\n\tconst newTypes = new Uint8Array(newSize);\n\n\t// Keep track of original types before overwriting o.types\n\tconst originalTypes = new Uint8Array(o.types);\n\n\t// 3. Copy data and update TypeRanges\n\tfor (const [type, range] of o.typeRanges) {\n\t\tconst offset = typeOffsets.get(type)!;\n\t\tconst addedSlots = additionalUndefinedsNeeded.get(type)!; // Will be 0 if not modified\n\t\tconst newStart = range.start + offset;\n\t\tconst newEnd = range.end + offset + addedSlots;\n\n\t\t// console.log(`Copying type ${typeutil.debugType(type)}: ${range.start} -> ${newStart}, ${range.end} -> ${newEnd}`);\n\n\t\t// Copy existing data block\n\t\tconst copyLength = range.end - range.start;\n\t\tfor (let i = 0; i < copyLength; i++) {\n\t\t\tnewXPositions[newStart + i] = o.XPositions[range.start + i]!;\n\t\t\tnewYPositions[newStart + i] = o.YPositions[range.start + i]!;\n\t\t}\n\t\tnewTypes.set(o.types.subarray(range.start, range.end), newStart);\n\n\t\t// Update the TypeRange\n\n\t\t// Update existing undefined indices\n\t\trange.undefineds = range.undefineds.map((oldUndefIndex) => oldUndefIndex + offset);\n\n\t\t// Add new undefined indices (only if addedSlots > 0)\n\t\tif (addedSlots > 0) {\n\t\t\tconst firstNewUndefIndex = range.end + offset;\n\t\t\tfor (let i = 0; i < addedSlots; i++) {\n\t\t\t\tconst newIndex = firstNewUndefIndex + i;\n\t\t\t\tnewTypes[newIndex] = type; // Set type for the new slot\n\t\t\t\trange.undefineds.push(newIndex);\n\t\t\t}\n\t\t}\n\n\t\t// Update range properties\n\t\trange.start = newStart;\n\t\trange.end = newEnd;\n\t}\n\n\t// 4. Update indices in coords map\n\tconst newCoords = new Map<CoordsKey, number>();\n\tfor (const [key, oldIdx] of o.coords.entries()) {\n\t\tconst type = originalTypes[oldIdx]!;\n\t\tconst offset = typeOffsets.get(type)!;\n\t\tnewCoords.set(key, oldIdx + offset);\n\t}\n\to.coords = newCoords;\n\n\t// 5. Update indices in lines map\n\tfor (const lineGroup of o.lines.values()) {\n\t\tfor (const indicesArray of lineGroup.values()) {\n\t\t\tfor (let i = 0; i < indicesArray.length; i++) {\n\t\t\t\tconst oldIdx = indicesArray[i]!;\n\t\t\t\tconst type = originalTypes[oldIdx]!;\n\t\t\t\tconst offset = typeOffsets.get(type)!;\n\t\t\t\tindicesArray[i] = oldIdx + offset;\n\t\t\t}\n\t\t}\n\t}\n\n\t// 6. Replace old arrays with new ones\n\to.XPositions = newXPositions;\n\to.YPositions = newYPositions;\n\to.types = newTypes;\n\n\to.newlyRegenerated = true; // Mark as newly regenerated. Piece models should be regenerated too.\n\n\t// console.log(\"Regenerated lists:\");\n\t// console.log(o);\n}\n\n/** Generates a position with the coordinates as the key, and the piece type as the value. */\nfunction generatePositionFromPieces({ coords, types }: OrganizedPieces): Map<CoordsKey, number> {\n\tconst position = new Map<CoordsKey, number>();\n\tfor (const [coordsKey, idx] of coords) {\n\t\tposition.set(coordsKey, types[idx]!);\n\t}\n\treturn position;\n}\n\n/**\n * Generates an iterable of [coordsKey, pieceType] pairs from the given organized pieces.\n *\n * More efficient than {@link generatePositionFromPieces}, as this doesn't create an intermediate map.\n * @param {OrganizedPieces} o - The organized pieces object. Destructure the `coords` and `type` objects so the organized pieces can be garbage cleaned.\n * @returns The piece iterator, yielding [coordsKey, pieceType] pairs.\n */\nfunction* getPieceIterable({ coords, types }: OrganizedPieces): Iterable<[CoordsKey, number]> {\n\tfor (const [coordsKey, idx] of coords) {\n\t\tyield [coordsKey, types[idx]!];\n\t}\n}\n\n// Processing and Removing Pieces in space -------------------------------------------------\n\n/** Adds a piece to o.coords and o.lines so that it can be used for efficient collision detection. */\nfunction registerPieceInSpace(\n\tidx: number,\n\to: {\n\t\t/*\n\t\t * Declaring the argument like this instead of using\n\t\t * Partial<OrganizedPieces> guarantees these options MUST be present.\n\t\t * And doesn't require us pass in a fully-constructed organized pieces object.\n\t\t */\n\t\tXPositions: bigint[];\n\t\tYPositions: bigint[];\n\t\tcoords: Map<CoordsKey, number>;\n\t\tlines: Map<Vec2Key, Map<LineKey, number[]>>;\n\t},\n): void {\n\tif (idx === undefined) throw Error('Undefined idx is trying');\n\tconst x = o.XPositions[idx];\n\tconst y = o.YPositions[idx];\n\tconst coords = [x, y] as Coords;\n\t// console.log(\"Registering piece in space: \" + idx + \" coords: \" + coords);\n\tconst key = coordutil.getKeyFromCoords(coords);\n\tif (o.coords.has(key))\n\t\tthrow Error(\n\t\t\t`While organizing a piece, there was already an existing piece there!! ${key} idx ${idx}`,\n\t\t);\n\to.coords.set(key, idx);\n\tconst lines = o.lines;\n\tfor (const [strline, linegroup] of lines) {\n\t\tconst lkey = getKeyFromLine(coordutil.getCoordsFromKey(strline), coords);\n\t\t// Is line initialized\n\t\tif (linegroup.get(lkey) === undefined) lines.get(strline)!.set(lkey, []);\n\t\tlinegroup.get(lkey)!.push(idx);\n\t}\n}\n\n/** Deletes a piece from o.coords and o.lines */\nfunction removePieceFromSpace(\n\tidx: number,\n\to: {\n\t\t/*\n\t\t * Declaring the argument like this instead of using\n\t\t * Partial<OrganizedPieces> guarantees these options MUST be present.\n\t\t * And doesn't require us pass in a fully-constructed organized pieces object.\n\t\t */\n\t\tXPositions: bigint[];\n\t\tYPositions: bigint[];\n\t\tcoords: Map<CoordsKey, number>;\n\t\tlines: Map<Vec2Key, Map<LineKey, number[]>>;\n\t},\n): void {\n\tconst x = o.XPositions![idx];\n\tconst y = o.YPositions![idx];\n\tconst coords = [x, y] as Coords;\n\t// console.log(\"Removing piece from space: \" + idx + \" coords: \" + coords);\n\tconst key = coordutil.getKeyFromCoords(coords);\n\tif (!o.coords.has(key))\n\t\tthrow Error(\n\t\t\t`While removing a piece, there was no existing piece there!! ${key} idx ${idx}`,\n\t\t);\n\to.coords.delete(key);\n\tconst lines = o.lines;\n\tfor (const [strline, linegroup] of lines) {\n\t\tconst lkey = getKeyFromLine(coordutil.getCoordsFromKey(strline), coords);\n\t\t// Is line initialized\n\t\tif (linegroup.get(lkey) === undefined) continue;\n\t\tremovePieceFromLine(linegroup, lkey);\n\t}\n\n\t// Takes a line from a property of an organized piece list, deletes the piece at specified coords\n\tfunction removePieceFromLine(lineset: Map<LineKey, number[]>, lineKey: LineKey): void {\n\t\tconst line = lineset.get(lineKey)!;\n\n\t\tfor (let i = 0; i < line.length; i++) {\n\t\t\tconst thisPieceIdx = line[i]!;\n\t\t\tif (thisPieceIdx !== idx) continue;\n\t\t\tline.splice(i, 1); // Delete\n\t\t\t// If the line length is now 0, remove itself from the lineset\n\t\t\tif (line.length === 0) lineset.delete(lineKey);\n\t\t\tbreak;\n\t\t}\n\t}\n}\n\n// Helper Functions ------------------------------------------------------------------------\n\n/**\n * Takes a Set of all types in the STARTING POSITION and adds to it other\n * potential pieces that may join the game via promotion or board editor.\n */\nfunction calcRemainingExistingTypes(\n\tpositionExistingTypes: Set<number>,\n\tturnOrder: Player[],\n\teditor: boolean,\n\tpromotionsAllowed?: PlayerGroup<RawType[]>,\n): {\n\texistingTypes: number[];\n\texistingRawTypes: RawType[];\n} {\n\tlet existingTypes: number[];\n\tlet existingRawTypes: RawType[];\n\tif (editor) {\n\t\t// ALL pieces may be added in the board editor, but only of the players mentioned in turnOrder\n\t\tconst playersSet: Set<Player> = new Set(turnOrder);\n\t\tif (turnOrder.some((player) => player >= 3)) playersSet.add(p.NEUTRAL); // also add gargoyles for neutral player, if more than 2 players are in game\n\t\tconst playersArray: Array<Player> = [...playersSet];\n\t\texistingTypes = typeutil.buildAllTypesForPlayers(playersArray, Object.values(rawTypes));\n\t\texistingTypes = [...new Set([...neutralRawTypes, ...existingTypes])]; // This ensures VOID and OBSTACLE are always added.\n\t\texistingRawTypes = Object.values(rawTypes);\n\t} else {\n\t\tif (promotionsAllowed) {\n\t\t\t// Makes sure pieces that are possible to promote to are accounted for.\n\t\t\tfor (const playerString in promotionsAllowed) {\n\t\t\t\tconst player = Number(playerString) as Player;\n\t\t\t\tconst rawPromotions = promotionsAllowed[player]!;\n\t\t\t\tfor (const rawType of rawPromotions) {\n\t\t\t\t\tpositionExistingTypes.add(typeutil.buildType(rawType, player));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t/** If Player 3 or greater is present (multiplayer game), then gargoyles may appear when a player dies.\n\t\t * Which means we also must add corresponding neutral for every type in the game! */\n\t\tif (turnOrder.some((p) => p >= 3)) {\n\t\t\tfor (const type of [...positionExistingTypes]) {\n\t\t\t\t// Spread to avoid problems with infinite iteration when adding to it at the same time.\n\t\t\t\t// Convert it to neutral, and add it to existingTypes\n\t\t\t\tpositionExistingTypes.add(typeutil.getRawType(type) + ext.N);\n\t\t\t}\n\t\t}\n\t\texistingTypes = [...positionExistingTypes];\n\t\texistingRawTypes = [...new Set(existingTypes.map(typeutil.getRawType))];\n\t}\n\n\treturn {\n\t\texistingTypes,\n\t\texistingRawTypes,\n\t};\n}\n\n/**\n * Returns the target number of undefineds that should be alloted for a given type.\n * @param numOfPieces - The number of pieces of this type in the position, EXCLUDING undefineds\n */\nfunction getListExtrasOfType(\n\ttype: number,\n\tnumOfPieces: number,\n\teditor: boolean,\n\tpromotionsAllowed?: PlayerGroup<RawType[]>,\n): number {\n\tconst undefinedsBehavior = getTypeUndefinedsBehavior(type, editor, promotionsAllowed);\n\n\t// prettier-ignore\n\treturn undefinedsBehavior === 2 ? Math.max(listExtras_Editor, numOfPieces) // Count of piece can increase RAPIDLY (editor)\n\t\t : undefinedsBehavior === 1 ? listExtras // Count of piece can increase slowly (promotion)\n\t\t : undefinedsBehavior === 0 ? 0 // Count of piece CANNOT increase\n\t\t : (() => { throw Error(`Unsupported undefineds behavior\" ${undefinedsBehavior} for type ${typeutil.debugType(type)}!`); })();\n}\n\n/**\n * Returns a number signifying the importance of this piece type needing undefineds placeholders in its type list.\n *\n * 0 => Pieces of this type can not increase in count in this gamefile\n * 1 => Can increase in count, but slowly (promotion)\n * 2 => Can increase in count rapidly (board editor)\n */\nfunction getTypeUndefinedsBehavior(\n\ttype: number,\n\teditor: boolean,\n\tpromotionsAllowed?: PlayerGroup<RawType[]>,\n): 0 | 1 | 2 {\n\tif (editor) return 2; // gamefile is in the board editor, EVERY piece needs undefined placeholders, and a lot of them!\n\tif (!promotionsAllowed) return 0; // No pieces can promote, definitely not appending undefineds to this piece.\n\tconst [rawType, player] = typeutil.splitType(type);\n\tif (!promotionsAllowed[player]) return 0; // This player color cannot promote (neutral).\n\tif (promotionsAllowed[player].includes(rawType)) return 1; // Can be promoted to\n\treturn 0; // This piece cannot be promoted to anything.\n}\n\n/**\n * Tests if the provided gamefile has hippogonal lines present in the game.\n * True if there are knightriders or higher riders.\n */\nfunction areHippogonalsPresentInGame(slidingPossible: Vec2[]): boolean {\n\tfor (let i = 0; i < slidingPossible.length; i++) {\n\t\tconst thisSlideDir: Vec2 = slidingPossible[i]!;\n\t\tif (bimath.abs(thisSlideDir[0]) > 1n) return true;\n\t\tif (bimath.abs(thisSlideDir[1]) > 1n) return true;\n\t}\n\treturn false;\n}\n\n// Line Key Functions --------------------------------------------------------------\n\n/**\n * Returns a string that is a unique identifier of a given organized line: `\"C|X\"`.\n * Where `C` is the c in the linear standard form of the line: \"ax + by = c\",\n * and `X` is the nearest x-value the line intersects on or after the y-axis.\n * For example, the line with step-size [2,0] that starts on point (0,0) will have an X value of '0',\n * whereas the line with step-size [2,0] that starts on point (1,0) will have an X value of '1',\n * because it's step size means it never intersects the y-axis at x = 0, but x = 1 is the nearest it gets to it, after 0.\n *\n * If the line is perfectly vertical, the axis will be flipped, so `X` in this\n * situation would be the nearest **Y**-value the line intersects on or above the x-axis.\n * @param step - Line step `[dx,dy]`\n * @param coords `[x,y]` - A point the line intersects\n * @returns the key `C|X`\n */\nfunction getKeyFromLine(step: Vec2, coords: Coords): LineKey {\n\tconst C = vectors.getLineCFromCoordsAndVec(coords, step);\n\tconst X = getXFromLine(step, coords);\n\treturn `${C}|${X}`;\n}\n\n/** Splits the `C` value out of the line key */\nfunction getCFromKey(lineKey: LineKey): bigint {\n\treturn BigInt(lineKey.split('|')[0]!);\n}\n\n/**\n * Calculates the `X` value of the line's key from the provided step direction and coordinates,\n * which is the nearest x-value the line intersects on or after the y-axis.\n * For example, the line with step-size [2,0] that starts on point (0,0) will have an X value of '0',\n * whereas the line with step-size [2,0] that starts on point (1,0) will have an X value of '1',\n * because it's step size means it never intersects the y-axis at x = 0, but x = 1 is the nearest it gets to it, after 0.\n *\n * If the line is perfectly vertical, the axis will be flipped, so `X` in this\n * situation would be the nearest **Y**-value the line intersects on or above the x-axis.\n * @param {Vec2} step - [dx,dy]\n * @param {Coords} coords - Coordinates that are on the line\n * @returns {number} The X in the line's key: `C|X`\n */\nfunction getXFromLine(step: Coords, coords: Coords): bigint {\n\t// See these desmos graphs for inspiration for finding what line the coords are on:\n\t// https://www.desmos.com/calculator/d0uf1sqipn\n\t// https://www.desmos.com/calculator/t9wkt3kbfo\n\n\tconst lineIsVertical = step[0] === 0n;\n\tconst deltaAxis = lineIsVertical ? step[1] : step[0];\n\tconst coordAxis = lineIsVertical ? coords[1] : coords[0];\n\treturn bimath.posMod(coordAxis, deltaAxis);\n}\n\n// Exports --------------------------------------------------\n\nexport default {\n\tprocessInitialPosition,\n\tregenerateLists,\n\tgeneratePositionFromPieces,\n\tgetPieceIterable,\n\tregisterPieceInSpace,\n\tremovePieceFromSpace,\n\tgetTypeUndefinedsBehavior,\n\tgetKeyFromLine,\n\tgetCFromKey,\n\tgetXFromLine,\n};\n\nexport type { OrganizedPieces, TypeRange, LineKey };\n"
  },
  {
    "path": "src/shared/chess/logic/repetition.ts",
    "content": "// src/shared/chess/logic/repetition.ts\n\n/**\n * This script contains our algorithm for detecting draw by repetition.\n *\n * It is compatible with the enpassant state, as if 2 positions differ only\n * by the enpassant state, they are considered different.\n *\n * It also takes into account special rights.\n */\n\nimport type { MoveFull } from './movepiece.js';\nimport type { FullGame } from './gamefile.js';\nimport type { StateChange } from './state.js';\nimport type { GameConclusion } from '../util/winconutil.js';\n\nimport typeutil from '../util/typeutil.js';\nimport boardchanges from './boardchanges.js';\nimport { rawTypes as r } from '../util/typeutil.js';\n\n/** Either a surplus/deficit, on an exact coordinate. This may include a piece type, or an enpassant state. */\ntype Flux = `${string},${string},${number | string}`; // `x,y,43` | `x,y,enpassant`\n\n/**\n * Tests if the provided gamefile has had a repetition draw.\n *\n * Complexity O(m) where m is the move count since the last pawn push or capture or special right loss!\n * @param gamefile - The gamefile\n * @returns Whether there is a three fold repetition present.\n */\nfunction detectRepetitionDraw({ basegame, boardsim }: FullGame): GameConclusion | undefined {\n\tconst moveList = boardsim.moves;\n\tconst turnOrderLength = basegame.gameRules.turnOrder.length;\n\t/** What index of the turn order whos turn it is at the front of the game.\n\t * 0 => First player's turn in the turn order. */\n\tconst currentPlayerTurn = moveList.length % turnOrderLength;\n\n\t/** When compared to our current position, this is a running set of surpluses of previous positions, preventing them from being equivalent to the current position. */\n\tconst surplus = new Set<Flux>();\n\t/** When compared to our current position, this is a running set of deficits of previous positions, preventing them from being equivalent to the current position. */\n\tconst deficit = new Set<Flux>();\n\n\tlet equalPositionsFound: number = 0;\n\n\tconst startIndex: number = moveList.length - 1;\n\tlet indexOfLastEqualPositionFound: number = startIndex + 1; // We need +1 because the first move we observe is the move that brought us to this move index.\n\touter: for (let index = startIndex; index >= 0; index--) {\n\t\t// WILL BE -1 if we've reached the beginning of the game!\n\t\tconst move: MoveFull = moveList[index]!;\n\n\t\t// Did this move include a one-way action? Pawn push, special right loss..\n\t\t// If so, no further equal positions, terminate the loop.\n\t\t// 'capture' move changes are handled lower down, they are one-way too.\n\t\tif (typeutil.getRawType(move.type) === r.PAWN) break; // Pawn pushes reset the repetition alg because we know they can't move back to their previous position.\n\t\tif (\n\t\t\tmove.state.global.some(\n\t\t\t\t(stateChange: StateChange) =>\n\t\t\t\t\tstateChange.type === 'specialrights' && stateChange.future === false,\n\t\t\t)\n\t\t)\n\t\t\tbreak; // specialright was lost, no way its equal to the current position, unless in the future it's possible to add specialrights mid-game.\n\n\t\t// Iterate through all move changes, adding the fluxes.\n\t\tfor (const change of move.changes) {\n\t\t\t// Did this move change include a one-way action? (capture/deletion) If so, no further equal positions, terminate the loop.\n\t\t\tif (boardchanges.oneWayActions.includes(change.action)) break outer; // One-way action, can't be undone, no further equal positions.\n\t\t\t// The remaining actions are two-way, so we need to create fluxes for them..\n\t\t\tif (change.action === 'move') {\n\t\t\t\t// If this change was undo'd, there would be a DEFICIT on its endCoords\n\t\t\t\taddDeficit(`${change.endCoords[0]},${change.endCoords[1]},${change.piece.type}`);\n\t\t\t\t// There would also be a SURPLUS on its startCoords\n\t\t\t\taddSurplus(\n\t\t\t\t\t`${change.piece.coords[0]},${change.piece.coords[1]},${change.piece.type}`,\n\t\t\t\t);\n\t\t\t} else if (change.action === 'add') {\n\t\t\t\t// If this change was undo'd, there would be a DEFICIT on its coords\n\t\t\t\taddDeficit(\n\t\t\t\t\t`${change.piece.coords[0]},${change.piece.coords[1]},${change.piece.type}`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// Next, iterate through all enpassant state changes and add fluxes for them\n\t\tmove.state.global.forEach((state: StateChange) => {\n\t\t\tif (state.type !== 'enpassant') return false; // Filter out non-enpassant states\n\t\t\t/**\n\t\t\t * If we reverse applied this enpassant state,\n\t\t\t * there would be a DEFICIT on the future value,\n\t\t\t * and a SURPLUS on the current value.\n\t\t\t *\n\t\t\t * The reason we should also specify in the flux the pawn's coords that double-pushed is because\n\t\t\t * in the 5D variant, you can't tell where the pawn is that double pushed. It could be 1 square away, or 10.\n\t\t\t * Which means the enpassant square is fundamentally different if the pawn to be captured is a different distance.\n\t\t\t */\n\t\t\tif (state.future !== undefined)\n\t\t\t\taddDeficit(\n\t\t\t\t\t`${state.future.square[0]},${state.future.square[1]},${state.future.pawn[0]},${state.future.pawn[1]},enpassant`,\n\t\t\t\t);\n\t\t\tif (state.current !== undefined)\n\t\t\t\taddSurplus(\n\t\t\t\t\t`${state.current.square[0]},${state.current.square[1]},${state.current.pawn[0]},${state.current.pawn[1]},enpassant`,\n\t\t\t\t);\n\t\t\treturn; // typescript needs this to not complain\n\t\t});\n\n\t\tfunction addSurplus(flux: Flux): void {\n\t\t\t// If there is a DEFICIT with this exact same key, delete that instead! It's been canceled-out.\n\t\t\tif (deficit.has(flux)) deficit.delete(flux);\n\t\t\telse surplus.add(flux);\n\t\t}\n\n\t\tfunction addDeficit(flux: Flux): void {\n\t\t\t// If there is a SURPLUS with this exact same key, delete that instead! It's been canceled-out.\n\t\t\tif (surplus.has(flux)) surplus.delete(flux);\n\t\t\telse deficit.add(flux);\n\t\t}\n\n\t\tcheckEqualPosition: {\n\t\t\t// Has a full turn cycle ocurred since the last increment of equalPositionsFound?\n\t\t\t// If so, we can't count this as an equal position, because it will break it in multiplayer games,\n\t\t\t// or if we have multiple turns in a row.\n\t\t\tconst indexDiff = indexOfLastEqualPositionFound - index;\n\t\t\tif (indexDiff < turnOrderLength) break checkEqualPosition; // Hasn't been a full turn cycle yet, don't increment the counter\n\n\t\t\t// If both the deficit and surplus objects are EMPTY, this position is equal to our current position!\n\t\t\tif (surplus.size === 0 && deficit.size === 0) {\n\t\t\t\t// Check if it's the same player's turn as the front of the game, that is also a requirement for equality.\n\t\t\t\tconst thisPlayerTurn = index % turnOrderLength;\n\t\t\t\tif (thisPlayerTurn !== currentPlayerTurn) break checkEqualPosition;\n\t\t\t\t// Equal!\n\t\t\t\tequalPositionsFound++;\n\t\t\t\tindexOfLastEqualPositionFound = index;\n\t\t\t\tif (equalPositionsFound === 2) break; // Enough to confirm a repetition draw!\n\t\t\t}\n\t\t}\n\n\t\t// console.log('Surplus:', surplus);\n\t\t// console.log('Deficit:', deficit);\n\t}\n\n\t// Loop is finished. How many equal positions did we find?\n\tif (equalPositionsFound === 2) return { victor: null, condition: 'repetition' };\n\telse return undefined;\n}\n\nexport { detectRepetitionDraw };\n"
  },
  {
    "path": "src/shared/chess/logic/specialdetect.ts",
    "content": "// src/shared/chess/logic/specialdetect.ts\n\n/**\n * This detects if special moves are legal.\n * Does NOT execute the moves!\n */\n\nimport type { Coords } from '../util/coordutil.js';\nimport type { Player } from '../util/typeutil.js';\nimport type { CoordsTagged } from './movepiece.js';\nimport type { FullGame, Game, Board } from './gamefile.js';\nimport type { MoveTagged, MoveSpecialTags, SpecialTags } from './movepiece.js';\n\nimport bd from '@naviary/bigdecimal';\n\nimport math from '../../util/math/math.js';\nimport jsutil from '../../util/jsutil.js';\nimport bimath from '../../util/math/bimath.js';\nimport bounds from '../../util/math/bounds.js';\nimport vectors from '../../util/math/vectors.js';\nimport typeutil from '../util/typeutil.js';\nimport bdcoords from '../util/bdcoords.js';\nimport boardutil from '../util/boardutil.js';\nimport coordutil from '../util/coordutil.js';\nimport gamerules from '../util/gamerules.js';\nimport movepiece from './movepiece.js';\nimport legalmoves from './legalmoves.js';\nimport checkresolver from './checkresolver.js';\nimport gamefileutility from '../util/gamefileutility.js';\nimport organizedpieces from './organizedpieces.js';\nimport { players as p, rawTypes as r } from '../util/typeutil.js';\n\n// Functions -----------------------------------------------------------------------\n\n// EVERY one of these functions needs to include enough information in the special move tag\n// to be able to undo any of them!\n\n/**\n * Appends legal king special moves to the provided legal individual moves list. (castling)\n * @param gamefile - The gamefile\n * @param coords - Coordinates of the king selected\n * @param color - The color of the king selected\n * @param premove - Whether we should return all possible moves (premoving)\n */\nfunction kings(\n\tgamefile: FullGame,\n\tcoords: Coords,\n\tcolor: Player,\n\tpremove: boolean,\n): CoordsTagged[] {\n\tconst individualMoves: CoordsTagged[] = [];\n\n\tconst { boardsim, basegame } = gamefile;\n\n\tif (!doesPieceHaveSpecialRight(boardsim, coords)) return individualMoves; // King doesn't have castling rights\n\n\tconst king = boardutil.getPieceFromCoords(boardsim.pieces, coords)!;\n\tconst kingX = coords[0];\n\tconst kingY = coords[1];\n\tconst oppositeColor = typeutil.invertPlayer(color);\n\tconst key = organizedpieces.getKeyFromLine([1n, 0n], coords);\n\tconst row = boardsim.pieces.lines.get('1,0')!.get(key)!;\n\n\t// Add legal Castling...\n\n\tlet left: bigint | null = null; // Piece directly left of king. (Infinity if none)\n\tlet right: bigint | null = null; // Piece directly right of king. (Infinity if none)\n\n\t// If premoving, skip obstruction and check checks.\n\tif (premove) {\n\t\t// Find the closest CASTLEABLE piece on each side of the king.\n\t\tfor (const idx of row) {\n\t\t\tconst pieceCoords = boardutil.getCoordsFromIdx(boardsim.pieces, idx);\n\n\t\t\tif (!isPieceCastleable(pieceCoords)) continue; // Piece is not castleable, skip it\n\n\t\t\tif (pieceCoords[0] < kingX && (left === null || pieceCoords[0] > left))\n\t\t\t\tleft = pieceCoords[0];\n\t\t\telse if (pieceCoords[0] > kingX && (right === null || pieceCoords[0] < right))\n\t\t\t\tright = pieceCoords[0];\n\t\t}\n\n\t\t// THEN append the castling moves to the individual moves.\n\t\tprocessSide(left, -1n, premove); // Castling left\n\t\tprocessSide(right, 1n, premove); // Castling right\n\t} else {\n\t\t// Not premoving. Perform obsctruction and check checks as normal.\n\n\t\t// Find the CLOSEST piece on each side of the king.\n\t\tfor (const idx of row) {\n\t\t\tconst pieceCoords = boardutil.getCoordsFromIdx(boardsim.pieces, idx);\n\n\t\t\tif (pieceCoords[0] < kingX && (left === null || pieceCoords[0] > left))\n\t\t\t\tleft = pieceCoords[0];\n\t\t\telse if (pieceCoords[0] > kingX && (right === null || pieceCoords[0] < right))\n\t\t\t\tright = pieceCoords[0];\n\t\t}\n\n\t\t// THEN check if the piece is castleable.\n\t\tprocessSide(left, -1n, premove); // Castling left\n\t\tprocessSide(right, 1n, premove); // Castling right\n\t}\n\n\t/**\n\t * Returns whether the piece at the given coordinates is castleable:\n\t * * Its distance from the king is at least 3 squares\n\t * * It has its special rights\n\t * * It is a friendly piece\n\t * * It is not a pawn or jumping royal\n\t */\n\tfunction isPieceCastleable(pieceCoords: Coords): boolean {\n\t\t// Distance should be at least 3 squares away.\n\t\tconst dist = bimath.abs(kingX - pieceCoords[0]); // Distance from the king to the piece\n\t\tif (dist < 3) return false; // Piece is too close, can't castle with it\n\n\t\t// Piece should have its special rights\n\t\tif (!doesPieceHaveSpecialRight(boardsim, pieceCoords)) return false; // Piece doesn't have special rights, can't castle with it\n\n\t\t// Color should be a friendly piece\n\t\tconst pieceType: number = boardutil.getTypeFromCoords(boardsim.pieces, pieceCoords)!;\n\t\tconst [rawType, pieceColor] = typeutil.splitType(pieceType);\n\t\tif (pieceColor !== color) return false;\n\n\t\t// Piece should not be a pawn or jumping royal\n\t\tif (rawType === r.PAWN || typeutil.jumpingRoyals.includes(rawType)) return false;\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * If the given side is legal to castle with, it will append the castling move to the individual moves.\n\t * @param pieceX - The X coordinate of the piece that the king is castling with, or -Infinity/Infinity if there is no piece on that side.\n\t * @param dir - The direction the king is moving in. 1 for right, -1 for left.\n\t * @param premove - PREMOVING: Whether we should ignore checks.\n\t */\n\tfunction processSide(pieceX: bigint | null, dir: 1n | -1n, premove: boolean): void {\n\t\tif (pieceX === null) return; // No piece on this side, can't castle with it\n\n\t\tconst pieceCoord: Coords = [pieceX, kingY]; // The coordinates of the piece that the king is castling with.\n\n\t\tif (!isPieceCastleable(pieceCoord)) return; // Piece is not castleable, skip it\n\n\t\t// Check checks: Only need if opponent is using checkmate as a win condition.\n\t\t// Can skip if we're premoving, as we can't predict if we will be in check.\n\t\tif (\n\t\t\tgamerules.doesColorHaveWinCondition(basegame.gameRules, oppositeColor, 'checkmate') &&\n\t\t\t!premove\n\t\t) {\n\t\t\t// Can't currently be in check\n\t\t\tif (gamefileutility.isCurrentViewedPositionInCheck(boardsim)) return; // Not legal if in check\n\n\t\t\t// The square the king passes through must not be a check. Let's simulate that.\n\t\t\tconst middleSquare: Coords = [kingX + dir, kingY]; // The square the king passes through\n\t\t\tif (checkresolver.isMoveCheckInvalid(gamefile, king, middleSquare, color)) return; // The square the king passes through is a check\n\n\t\t\t// The square the king LANDS ON will be tested later, within checkresolver.\n\t\t}\n\n\t\t// All checks passed, this side is legal to castle with. Add the move!\n\n\t\tconst specialMove: CoordsTagged = [coords[0] + 2n * dir, coords[1]];\n\t\tspecialMove.castle = { dir, coord: pieceCoord }; // The special move tag, containing: The direction the king is moving in, and the coordinates of the piece that the king is castling with.\n\t\tindividualMoves.push(specialMove);\n\t}\n\n\treturn individualMoves;\n}\n\n/**\n * Appends legal pawn moves to the provided legal individual moves list.\n * This also is in charge of adding single-push, double-push, and capturing\n * pawn moves, even though those don't need a special move tag.\n * @param gamefile - The gamefile\n * @param coords - Coordinates of the pawn selected\n * @param color - The color of the pawn selected\n * @param premove - Whether we should return all possible moves (premoving)\n */\nfunction pawns(\n\tgamefile: FullGame,\n\tcoords: Coords,\n\tcolor: Player,\n\tpremove: boolean,\n): CoordsTagged[] {\n\tconst { boardsim, basegame } = gamefile;\n\t// White and black pawns move and capture in opposite directions.\n\tconst yOneorNegOne = color === p.WHITE ? 1n : -1n;\n\tconst individualMoves: CoordsTagged[] = [];\n\t// How do we go about calculating a pawn's legal moves?\n\n\t// 1. It can move forward if there is no piece there\n\n\t// Is there a piece in front of it?\n\tconst singlePushCoord: CoordsTagged = [coords[0], coords[1] + yOneorNegOne];\n\tlet moveValidity = legalmoves.testSquareValidity(\n\t\tboardsim,\n\t\tgamefile.basegame.gameRules.worldBorder,\n\t\tsinglePushCoord,\n\t\tcolor,\n\t\tpremove,\n\t\tfalse,\n\t);\n\n\tif (moveValidity === 0) {\n\t\t// Pawns forward-motion validity check must be 0, as they can't capture forward.\n\t\tappendPawnMoveAndAttachPromoteTag(basegame, individualMoves, singlePushCoord, color); // Legal, add the move\n\n\t\t// Further... Is the double push legal?\n\t\tconst doublePushCoord: CoordsTagged = [\n\t\t\tsinglePushCoord[0],\n\t\t\tsinglePushCoord[1] + yOneorNegOne,\n\t\t];\n\t\tmoveValidity = legalmoves.testSquareValidity(\n\t\t\tboardsim,\n\t\t\tgamefile.basegame.gameRules.worldBorder,\n\t\t\tdoublePushCoord,\n\t\t\tcolor,\n\t\t\tpremove,\n\t\t\tfalse,\n\t\t);\n\n\t\tif (doesPieceHaveSpecialRight(boardsim, coords) && moveValidity === 0) {\n\t\t\t// Add the double push!\n\t\t\t// Only create the enpassantCreate tag if it's not a premove.\n\t\t\tif (!premove)\n\t\t\t\tdoublePushCoord.enpassantCreate = getEnPassantGamefileProperty(\n\t\t\t\t\tcoords,\n\t\t\t\t\tdoublePushCoord,\n\t\t\t\t);\n\t\t\tappendPawnMoveAndAttachPromoteTag(basegame, individualMoves, doublePushCoord, color);\n\t\t}\n\t}\n\n\t// 2. It can capture diagonally if there are opponent pieces there\n\n\tconst coordsToCapture: CoordsTagged[] = [\n\t\t[coords[0] - 1n, coords[1] + yOneorNegOne],\n\t\t[coords[0] + 1n, coords[1] + yOneorNegOne],\n\t];\n\tfor (const captureCoords of coordsToCapture) {\n\t\tconst moveValidity = legalmoves.testSquareValidity(\n\t\t\tboardsim,\n\t\t\tgamefile.basegame.gameRules.worldBorder,\n\t\t\tcaptureCoords,\n\t\t\tcolor,\n\t\t\tpremove,\n\t\t\ttrue,\n\t\t); // true for capture is required\n\t\tif (moveValidity <= 1)\n\t\t\tappendPawnMoveAndAttachPromoteTag(basegame, individualMoves, captureCoords, color); // Good to add the capture!\n\t}\n\n\t// 3. It can capture en passant if a pawn next to it just pushed twice.\n\t// Skip if we're premoving, as the capturing moves are added above\n\tif (!premove) addPossibleEnPassant(gamefile, individualMoves, coords, color);\n\n\treturn individualMoves;\n}\n\n/**\n * Returns what the gamefile's enpassant property should be after this double pawn push move\n * @param moveStartCoords - The start coordinates of the move\n * @param moveEndCoords - The end coordinates of the move\n * @returns The coordinates en passant is allowed\n */\nfunction getEnPassantGamefileProperty(\n\tmoveStartCoords: Coords,\n\tmoveEndCoords: Coords,\n): MoveSpecialTags['enpassantCreate'] {\n\tconst y = (moveStartCoords[1] + moveEndCoords[1]) / 2n;\n\tconst enpassantSquare: Coords = [moveStartCoords[0], y];\n\treturn { square: enpassantSquare, pawn: coordutil.copyCoords(moveEndCoords) }; // Copy needed to strip endCoords of existing special tags\n}\n\n/**\n * Appends legal enpassant capture to the selected pawn's provided individual moves.\n * @param gamefile - The gamefile\n * @param individualMoves - The running list of legal individual moves\n * @param coords - The coordinates of the pawn selected, [x,y]\n * @param color - The color of the pawn selected\n */\n// If it can capture en passant, the move is appended to  legalmoves\nfunction addPossibleEnPassant(\n\t{ boardsim, basegame }: FullGame,\n\tindividualMoves: CoordsTagged[],\n\tcoords: Coords,\n\tcolor: Player,\n): void {\n\tif (boardsim.state.global.enpassant === undefined) return; // No enpassant tag on the game, no enpassant possible\n\tif (color !== basegame.whosTurn) return; // Not our turn (the only color who can legally capture enpassant is whos turn it is). If it IS our turn, this also guarantees the captured pawn will be an enemy pawn.\n\tconst enpassantCapturedPawnType = boardutil.getTypeFromCoords(\n\t\tboardsim.pieces,\n\t\tboardsim.state.global.enpassant.pawn,\n\t)!;\n\tif (typeutil.getColorFromType(enpassantCapturedPawnType) === color) return; // The captured pawn is not an enemy pawn. THIS IS ONLY EVER NEEDED if we can move opponent pieces on our turn, which is the case in EDIT MODE.\n\n\tconst xDifference = boardsim.state.global.enpassant.square[0] - coords[0];\n\tif (bimath.abs(xDifference) !== 1n) return; // Not immediately left or right of us\n\t// prettier-ignore\n\tconst yParity = color === p.WHITE ? 1n : color === p.BLACK ? -1n : (() => { throw new Error(\"Invalid color!\"); })();\n\tif (coords[1] + yParity !== boardsim.state.global.enpassant.square[1]) return; // Not one in front of us\n\n\t// It is capturable en passant!\n\n\t/** The square the pawn lands on. */\n\tconst enPassantSquare: CoordsTagged = coordutil.copyCoords(\n\t\tboardsim.state.global.enpassant.square,\n\t);\n\n\t// TAG THIS MOVE as an en passant capture!! gamefile looks for this tag\n\t// on the individual move to detect en passant captures and know when to perform them.\n\tenPassantSquare.enpassant = true;\n\tappendPawnMoveAndAttachPromoteTag(basegame, individualMoves, enPassantSquare, color);\n}\n\n/**\n * Appends the provided move to the running individual moves list,\n * and adds the `promoteTrigger` special tag to it if it landed on a promotion rank.\n */\nfunction appendPawnMoveAndAttachPromoteTag(\n\tbasegame: Game,\n\tindividualMoves: CoordsTagged[],\n\tlandCoords: CoordsTagged,\n\tcolor: Player,\n): void {\n\tif (basegame.gameRules.promotionRanks !== undefined) {\n\t\tconst teamPromotionRanks = basegame.gameRules.promotionRanks[color];\n\t\tif (teamPromotionRanks?.includes(landCoords[1])) landCoords.promoteTrigger = true;\n\t}\n\n\tindividualMoves.push(landCoords);\n}\n\n/**\n * Appends legal moves for the rose piece to the provided legal individual moves list.\n * @param gamefile - The gamefile\n * @param coords - Coordinates of the rose selected\n * @param color - The color of the rose selected\n * @param premove - Whether we should return all possible moves (premoving)\n * @returns\n */\nfunction roses(\n\tgamefile: FullGame,\n\tcoords: Coords,\n\tcolor: Player,\n\tpremove: boolean,\n): CoordsTagged[] {\n\t// prettier-ignore\n\tconst movements: Coords[] = [[-2n, -1n], [-1n, -2n], [1n, -2n], [2n, -1n], [2n, 1n], [1n, 2n], [-1n, 2n], [-2n, 1n]]; // Counter-clockwise\n\tconst directions = [1, -1] as const; // Counter-clockwise and clockwise directions\n\tconst individualMoves: CoordsTagged[] = [];\n\n\tfor (let i = 0; i < movements.length; i++) {\n\t\tfor (const direction of directions) {\n\t\t\tlet currentCoord: CoordsTagged = coordutil.copyCoords(coords);\n\t\t\tlet b = i;\n\t\t\tconst path = [coords]; // The running path of travel for the current spiral. Used for animating.\n\t\t\tfor (let c = 0; c < movements.length - 1; c++) {\n\t\t\t\t// Iterate 7 times, since we can't land on the square we started\n\t\t\t\tconst movement = movements[math.posMod(b, movements.length)]!;\n\t\t\t\tcurrentCoord = coordutil.addCoords(currentCoord, movement);\n\t\t\t\tpath.push(coordutil.copyCoords(currentCoord));\n\n\t\t\t\tconst moveValidity = legalmoves.testSquareValidity(\n\t\t\t\t\tgamefile.boardsim,\n\t\t\t\t\tgamefile.basegame.gameRules.worldBorder,\n\t\t\t\t\tcurrentCoord,\n\t\t\t\t\tcolor,\n\t\t\t\t\tpremove,\n\t\t\t\t\tfalse,\n\t\t\t\t);\n\t\t\t\tif (moveValidity <= 1) appendCoordToIndividuals(currentCoord, path); // Capture is legal\n\t\t\t\tif (moveValidity >= 1) break; // Blocked, break the spiral\n\n\t\t\t\tb += direction; // Update 'b' for the next iteration\n\t\t\t}\n\t\t}\n\t}\n\n\treturn individualMoves;\n\n\t/**\n\t * Appends a ROSE coordinate to the individual moves list if it's not already present.\n\t * If it is present, it chooses the one according to this priority:\n\t * 1. Shortest path\n\t * 2. Path that curves towards the center of play\n\t * 3. Randomly pick one\n\t * @param {Coords} newCoord - The coordinate to append [x, y].\n\t */\n\tfunction appendCoordToIndividuals(newCoord: CoordsTagged, path: Coords[]): void {\n\t\tnewCoord.path = jsutil.deepCopyObject(path);\n\t\tfor (let i = 0; i < individualMoves.length; i++) {\n\t\t\tconst coord = individualMoves[i]!;\n\t\t\tif (!coordutil.areCoordsEqual(coord, newCoord)) continue;\n\t\t\t/*\n\t\t\t * This coord has already been added to our individual moves!!!\n\t\t\t * Pick the one with the shortest path.\n\t\t\t */\n\t\t\tif (coord.path!.length < newCoord.path.length)\n\t\t\t\tindividualMoves[i] = coord; // First path shorter\n\t\t\telse if (coord.path!.length > newCoord.path.length)\n\t\t\t\tindividualMoves[i] = newCoord; // Second path shorter\n\t\t\telse if (coord.path!.length === newCoord.path.length) {\n\t\t\t\t// Path are equal length\n\t\t\t\t// Pick the one that curves towards the center of play,\n\t\t\t\t// as that's more likely to stay within the window during animation.\n\t\t\t\tconst coordsBD = bdcoords.FromCoords(coords);\n\t\t\t\tconst coordPathBD = bdcoords.FromCoords(coord.path![1]!);\n\t\t\t\tconst newCoordPathBD = bdcoords.FromCoords(newCoord.path[1]!);\n\n\t\t\t\tconst startingBoxBD = bounds.castBoundingBoxToBigDecimal(\n\t\t\t\t\tgamefile.boardsim.startSnapshot.box,\n\t\t\t\t);\n\t\t\t\tconst centerOfPlay = bounds.calcCenterOfBoundingBox(startingBoxBD);\n\t\t\t\tconst vectorToCenter = vectors.calculateVectorFromBDPoints(coordsBD, centerOfPlay);\n\t\t\t\tconst existingCoordVector = vectors.calculateVectorFromBDPoints(\n\t\t\t\t\tcoordsBD,\n\t\t\t\t\tcoordPathBD,\n\t\t\t\t);\n\t\t\t\tconst newCoordVector = vectors.calculateVectorFromBDPoints(\n\t\t\t\t\tcoordsBD,\n\t\t\t\t\tnewCoordPathBD,\n\t\t\t\t);\n\t\t\t\t// Whichever's dot product scores higher is the one that curves more towards the center\n\t\t\t\tconst existingCoordDotProd = vectors.dotProductBD(\n\t\t\t\t\texistingCoordVector,\n\t\t\t\t\tvectorToCenter,\n\t\t\t\t);\n\t\t\t\tconst newCoordDotProd = vectors.dotProductBD(newCoordVector, vectorToCenter);\n\t\t\t\tconst compareResult = bd.compare(existingCoordDotProd, newCoordDotProd);\n\t\t\t\tif (compareResult > 0)\n\t\t\t\t\tindividualMoves[i] = coord; // Existing move's path curves more towards the center\n\t\t\t\telse if (compareResult < 0)\n\t\t\t\t\tindividualMoves[i] = newCoord; // New move's path curves more towards the center\n\t\t\t\telse {\n\t\t\t\t\t// BOTH point equally point towards the origin.\n\t\t\t\t\t// JUST pick a random one!\n\t\t\t\t\tindividualMoves[i] = Math.random() < 0.5 ? coord : newCoord;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn;\n\t\t}\n\n\t\t// This coordinate has not been added yet. Let's do it now.\n\t\tindividualMoves.push(newCoord);\n\t}\n}\n\n/**\n * Tests if the piece at the given coordinates has it's special move rights.\n * @param gamefile - The gamefile\n * @param coords - The coordinates of the piece\n * @returns *true* if it has it's special move rights.\n */\nfunction doesPieceHaveSpecialRight(boardsim: Board, coords: Coords): boolean {\n\tconst key = coordutil.getKeyFromCoords(coords);\n\treturn boardsim.state.global.specialRights.has(key);\n}\n\n// Returns true if the type is a pawn and the coords it moved to is a promotion line\n\n/**\n * Returns true if a pawn moved onto a promotion line.\n * @param gamefile\n * @param type\n * @param coordsClicked\n * @returns\n */\nfunction isPawnPromotion(basegame: Game, type: number, coordsClicked: Coords): boolean {\n\tif (typeutil.getRawType(type) !== r.PAWN) return false;\n\tif (!basegame.gameRules.promotionRanks) return false; // This game doesn't have promotion.\n\n\tconst color = typeutil.getColorFromType(type);\n\tconst promotionRanks = basegame.gameRules.promotionRanks[color];\n\n\treturn promotionRanks?.includes(coordsClicked[1]) || false;\n}\n\n/**\n * Transfers the move-retained special tags from the provided coordinates to the move.\n * UI-only tags (e.g. `promoteTrigger`) are intentionally excluded — they are\n * consumed and deleted before any call to this function.\n */\nfunction transferSpecialTags_FromCoordsToMove(coords: CoordsTagged, move: MoveTagged): void {\n\tfor (const special of movepiece.MOVE_SPECIAL_TAGS) {\n\t\ttransferSpecialTag(coords, move, special);\n\t}\n}\n\n/**\n * Transfers all special move tags (move and UI) from one set of coordinates to another.\n * @param srcCoords - The source coordinates\n * @param destCoords - The destination coordinates\n */\nfunction transferSpecialTags_FromCoordsToCoords(\n\tsrcCoords: CoordsTagged,\n\tdestCoords: CoordsTagged,\n): void {\n\tfor (const special of movepiece.SPECIAL_TAGS) {\n\t\ttransferSpecialTag(srcCoords, destCoords, special);\n\t}\n}\n\n/**\n * Copies a single {@link SpecialTags} key from `src` to `dest`.\n *\n * Keeping `Tags = MoveSpecialTags` fixed and `K` as a free parameter gives\n * TypeScript full correlation between the key and value types on both sides,\n * so the assignment is verified with full type safety.\n */\nfunction transferSpecialTag<K extends keyof SpecialTags>(\n\tsrc: Partial<SpecialTags>,\n\tdest: Partial<SpecialTags>,\n\tkey: K,\n): void {\n\tif (src[key] !== undefined) dest[key] = jsutil.deepCopyObject(src[key]); // SpecialTag[K] → SpecialTag[K] | undefined ✓\n}\n\n// Exports -----------------------------------------------------------------------\n\nexport default {\n\tkings,\n\tpawns,\n\troses,\n\tgetEnPassantGamefileProperty,\n\tisPawnPromotion,\n\ttransferSpecialTags_FromCoordsToMove,\n\ttransferSpecialTags_FromCoordsToCoords,\n};\n"
  },
  {
    "path": "src/shared/chess/logic/specialmove.ts",
    "content": "// src/shared/chess/logic/specialmove.ts\n\n/** This script stores the default methods for EXECUTING special moves */\n\nimport type { Piece } from '../util/boardutil.js';\nimport type { Board } from './gamefile.js';\nimport type { Coords } from '../util/coordutil.js';\nimport type { RawTypeGroup } from '../util/typeutil.js';\nimport type { Edit, MoveTagged } from './movepiece.js';\n\nimport state from './state.js';\nimport boardutil from '../util/boardutil.js';\nimport boardchanges from './boardchanges.js';\nimport { rawTypes as r } from '../util/typeutil.js';\n\n/**\n * Function that queues all of the changes a special move makes when executed.\n */\ntype SpecialMoveFunction = (_boardsim: Board, _piece: Piece, _move: MoveRunning) => boolean;\n\n/** All properties of the Move that special move functions need to access */\ninterface MoveRunning extends MoveTagged, Edit {}\n\n/**\n * An object storing the squares in the immediate vicinity\n * a piece has a CHANCE of making a special-move capture from.\n *\n * The value is a list of coordinates that it may be possible for that raw piece type to make a special capture from that distance.\n */\ntype SpecialVicinity = RawTypeGroup<Coords[]>;\n\n// This returns the functions for executing special moves,\n// it does NOT calculate if they're legal.\n// In the future, parameters can be added if variants have\n// different special moves for pieces.\nconst defaultSpecialMoves: RawTypeGroup<SpecialMoveFunction> = {\n\t[r.KING]: kings,\n\t[r.ROYALCENTAUR]: kings,\n\t[r.PAWN]: pawns,\n\t[r.ROSE]: roses,\n};\n\n// A custom special move needs to be able to:\n// * Delete a custom piece\n// * Move a custom piece\n// * Add a custom piece\n\n// ALL FUNCTIONS NEED TO:\n// * Make the move\n// * Append the move\n\n// Called when the piece moved is a king.\n// Tests if the move contains \"castle\" special move, if so it executes it!\n// RETURNS FALSE if special move was not executed!\nfunction kings(boardsim: Board, piece: Piece, move: MoveRunning): boolean {\n\tconst specialTag = move.castle; // { dir: -1/1, coord }\n\tif (!specialTag) return false; // No special move to execute, return false to signify we didn't move the piece.\n\n\t// Move the king to new square\n\tconst moveChanges = move.changes;\n\tconst kingCapturedPiece = boardutil.getPieceFromCoords(boardsim.pieces, move.endCoords);\n\t// CASTLING CAN CAPTURE A PIECE IF IT'S A PREMOVE!!!\n\tif (kingCapturedPiece) boardchanges.queueCapture(moveChanges, true, kingCapturedPiece); // Capture piece\n\tboardchanges.queueMovePiece(moveChanges, true, piece, move.endCoords);\n\n\t// Move the rook to new square\n\tconst pieceToCastleWith = boardutil.getPieceFromCoords(boardsim.pieces, specialTag.coord)!;\n\tconst landSquare: Coords = [move.endCoords[0] - specialTag.dir, move.endCoords[1]];\n\tconst rookCapturedPiece = boardutil.getPieceFromCoords(boardsim.pieces, landSquare);\n\t// CASTLING CAN CAPTURE A PIECE IF IT'S A PREMOVE!!!\n\tif (rookCapturedPiece) boardchanges.queueCapture(moveChanges, false, rookCapturedPiece); // Capture piece\n\tboardchanges.queueMovePiece(moveChanges, false, pieceToCastleWith, landSquare);\n\n\t// Special move was executed!\n\t// (There is no captured piece with castling)\n\treturn true;\n}\n\nfunction pawns(boardsim: Board, piece: Piece, move: MoveRunning): boolean {\n\tconst moveChanges = move.changes;\n\n\t// If it was a double push, then queue adding the new enpassant square to the gamefile!\n\tif (move.enpassantCreate !== undefined)\n\t\tstate.createEnPassantState(move, boardsim.state.global.enpassant, move.enpassantCreate);\n\n\tconst enpassantTag = move.enpassant; // true | undefined\n\tconst promotionTag = move.promotion; // promote type\n\tif (!enpassantTag && !promotionTag) return false; // No special move to execute, return false to signify we didn't move the piece.\n\n\tconst captureCoords = enpassantTag ? boardsim.state.global.enpassant!.pawn : move.endCoords;\n\t// const captureCoords = enpassantTag ? getEnpassantCaptureCoords(move.endCoords, enpassantTag) : move.endCoords;\n\tconst capturedPiece = boardutil.getPieceFromCoords(boardsim.pieces, captureCoords);\n\n\t// Delete the piece captured\n\n\tif (capturedPiece) boardchanges.queueCapture(moveChanges, true, capturedPiece);\n\tboardchanges.queueMovePiece(moveChanges, true, piece, move.endCoords);\n\n\tif (promotionTag) {\n\t\t// Delete original pawn\n\t\tboardchanges.queueDeletePiece(moveChanges, true, {\n\t\t\tcoords: move.endCoords,\n\t\t\ttype: piece.type,\n\t\t\tindex: piece.index,\n\t\t});\n\n\t\tboardchanges.queueAddPiece(moveChanges, {\n\t\t\tcoords: move.endCoords,\n\t\t\ttype: promotionTag,\n\t\t\tindex: -1,\n\t\t});\n\t}\n\n\t// Special move was executed!\n\treturn true;\n}\n\n// The Roses need a custom special move function so that it can pass the `path` special flag to the move changes.\nfunction roses(boardsim: Board, piece: Piece, move: MoveRunning): boolean {\n\tconst capturedPiece = boardutil.getPieceFromCoords(boardsim.pieces, move.endCoords);\n\n\t// Delete the piece captured\n\tif (capturedPiece) boardchanges.queueCapture(move.changes, true, capturedPiece);\n\tboardchanges.queueMovePiece(move.changes, true, piece, move.endCoords, move.path);\n\n\t// Special move was executed!\n\treturn true;\n}\n\n/**\n * Returns the coordinate distances certain piece types have a chance\n * of special-move capturing on, according to the default specialMove functions.\n */\nfunction getDefaultSpecialVicinitiesByPiece(): SpecialVicinity {\n\t// prettier-ignore\n\treturn {\n\t\t[r.PAWN]: [[-1n,1n],[1n,1n],[-1n,-1n],[1n,-1n]], // All squares a pawn could potentially capture on.\n\t\t// All squares a rose piece could potentially capture on.\n\t\t[r.ROSE]: [[-2n,-1n],[-3n,-3n],[-2n,-5n],[0n,-6n],[2n,-5n],[3n,-3n],[2n,-1n],[-4n,0n],[-5n,2n],[-4n,4n],[-2n,5n],[0n,4n],[1n,2n],[-1n,-2n],[0n,-4n],[4n,-4n],[5n,-2n],[4n,0n],[2n,1n],[-5n,-2n],[-6n,0n],[-3n,3n],[-1n,2n],[1n,-2n],[6n,0n],[5n,2n],[3n,3n],[-4n,-4n],[-2n,1n],[4n,4n],[2n,5n],[0n,6n]],\n\t};\n}\n\nexport default {\n\tdefaultSpecialMoves,\n\tgetDefaultSpecialVicinitiesByPiece,\n};\n\nexport type { MoveRunning, SpecialMoveFunction, SpecialVicinity };\n"
  },
  {
    "path": "src/shared/chess/logic/state.ts",
    "content": "// src/shared/chess/logic/state.ts\n\n/**\n * This script creates, queues, and applies gamefile states\n * to the gamefile when a Move is created, and executed.\n */\n\nimport type { Coords } from '../util/coordutil.js';\nimport type { CoordsKey } from '../util/coordutil.js';\nimport type { Edit, MoveSpecialTags } from './movepiece.js';\n\n// Types -----------------------------------------------------------------------------------------------\n\n/** The state of a game holds variables that change over the duration of it. */\ninterface GameState {\n\tlocal: LocalGameState;\n\tglobal: GlobalGameState;\n}\n\n/** State of a specific move your are VIEWING. */\ninterface LocalGameState {\n\t/** Index of the move we're currently viewing in the moves list. -1 means we're looking at the very beginning of the game. */\n\tmoveIndex: number;\n\t/** If the currently-viewed move is in check, this will be a list of coordinates\n\t * of all the royal pieces in check: `[[5,1],[10,1]]`, otherwise *false*. @type {} */\n\tinCheck: Coords[] | false;\n\t/**\n\t * All active checks against whoever's turn it is, each pairing the checked royal with\n\t * its attacker. ONLY USED with the `checkmate` win condition!!\n\t * Only used to calculate legal moves, and detect checkmate.\n\t * The same royal or attacker may appear in multiple checks, in scenarios such as double checks.\n\t */\n\tchecks: CheckInfo[];\n}\n\n/**\n * State of a game that DOESN'T change depending on what move your VIEWING,\n * but DO change when new moves are made, or rewound (deleted).\n *\n * They represent the state of the game at the FRONT.\n */\ninterface GlobalGameState {\n\t/** An object containing the information if each individual piece has its special move rights. */\n\tspecialRights: Set<CoordsKey>;\n\t/** If enpassant is allowed at the front of the game, this defines the coordinates. */\n\tenpassant?: EnPassant;\n\t/** The number of half-moves played since the last capture or pawn push. */\n\tmoveRuleState?: number;\n}\n\n// TODO: Move to gamefile type definition (right now it's not in typescript)\ntype inCheck = false | Coords[];\n\n/**\n *\n * Local statechanges are unique to the move you're viewing, and are always applied. Those include:\n *\n * check, checks\n *\n * Global statechanges are a property of the game as a whole, not unique to the move,\n * and are not applied when VIEWING a move.\n * However, they are applied only when we make a new move, or rewind a simulated one. Those include:\n *\n * enpassant, specialrights, moverulestate\n */\n\n/**\n * Contains the statechanges for the turn before and after a move is made\n *\n * Local state change examples: (check, checks)\n * Global state change examples: (enpassant, specialrights, moverule state, running check counter)\n */\ninterface MoveState {\n\tlocal: Array<StateChange>;\n\tglobal: Array<StateChange>;\n}\n\n/**\n * A state change, local or global, that contains enough information to set the gamefile's\n * property whether the move is being rewound or replayed.\n */\ntype StateChange =\n\t| {\n\t\t\t/** The type of state this {@link StateChange} is */\n\t\t\ttype: 'check';\n\t\t\t/* The gamefile's property of this type BEFORE this move was made, used to restore them when the move is rewinded. */\n\t\t\tcurrent: inCheck;\n\t\t\t/* The gamefile's property of this type AFTER this move was made, used to restore them when the move is replayed. */\n\t\t\tfuture: inCheck;\n\t  }\n\t| {\n\t\t\ttype: 'checks';\n\t\t\tcurrent: CheckInfo[];\n\t\t\tfuture: CheckInfo[];\n\t  }\n\t| {\n\t\t\ttype: 'enpassant';\n\t\t\tcurrent?: EnPassant;\n\t\t\tfuture?: EnPassant;\n\t  }\n\t| {\n\t\t\ttype: 'specialrights';\n\t\t\tcurrent: boolean;\n\t\t\tfuture: boolean;\n\t\t\t/** The coordsKey of what square was affected by this specialrights state change. */\n\t\t\tcoordsKey: CoordsKey;\n\t  }\n\t| {\n\t\t\ttype: 'moverulestate';\n\t\t\tcurrent: number;\n\t\t\tfuture: number;\n\t  };\n\n/** A single check being delivered: the checked royal paired with its attacker. */\ntype CheckInfo = {\n\t/** The coordinates of the royal being checked */\n\troyal: Coords;\n\t/** The coordinates of the attacking piece */\n\tattacker: Coords;\n\t/** Whether the check is delivered via a sliding movement (not individual, NOR special with a `path` attribute) */\n\tslidingCheck: boolean;\n} & (\n\t| {\n\t\t\tslidingCheck: true;\n\t\t\t/** Whether the attacker is moving colinearly. */\n\t\t\tcolinear: boolean;\n\t  }\n\t| {\n\t\t\tslidingCheck: false;\n\t\t\t/** Optionally, if it's an individual (non-slidingCheck), the path this piece takes to check the royal (e.g. Rose piece) */\n\t\t\tpath?: MoveSpecialTags['path'];\n\t  }\n);\n\ninterface EnPassant {\n\t/** The enpassant square. */\n\tsquare: Coords;\n\t/**\n\t * The square the pawn that doubled pushed is on.\n\t *\n\t * We need this info, because otherwise in the 5D variant,\n\t * you can't tell where the pawn is that double pushed.\n\t * It could be 1 square away, or 10.\n\t */\n\tpawn: Coords;\n}\n\n// Creating Local State Changes --------------------------------------------------------------------\n\n/** Creates a check local StateChange, adding it to the Move and immediately applying it to the gamefile. */\nfunction createCheckState(\n\tmove: Edit,\n\tcurrent: inCheck,\n\tfuture: inCheck,\n\tgamestate: GameState,\n): void {\n\tconst newStateChange: StateChange = { type: 'check', current, future };\n\tmove.state.local.push(newStateChange); // Check is a local state\n\t// Check states are immediately applied to the gamefile\n\tapplyLocalState(gamestate.local, newStateChange, true);\n}\n\n/** Creates a checks local StateChange, adding it to the Move and immediately applying it to the gamefile. */\nfunction createChecksState(\n\tmove: Edit,\n\tcurrent: CheckInfo[],\n\tfuture: CheckInfo[],\n\tgamestate: GameState,\n): void {\n\tconst newStateChange: StateChange = { type: 'checks', current, future };\n\tmove.state.local.push(newStateChange); // Checks is a local state\n\t// Checks states are immediately applied to the gamefile\n\tapplyLocalState(gamestate.local, newStateChange, true);\n}\n\n// Creating Global State Changes --------------------------------------------------------------------\n\n/** Creates an enpassant global StateChange, queueing it by adding it to the Move. */\nfunction createEnPassantState(move: Edit, current?: EnPassant, future?: EnPassant): void {\n\tif (current === future) return; // If the current and future values are identical, we can skip queueing this state.\n\tconst newStateChange: StateChange = { type: 'enpassant', current, future };\n\t// Check to make sure there isn't already an enpassant state change,\n\t// If so, we need to overwrite that one's future value, instead of queueing a new one.\n\tconst preExistingEnPassantState = move.state.global.find((state) => state.type === 'enpassant');\n\tif (preExistingEnPassantState !== undefined) preExistingEnPassantState.future = future;\n\telse move.state.global.push(newStateChange); // EnPassant is a global state\n}\n\n/**\n * Creates a specialrights global StateChange, queueing it by adding it to the Move.\n * IN NORMAL GAMES (outside of board editor), `current` and `future` SHOULD NEVER BE EQUAL,\n * otherwise it breaks the threefold repetition algorithm!!\n * We can't just exit early if they are equal, because the board editor needs to be able to create\n * multiple state changes with equal current and future values for accurate selection tool reflections.\n */\nfunction createSpecialRightsState(\n\tmove: Edit,\n\tcoordsKey: CoordsKey,\n\tcurrent: boolean,\n\tfuture: boolean,\n): void {\n\tconst newStateChange: StateChange = { type: 'specialrights', current, future, coordsKey };\n\tmove.state.global.push(newStateChange); // Special Rights is a global state\n}\n\n/** Creates a moverule global StateChange, queueing it by adding it to the Move. */\nfunction createMoveRuleState(move: Edit, current: number, future: number): void {\n\tif (current === future) return; // If the current and future values are identical, we can skip queueing this state.\n\tconst newStateChange: StateChange = { type: 'moverulestate', current, future };\n\tmove.state.global.push(newStateChange); // Special Rights is a global state\n}\n\n// Applying State Changes ----------------------------------------------------------------------------\n\n/**\n * Applies all the StateChanges of a Move, in order, to the gamefile,\n * whether forward or backward, local or global.\n */\nfunction applyMove(\n\tgamestate: GameState,\n\tmoveState: MoveState,\n\t/** Whether we're playing this move forward or backward. */\n\tforward: boolean,\n\t/**\n\t * Specify `globalChange` as true if you are making a physical move in the game,\n\t * or rewinding a simulated move.\n\t * All other situations, such as rewinding and forwarding the game, should only\n\t * be local, so `globalChange` should be false.\n\t */\n\t{ globalChange = false } = {},\n): void {\n\tapplyLocalStateChanges(gamestate.local, moveState.local, forward);\n\tif (globalChange) applyGlobalStateChanges(gamestate.global, moveState.global, forward);\n}\n\nfunction applyLocalStateChanges(\n\tgamestate: LocalGameState,\n\tchanges: Array<StateChange>,\n\tforward: boolean,\n): void {\n\tfor (const state of changes) {\n\t\tapplyLocalState(gamestate, state, forward);\n\t}\n}\n\nfunction applyGlobalStateChanges(\n\tgamestate: GlobalGameState,\n\tchanges: Array<StateChange>,\n\tforward: boolean,\n): void {\n\t/** The reason we don't include the whole gamefile is so that {@link gamecompressor.GameToPosition} can also use applyMove(). */\n\tfor (const state of changes) {\n\t\tapplyGlobalState(gamestate, state, forward);\n\t}\n}\n\n/** Applies a move's local state change to the gamefile, forward or backward. */\nfunction applyLocalState(gamestate: LocalGameState, state: StateChange, forward: boolean): void {\n\tconst noNewValue = (forward ? state.future : state.current) === undefined;\n\tswitch (state.type) {\n\t\tcase 'check':\n\t\t\tgamestate.inCheck = forward ? state.future : state.current;\n\t\t\tbreak;\n\t\tcase 'checks':\n\t\t\tif (noNewValue) gamestate.checks = [];\n\t\t\telse gamestate.checks = forward ? state.future : state.current;\n\t\t\tbreak;\n\t\tdefault:\n\t\t\tthrow new Error(`State ${state.type} is not a local state change.`);\n\t}\n}\n\n/** Applies a move's global state change to the gamefile, forward or backward. */\nfunction applyGlobalState(gamestate: GlobalGameState, state: StateChange, forward: boolean): void {\n\tconst noNewValue = (forward ? state.future : state.current) === undefined;\n\tswitch (state.type) {\n\t\tcase 'specialrights':\n\t\t\tif (!(forward ? state.future : state.current))\n\t\t\t\tgamestate.specialRights.delete(state.coordsKey);\n\t\t\telse gamestate.specialRights.add(state.coordsKey);\n\t\t\tbreak;\n\t\tcase 'enpassant':\n\t\t\tif (noNewValue) delete gamestate.enpassant;\n\t\t\telse gamestate.enpassant = forward ? state.future : state.current;\n\t\t\tbreak;\n\t\tcase 'moverulestate':\n\t\t\tgamestate.moveRuleState = forward ? state.future : state.current;\n\t\t\tbreak;\n\t\tdefault:\n\t\t\tthrow new Error(`State ${state.type} is not a global state change.`);\n\t}\n}\n\n// Exports --------------------------------------------------------------------------\n\nexport default {\n\tapplyMove,\n\tapplyGlobalStateChanges,\n\tcreateCheckState,\n\tcreateChecksState,\n\tcreateEnPassantState,\n\tcreateSpecialRightsState,\n\tcreateMoveRuleState,\n};\n\nexport type { GameState, GlobalGameState, MoveState, StateChange, CheckInfo, EnPassant };\n"
  },
  {
    "path": "src/shared/chess/logic/wincondition.ts",
    "content": "// src/shared/chess/logic/wincondition.ts\n\n/**\n * This script contains the methods for calculating if the\n * game is over by the win condition used, for all win\n * conditions except for checkmate, stalemate, and repetition.\n */\n\nimport type { Coords } from '../util/coordutil.js';\nimport type { GameConclusion } from '../util/winconutil.js';\nimport type { Board, FullGame } from './gamefile.js';\n\nimport moveutil from '../util/moveutil.js';\nimport boardutil from '../util/boardutil.js';\nimport boardchanges from './boardchanges.js';\nimport gamefileutility from '../util/gamefileutility.js';\nimport typeutil, { RawType } from '../util/typeutil.js';\nimport { detectRepetitionDraw } from './repetition.js';\nimport { rawTypes as r, Player } from '../util/typeutil.js';\nimport { detectInsufficientMaterial } from './insufficientmaterial.js';\nimport {\n\tdetectCheckmateOrStalemate,\n\tpieceCountToDisableCheckmate,\n\troyalCountToDisableCheckmate,\n} from './checkmate.js';\n\n// The squares in KOTH where if you get your king to you WIN\n// prettier-ignore\nconst kothCenterSquares: Coords[] = [[4n, 4n], [5n, 4n], [4n, 5n], [5n, 5n]];\n\n/**\n * Tests if the game is over by the win condition used, and if so,\n * returns the `gameConclusion` property of the gamefile.\n * For example, `{ victor: 1, condition: 'checkmate' }`, or `{ victor: 0, condition: 'stalemate' }`.\n * @param gamefile - The gamefile\n * @returns The conclusion object, if the game is over. For example, `{ victor: 1, condition: 'checkmate' }`, or `{ victor: 0, condition: 'stalemate' }`. If the game isn't over, this returns *undefined*.\n */\nfunction getGameConclusion(gamefile: FullGame): GameConclusion | undefined {\n\tif (!moveutil.areWeViewingLatestMove(gamefile.boardsim))\n\t\tthrow new Error(\"Cannot perform game over checks when we're not on the last move.\");\n\n\treturn (\n\t\tdetectAllpiecescaptured(gamefile) ||\n\t\tdetectRoyalCapture(gamefile) ||\n\t\tdetectAllroyalscaptured(gamefile) ||\n\t\tdetectKoth(gamefile) ||\n\t\tdetectRepetitionDraw(gamefile) ||\n\t\tdetectCheckmateOrStalemate(gamefile) ||\n\t\t// This needs to be last so that a draw isn't enforced in a true win\n\t\tdetectMoveRule(gamefile) || // 50-move-rule\n\t\tdetectInsufficientMaterial(gamefile.basegame.gameRules, gamefile.boardsim) ||\n\t\tundefined\n\t); // No win condition passed. No game conclusion!\n}\n\nfunction detectRoyalCapture({ boardsim, basegame }: FullGame): GameConclusion | undefined {\n\tif (!gamefileutility.isOpponentUsingWinCondition(basegame, basegame.whosTurn, 'royalcapture'))\n\t\treturn undefined; // Not using this gamerule\n\n\t// Was the last move capturing a royal piece?\n\tif (wasLastMoveARoyalCapture(boardsim)) {\n\t\tconst colorThatWon: Player = moveutil.getColorThatPlayedMoveIndex(\n\t\t\tbasegame,\n\t\t\tboardsim.moves.length - 1,\n\t\t);\n\t\treturn { victor: colorThatWon, condition: 'royalcapture' };\n\t}\n\n\treturn undefined;\n}\n\nfunction detectAllroyalscaptured({ boardsim, basegame }: FullGame): GameConclusion | undefined {\n\tif (\n\t\t!gamefileutility.isOpponentUsingWinCondition(\n\t\t\tbasegame,\n\t\t\tbasegame.whosTurn,\n\t\t\t'allroyalscaptured',\n\t\t)\n\t)\n\t\treturn undefined; // Not using this gamerule\n\tif (!wasLastMoveARoyalCapture(boardsim)) return undefined; // Last move wasn't a royal capture.\n\n\t// Are there any royal pieces remaining?\n\t// Remember that whosTurn has already been flipped since the last move.\n\tconst royalCount: Coords[] = boardutil.getRoyalCoordsOfColor(\n\t\tboardsim.pieces,\n\t\tbasegame.whosTurn,\n\t);\n\n\tif (royalCount.length === 0) {\n\t\tconst colorThatWon: Player = moveutil.getColorThatPlayedMoveIndex(\n\t\t\tbasegame,\n\t\t\tboardsim.moves.length - 1,\n\t\t);\n\t\treturn { victor: colorThatWon, condition: 'allroyalscaptured' };\n\t}\n\n\treturn undefined;\n}\n\nfunction detectAllpiecescaptured({ boardsim, basegame }: FullGame): GameConclusion | undefined {\n\tif (\n\t\t!gamefileutility.isOpponentUsingWinCondition(\n\t\t\tbasegame,\n\t\t\tbasegame.whosTurn,\n\t\t\t'allpiecescaptured',\n\t\t)\n\t)\n\t\treturn undefined; // Not using this gamerule\n\n\t// If the player who's turn it is now has zero pieces left, win!\n\tconst count: number = boardutil.getPieceCountOfColor(boardsim.pieces, basegame.whosTurn);\n\n\tif (count === 0) {\n\t\tconst colorThatWon: Player = moveutil.getColorThatPlayedMoveIndex(\n\t\t\tbasegame,\n\t\t\tboardsim.moves.length - 1,\n\t\t);\n\t\treturn { victor: colorThatWon, condition: 'allpiecescaptured' };\n\t}\n\n\treturn undefined;\n}\n\nfunction detectKoth({ boardsim, basegame }: FullGame): GameConclusion | undefined {\n\tif (!gamefileutility.isOpponentUsingWinCondition(basegame, basegame.whosTurn, 'koth'))\n\t\treturn undefined; // Not using this gamerule\n\n\t// Was the last move a king move?\n\tconst lastMove = moveutil.getLastMove(boardsim.moves);\n\tif (!lastMove) return undefined;\n\tif (typeutil.getRawType(lastMove.type) !== r.KING) return undefined;\n\n\tlet kingInCenter = false;\n\tfor (const thisCenterSquare of kothCenterSquares) {\n\t\tconst typeAtSquare: number | undefined = boardutil.getTypeFromCoords(\n\t\t\tboardsim.pieces,\n\t\t\tthisCenterSquare,\n\t\t);\n\t\tif (typeAtSquare === undefined) continue;\n\t\tif (typeutil.getRawType(typeAtSquare) === r.KING) {\n\t\t\tkingInCenter = true;\n\t\t\tbreak;\n\t\t}\n\t}\n\n\tif (kingInCenter) {\n\t\tconst colorThatWon: Player = moveutil.getColorThatPlayedMoveIndex(\n\t\t\tbasegame,\n\t\t\tboardsim.moves.length - 1,\n\t\t);\n\t\treturn { victor: colorThatWon, condition: 'koth' };\n\t}\n\n\treturn undefined;\n}\n\n/**\n * Detects if the game is over by, for example, the 50-move rule.\n * @param gamefile - The gamefile\n * @returns `{ victor: 0, condition: 'moverule' }`, if the game is over by the move-rule, otherwise *undefined*.\n */\nfunction detectMoveRule({ boardsim, basegame }: FullGame): GameConclusion | undefined {\n\tif (basegame.gameRules.moveRule === undefined) return undefined; // No move-rule being used\n\tif (boardsim.state.global.moveRuleState === basegame.gameRules.moveRule) {\n\t\treturn { victor: null, condition: 'moverule' };\n\t}\n\treturn undefined;\n}\n\n// Returns true if the very last move captured a royal piece.\nfunction wasLastMoveARoyalCapture(boardsim: Board): boolean | undefined {\n\tconst lastMove = moveutil.getLastMove(boardsim.moves);\n\tif (!lastMove) return undefined;\n\n\tconst capturedTypes = new Set<RawType>();\n\n\tboardchanges.getCapturedPieceTypes(lastMove).forEach((type: number) => {\n\t\tcapturedTypes.add(typeutil.getRawType(type));\n\t});\n\n\tif (capturedTypes.size === 0) return undefined; // Last move not a capture\n\n\t// Vscode or the Node.js environment does NOT have set methods!\n\t// return !capturedTypes.isDisjointFrom(new Set(typeutil.royals)); // disjoint if they share nothing in common\n\t// Check if any captured type is a royal piece.\n\tconst royalSet = new Set<RawType>(typeutil.royals);\n\tfor (const capturedType of capturedTypes) {\n\t\tif (royalSet.has(capturedType)) return true;\n\t}\n\n\treturn false;\n}\n\n/**\n * If the game is multiplayer, or if anyone gets multiple turns in a row, then that allows capturing\n * of the kings no matter the win conditions, by way of one person opening a discovered on turn 1, and\n * another person capturing the king on turn 2 => CHECKMATE NOT COMPATIBLE!\n *\n * Checkmate is also not compatible with games with colinear lines present, because the logic surrounding\n * making opening discovered attacks illegal is a nightmare.\n * @param gamefile\n * @returns true if the gamefile is checkmate compatible\n */\nfunction isCheckmateCompatibleWithGame({ boardsim, basegame }: FullGame): boolean {\n\tif (boardsim.editor) return false; // This prevents legal move calculation respecting check in the editor.\n\tif (boardutil.getPieceCountOfGame(boardsim.pieces) > pieceCountToDisableCheckmate) return false; // Too many pieces (checkmate algorithm takes too long)\n\tif (boardsim.pieces.slides.length > 16) return false; // If the game has more lines than this, then checkmate creates lag spikes.\n\tif (gamefileutility.getPlayerCount(basegame) > 2) return false; // 3+ Players allows for 1 player to open a discovered and a 2nd to capture a king. CHECKMATE NOT COMPATIBLE\n\tif (moveutil.doesAnyPlayerGet2TurnsInARow(basegame)) return false; // This also allows the capture of the king.\n\tif (boardutil.getRoyalCountOfGame(boardsim.pieces) > royalCountToDisableCheckmate) return false; // Too many royals (check & checkmate algorithm takes too long)\n\treturn true; // Checkmate compatible!\n}\n\nexport default {\n\tgetGameConclusion,\n\tisCheckmateCompatibleWithGame,\n};\n"
  },
  {
    "path": "src/shared/chess/util/bdcoords.ts",
    "content": "// src/shared/chess/util/bdcoords.ts\n\nimport type { BDCoords, Coords, DoubleCoords } from './coordutil';\n\nimport { fromBigInt, fromNumber, isInteger, toBigInt, toNumber } from '@naviary/bigdecimal';\n\n// Constructors --------------------------------------------------------------------\n\n/** Converts BigInt Coords to BDCoords (BigDecimal), capable of decimal arithmetic. */\nfunction FromCoords(coords: Coords, precision?: number): BDCoords {\n\treturn [fromBigInt(coords[0], precision), fromBigInt(coords[1], precision)];\n}\n\n/** Converts coordinates of javascript doubles to BDCoords (BigDecimal) */\nfunction FromDoubleCoords(coords: DoubleCoords): BDCoords {\n\treturn [fromNumber(coords[0]), fromNumber(coords[1])];\n}\n\n// Comparisons ------------------------------------------------------------------------\n\n/**\n * Checks if both coordinates in a BDCoords tuple represent perfect integers.\n * This is useful for determining if a point lies exactly on an integer grid.\n * @param coords The BDCoords tuple [x, y] to check.\n * @returns True if both the x and y coordinates are whole numbers.\n */\nfunction areCoordsIntegers(coords: BDCoords): boolean {\n\treturn isInteger(coords[0]) && isInteger(coords[1]);\n}\n\n// Conversion ------------------------------------------------------------------------\n\n/**\n * Converts a pair of bigdecimal coords into normal bigint Coords.\n * THIS WILL LOSE PRECISION if you aren't already confident that both\n * coordinates are integers!\n */\nfunction coordsToBigInt(coords: BDCoords): Coords {\n\t// Convert each coordinate to a BigInt using the toBigInt function.\n\treturn [toBigInt(coords[0]), toBigInt(coords[1])];\n}\n\n/**\n * Converts a pair of bigdecimal coords into DoubleCoords.\n * Only call if you are CONFIDENT all both coordinates won't overflow or underflow!\n */\nfunction coordsToDoubles(coords: BDCoords): DoubleCoords {\n\t// Convert each coordinate to a BigInt using the toBigInt function.\n\treturn [toNumber(coords[0]), toNumber(coords[1])];\n}\n\nexport default {\n\t// Constructors\n\tFromCoords,\n\tFromDoubleCoords,\n\t// Comparisons\n\tareCoordsIntegers,\n\t// Conversion\n\tcoordsToBigInt,\n\tcoordsToDoubles,\n};\n"
  },
  {
    "path": "src/shared/chess/util/boardutil.ts",
    "content": "// src/shared/chess/util/boardutil.ts\n\n/**\n * This script contains utility methods for working with the organized pieces of a game.\n */\n\nimport type { RawType, Player } from './typeutil.js';\nimport type { Coords, CoordsKey } from './coordutil.js';\nimport type { OrganizedPieces, TypeRange } from '../logic/organizedpieces.js';\n\nimport jsutil from '../../util/jsutil.js';\nimport vectors from '../../util/math/vectors.js';\nimport typeutil from './typeutil.js';\nimport coordutil from './coordutil.js';\nimport organizedpieces from '../logic/organizedpieces.js';\nimport bounds, { BoundingBox } from '../../util/math/bounds.js';\n\n// Types ----------------------------------------------------------------------------------------------------\n\ninterface Piece {\n\ttype: number;\n\tcoords: Coords;\n\t/**\n\t * Relative to the start of its type range.\n\t * To get the absolute idx, use boardutil.getAbsoluteIdx.\n\t *\n\t * This will be -1 if the piece does not have an index yet.\n\t * This will get set to another number when it is added to the board.\n\t */\n\tindex: number;\n}\n\n// Counting Pieces ----------------------------------------------------------------------------------------------\n\n/**\n * Counts the number of pieces in the gamefile. Doesn't count undefined placeholders.\n * @param o - The pieces\n * @param [options] - Optional settings.\n * @param [options.ignoreColors] - Whether to ignore certain colors eg p.NEUTRAL.\n * @param [options.ignoreTypes] - Whether to ignore certain types pieces.\n * @returns The number of pieces in the gamefile.\n */\nfunction getPieceCountOfGame(\n\to: OrganizedPieces,\n\t{\n\t\tignoreColors,\n\t\tignoreRawTypes,\n\t}: { ignoreColors?: Set<Player>; ignoreRawTypes?: Set<RawType> } = {},\n): number {\n\t// Early exit optimization: If ignoreColors and ignoreRawTypes are not specified,\n\t// return the size of o.coords, since that has zero undefineds.\n\tif (!ignoreColors && !ignoreRawTypes) return o.coords.size;\n\n\tlet count = 0; // Running count list\n\n\tfor (const [type, range] of o.typeRanges) {\n\t\tif (ignoreColors && ignoreColors.has(typeutil.getColorFromType(type))) continue;\n\t\tif (ignoreRawTypes && ignoreRawTypes.has(typeutil.getRawType(type))) continue;\n\n\t\tcount += getPieceCountOfTypeRange(range);\n\t}\n\n\treturn count;\n}\n\n/**\n * Counts the total number of royal pieces (jumping + sliding) in the game.\n * @param o - The organized pieces data\n * @returns The total number of royal pieces on the board\n */\nfunction getRoyalCountOfGame(o: OrganizedPieces): number {\n\tlet royalCount = 0;\n\n\tfor (const [type, range] of o.typeRanges) {\n\t\tif (!typeutil.royals.includes(typeutil.getRawType(type))) continue; // Not a royal\n\n\t\troyalCount += getPieceCountOfTypeRange(range);\n\t}\n\n\treturn royalCount;\n}\n\n/**\n * Returns the number of pieces of a SPECIFIC color in a game,\n * EXCLUDING undefined placeholders\n */\nfunction getPieceCountOfColor(o: OrganizedPieces, color: Player): number {\n\tlet pieceCount = 0;\n\n\tfor (const [type, range] of o.typeRanges) {\n\t\tconst thisTypesColor = typeutil.getColorFromType(type);\n\t\tif (thisTypesColor !== color) continue; // Different color\n\t\t// Same color! Increment the counter\n\t\tpieceCount += getPieceCountOfTypeRange(range);\n\t}\n\n\treturn pieceCount;\n}\n\n/**\n * Returns the number of pieces in a given type list (e.g. \"pawnsW\"),\n * EXCLUDING undefined placeholders\n * @param o the piece data for the game\n * @param type\n */\nfunction getPieceCountOfType(o: OrganizedPieces, type: number): number {\n\tconst typeList = o.typeRanges.get(type);\n\tif (typeList === undefined) return 0;\n\treturn getPieceCountOfTypeRange(typeList);\n}\n\n/** Excludes undefined placeholders */\nfunction getPieceCountOfTypeRange(range: TypeRange): number {\n\treturn range.end - range.start - range.undefineds.length;\n}\n\n/**\n * Calculates and returns the total number of pieces in the `OrganizedPieces` lists, INCLUDING undefined placeholders.\n */\nfunction getPieceCount_IncludingUndefineds(o: OrganizedPieces): number {\n\treturn o.types.length;\n}\n\n// Getting All Pieces -------------------------------------------------------------------------------------------------\n\n/**\n * Retrieves the coordinates of all pieces.\n * @param o - contains the pieces data.\n * @returns A list of coordinates of all pieces.\n */\nfunction getCoordsOfAllPieces(o: OrganizedPieces): Coords[] {\n\tconst allCoords: Coords[] = [];\n\tfor (const range of o.typeRanges.values()) {\n\t\tgetCoordsOfTypeRange(o, allCoords, range);\n\t}\n\treturn allCoords;\n}\n\n/**\n * Returns an array containing the coordinates of ALL royal pieces of the specified color.\n * @param o - the piece lists\n * @param color - The color of the royals to look for.\n * @returns A list of coordinates where all the royals of the provided color are at.\n */\nfunction getRoyalCoordsOfColor(o: OrganizedPieces, color: Player): Coords[] {\n\tconst royalCoordsList: Coords[] = [];\n\n\ttypeutil.forEachPieceType(\n\t\t(t) => {\n\t\t\tconst range = o.typeRanges.get(t);\n\t\t\tif (range === undefined) return;\n\n\t\t\tgetCoordsOfTypeRange(o, royalCoordsList, range);\n\t\t},\n\t\t[color],\n\t\ttypeutil.royals,\n\t);\n\n\treturn royalCoordsList;\n}\n\n/**\n * O(sqrt(n)) algorithm to get the bounding box of all pieces.\n * Falls back to O(n) if no vertical or horizontal slides are in the game.\n */\nfunction getBoundingBoxOfAllPieces(o: OrganizedPieces): BoundingBox | undefined {\n\tif (o.coords.size === 0) return undefined; // No pieces\n\n\tconst allSlides = Array.from(o.lines.keys());\n\n\t// Find a single vertical slide direction\n\tconst vertSlideKey = allSlides.find((slideKey) => {\n\t\tconst vec = vectors.getVec2FromKey(slideKey);\n\t\treturn vec[0] === 0n;\n\t});\n\n\t// Find a single horizontal slide direction\n\tconst horzSlideKey = allSlides.find((slideKey) => {\n\t\tconst vec = vectors.getVec2FromKey(slideKey);\n\t\treturn vec[1] === 0n;\n\t});\n\n\tif (vertSlideKey === undefined || horzSlideKey === undefined) {\n\t\t// This can happen in practice checkmate 1K3NR-1k.\n\t\t// Only console warn if there is a large number of pieces\n\t\tif (o.coords.size > 1_000_000)\n\t\t\tconsole.warn(\n\t\t\t\t'Falling back to slower O(n) bounding box calculation for all pieces. Either no vertical or horizontal slide found.',\n\t\t\t);\n\t\t// Fallback to O(n) algorithm, we don't have the advantage of organized lines to optimize this.\n\t\tconst allCoords = getCoordsOfAllPieces(o);\n\t\treturn bounds.getBoxFromCoordsList(allCoords);\n\t}\n\n\t// Find the left-most and right-most vertical lines\n\tlet left: bigint | undefined = undefined;\n\tlet right: bigint | undefined = undefined;\n\tconst vertSlide = vectors.getVec2FromKey(vertSlideKey);\n\tfor (const lineKey of o.lines.get(vertSlideKey)!.keys()) {\n\t\tconst C = organizedpieces.getCFromKey(lineKey);\n\t\tconst x = C / -vertSlide[1]; // Reverse engineered vectors.getLineCFromCoordsAndVec() to obtain x\n\t\tif (left === undefined || x < left) left = x;\n\t\tif (right === undefined || x > right) right = x;\n\t}\n\n\t// Find the bottom-most and top-most horizontal lines\n\tlet bottom: bigint | undefined = undefined;\n\tlet top: bigint | undefined = undefined;\n\tconst horzSlide = vectors.getVec2FromKey(horzSlideKey);\n\tfor (const lineKey of o.lines.get(horzSlideKey)!.keys()) {\n\t\tconst C = organizedpieces.getCFromKey(lineKey);\n\t\tconst y = C / horzSlide[0]; // Reverse engineered vectors.getLineCFromCoordsAndVec() to obtain y\n\t\tif (bottom === undefined || y < bottom) bottom = y;\n\t\tif (top === undefined || y > top) top = y;\n\t}\n\n\tif (left === undefined || right === undefined || bottom === undefined || top === undefined)\n\t\tthrow new Error(\n\t\t\t'Failed to calculate bounding box of all pieces. Lines of slide direction was empty (failure of organizedpieces)',\n\t\t);\n\n\treturn { left, right, bottom, top };\n}\n\n/**\n * Returns a list of all the jumping royal pieces of a specific color.\n * @param o the piece lists\n * @param color - The color of the jumping royals to look for.\n * @returns A list of coordinates where all the jumping royals of the provided color are at.\n */\nfunction getJumpingRoyalCoordsOfColor(o: OrganizedPieces, color: Player): Coords[] {\n\tconst royalCoordsList: Coords[] = []; // A running list of all the jumping royals of this color\n\n\ttypeutil.forEachPieceType(\n\t\t(t) => {\n\t\t\tconst range = o.typeRanges.get(t);\n\t\t\tif (range === undefined) return;\n\n\t\t\tgetCoordsOfTypeRange(o, royalCoordsList, range);\n\t\t},\n\t\t[color],\n\t\ttypeutil.jumpingRoyals,\n\t);\n\n\treturn royalCoordsList;\n}\n\n/**\n * Efficiently iterates through every piece in a type range,\n * skipping over undefineds placeholders, executing callback\n * on each piece idx.\n */\nfunction iteratePiecesInTypeRange(\n\to: OrganizedPieces,\n\ttype: number,\n\tcallback: (_idx: number) => void,\n): void {\n\tconst range = o.typeRanges.get(type)!;\n\tlet undefinedidx = 0;\n\tfor (let idx = range.start; idx < range.end; idx++) {\n\t\tif (idx === range.undefineds[undefinedidx]) {\n\t\t\t// Is our next undefined piece entry, skip.\n\t\t\tundefinedidx++;\n\t\t\tcontinue;\n\t\t}\n\t\tcallback(idx);\n\t}\n}\n\n/**\n * Efficiently iterates through every piece in a type range,\n * calculating if each idx is an undefined placeholder.\n */\nfunction iteratePiecesInTypeRange_IncludeUndefineds(\n\to: OrganizedPieces,\n\ttype: number,\n\tcallback: (_idx: number, _isUndefined: boolean) => void,\n): void {\n\tconst range = o.typeRanges.get(type)!;\n\tlet undefinedidx = 0;\n\tfor (let idx = range.start; idx < range.end; idx++) {\n\t\tconst isUndefined = idx === range.undefineds[undefinedidx];\n\t\tif (isUndefined) undefinedidx++;\n\t\tcallback(idx, isUndefined);\n\t}\n}\n\nfunction getCoordsOfTypeRange(o: OrganizedPieces, coords: Coords[], range: TypeRange): void {\n\tlet undefinedidx = 0;\n\tfor (let idx = range.start; idx < range.end; idx++) {\n\t\tif (idx === range.undefineds[undefinedidx]) {\n\t\t\t// Is our next undefined piece entry, skip.\n\t\t\tundefinedidx++;\n\t\t\tcontinue;\n\t\t}\n\t\tcoords.push([o.XPositions[idx]!, o.YPositions[idx]!]);\n\t}\n}\n\n// Getting A Single Piece -------------------------------------------------------------------------------------------------\n\nfunction getCoordsFromIdx(o: OrganizedPieces, idx: number): Coords {\n\treturn [o.XPositions[idx]!, o.YPositions[idx]!];\n}\n\nfunction isIdxUndefinedPiece(o: OrganizedPieces, idx: number): boolean {\n\treturn jsutil.binarySearch(o.typeRanges.get(o.types[idx]!)!.undefineds, idx).found;\n}\n\nfunction getTypeFromCoords(o: OrganizedPieces, coords: Coords): number | undefined {\n\tconst key = coordutil.getKeyFromCoords(coords);\n\tif (!o.coords.has(key)) return undefined;\n\tconst idx = o.coords.get(key)!;\n\treturn o.types[idx]!;\n}\n\nfunction getIdxFromCoords(o: OrganizedPieces, coords: Coords): number | undefined {\n\tconst key = coordutil.getKeyFromCoords(coords);\n\tif (!o.coords.has(key)) return undefined;\n\tconst idx = o.coords.get(key)!;\n\treturn idx;\n}\n\nfunction getPieceFromCoords(o: OrganizedPieces, coords: Coords): Piece | undefined {\n\tconst key = coordutil.getKeyFromCoords(coords);\n\tif (!o.coords.has(key)) return undefined;\n\tconst idx = o.coords.get(key)!;\n\tconst type = o.types[idx]!;\n\treturn {\n\t\ttype,\n\t\tcoords,\n\t\tindex: getRelativeIdx(o, idx),\n\t};\n}\n\nfunction getPieceFromCoordsKey(o: OrganizedPieces, coordsKey: CoordsKey): Piece | undefined {\n\tif (!o.coords.has(coordsKey)) return undefined;\n\tconst idx = o.coords.get(coordsKey)!;\n\tconst type = o.types[idx]!;\n\treturn {\n\t\ttype,\n\t\tcoords: coordutil.getCoordsFromKey(coordsKey),\n\t\tindex: getRelativeIdx(o, idx),\n\t};\n}\n\n/** Returns the relative index of a piece in its type range. */\nfunction getRelativeIdx(o: OrganizedPieces, idx: number): number {\n\treturn idx - o.typeRanges.get(o.types[idx]!)!.start;\n}\n\n/** Reverts the relative-ness of the piece's index to the start of its type range to get its absolute index. */\nfunction getAbsoluteIdx(o: OrganizedPieces, piece: Piece): number {\n\treturn piece.index + o.typeRanges.get(piece.type)!.start;\n}\n\n/**\n * Returns the Piece object of the piece with given idx, or undefined if the\n * idx is an undefined placeholder (has to perform a search to find that out).\n * IF YOU KNOW it's not an undefined placeholder, use {@link getDefinedPieceFromIdx} instead for better performance.\n */\nfunction getPieceFromIdx(o: OrganizedPieces, idx: number): Piece | undefined {\n\tif (isIdxUndefinedPiece(o, idx)) return undefined;\n\treturn getDefinedPieceFromIdx(o, idx);\n}\n\n/**\n * Returns the Piece object of the piece with given idx. MORE PERFORMANT than {@link getPieceFromIdx}.\n * Only call if you know it's not an undefined placeholder.\n */\nfunction getDefinedPieceFromIdx(o: OrganizedPieces, idx: number): Piece {\n\tconst type = o.types[idx]!;\n\treturn {\n\t\ttype,\n\t\tcoords: getCoordsFromIdx(o, idx),\n\t\tindex: getRelativeIdx(o, idx),\n\t};\n}\n\nfunction getTypeRangeFromIdx(o: OrganizedPieces, idx: number): TypeRange {\n\tconst type = o.types[idx];\n\tif (type === undefined) throw Error('Index is out of piece lists');\n\tif (!o.typeRanges.has(type)) throw Error('Typerange is not initialized');\n\n\treturn o.typeRanges.get(type)!;\n}\n\n/** Whether a piece is on the provided coords */\nfunction isPieceOnCoords(o: OrganizedPieces, coords: Coords): boolean {\n\treturn o.coords.has(coordutil.getKeyFromCoords(coords));\n}\n\nexport type { Piece };\n\nexport default {\n\tgetPieceCountOfGame,\n\tgetRoyalCountOfGame,\n\tgetPieceCountOfColor,\n\tgetPieceCountOfType,\n\tgetPieceCountOfTypeRange,\n\tgetPieceCount_IncludingUndefineds,\n\n\tgetCoordsOfAllPieces,\n\tgetJumpingRoyalCoordsOfColor,\n\tgetRoyalCoordsOfColor,\n\tgetBoundingBoxOfAllPieces,\n\titeratePiecesInTypeRange,\n\titeratePiecesInTypeRange_IncludeUndefineds,\n\n\tisIdxUndefinedPiece,\n\tisPieceOnCoords,\n\tgetTypeFromCoords,\n\tgetPieceFromCoords,\n\tgetPieceFromCoordsKey,\n\tgetRelativeIdx,\n\tgetAbsoluteIdx,\n\tgetPieceFromIdx,\n\tgetDefinedPieceFromIdx,\n\tgetCoordsFromIdx,\n\tgetTypeRangeFromIdx,\n\tgetIdxFromCoords,\n};\n"
  },
  {
    "path": "src/shared/chess/util/clockutil.ts",
    "content": "// src/shared/chess/util/clockutil.ts\n\n/**\n * The clock value for the game, `s+s`, where the left side is\n * start time in seconds, and the right is increment in seconds.\n * Untimed = `-`\n */\n\nimport type { TimeControl } from '../../types.js';\n\nfunction getTextContentFromTimeRemain(time: number): string {\n\tlet seconds = Math.ceil(time / 1000);\n\tlet minutes = 0;\n\twhile (seconds >= 60) {\n\t\tseconds -= 60;\n\t\tminutes++;\n\t}\n\tif (seconds < 0) seconds = 0;\n\n\treturn `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;\n}\n\n/**\n * Returns true if the clock value is infinite. Internally, untimed games are represented with a \"-\".\n * @param clock - The clock value (e.g. \"10+5\").\n * @returns *true* if it's infinite.\n */\nfunction isClockValueInfinite(clock: TimeControl): boolean {\n\treturn clock === '-';\n}\n\n/**\n * Returns the clock in a slightly more human-readable format: `10m+5s`\n * @param key - The clock string: `600+5`, where the left is the start time in seconds, right is increment in seconds.\n * @returns\n */\nfunction getClockFromKey(key: TimeControl): string {\n\t// ssss+ss  converted to  15m+15s\n\tconst minutesAndIncrement = getMinutesAndIncrementFromClock(key);\n\tif (minutesAndIncrement === null) return translations['no_clock'];\n\treturn `${minutesAndIncrement.minutes}m+${minutesAndIncrement.increment}s`;\n}\n\n/**\n * Splits the clock from the form `10+5` into the `minutes` and `increment` properties.\n * If it is an untimed game (represented by `-`), then this will return null.\n * @param clock - The string representing the clock value: `10+5`\n * @returns An object with 2 properties: `minutes`, `increment`, or `null` if the clock is infinite.\n */\nfunction getMinutesAndIncrementFromClock(\n\tclock: TimeControl,\n): null | { minutes: number; increment: number } {\n\tif (isClockValueInfinite(clock)) return null;\n\tconst [seconds, increment] = clock.split('+').map((part) => +part) as [number, number]; // Convert them into a number\n\tconst minutes = seconds / 60;\n\treturn { minutes, increment };\n}\n\n/**\n * Splits the clock from the form `s+s` into the `base_time_seconds` and `increment_seconds` properties.\n * @param time_control\n * @returns\n */\nfunction splitTimeControl(time_control: TimeControl): {\n\tbase_time_seconds: number | null;\n\tincrement_seconds: number | null;\n} {\n\t// Check for the untimed indicator first\n\tif (time_control === '-') return { base_time_seconds: null, increment_seconds: null };\n\t// Split the time control string into base time and increment\n\tconst [base_time_seconds, increment_seconds] = time_control.split('+').map((part) => +part) as [\n\t\tnumber,\n\t\tnumber,\n\t]; // Convert them into a number\n\t// Throw error if either of them are Nan, or negative\n\tif (\n\t\tisNaN(base_time_seconds) ||\n\t\tisNaN(increment_seconds) ||\n\t\tbase_time_seconds <= 0 ||\n\t\tincrement_seconds < 0\n\t)\n\t\tthrow new Error(`Invalid time control: ${time_control}`);\n\treturn { base_time_seconds, increment_seconds };\n}\n\nexport default {\n\tgetTextContentFromTimeRemain,\n\tisClockValueInfinite,\n\tgetClockFromKey,\n\tgetMinutesAndIncrementFromClock,\n\tsplitTimeControl,\n};\n"
  },
  {
    "path": "src/shared/chess/util/coordutil.ts",
    "content": "// src/shared/chess/util/coordutil.ts\n\n/**\n * This script contains utility methods for working with coordinates [x,y].\n *\n * ZERO dependancies.\n */\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\n// Types -----------------------------------------------------------------------\n\n/**\n * A length-2 array of coordinates: `[x,y]`\n * Contains infinite precision integers, represented as BigInt.\n */\ntype Coords = [bigint, bigint];\n\n/**\n * A pair of arbitrarily large coordinates WITH decimal precision included.\n * Typically used for calculating graphics on the cpu-side.\n * BD = BigDecimal\n */\ntype BDCoords = [BigDecimal, BigDecimal];\n\n/** For when we don't need arbitrary size. */\ntype DoubleCoords = [number, number];\n\n/**\n * A pair of coordinates, represented in a string, separated by a `,`.\n *\n * This is often used as the key for a piece in piece lists.\n *\n * This will never be in scientific notation. However, moves beyond\n * Number.MAX_SAFE_INTEGER can't be expressed exactly.\n */\ntype CoordsKey = `${bigint},${bigint}`;\n\n// Functions -------------------------------------------------------------------\n\n/** Returns the key string of the coordinates: [x,y] => 'x,y' */\nfunction getKeyFromCoords(coords: Coords): CoordsKey {\n\treturn `${coords[0]},${coords[1]}`;\n}\n\n/**\n * Returns a length-2 array of the provided coordinates\n * @param key - 'x,y'\n * @returns The coordinates of the piece, [x,y]\n */\nfunction getCoordsFromKey(key: CoordsKey): Coords {\n\treturn key.split(',').map(BigInt) as Coords;\n}\n\n/**  Returns true if the coordinates are equal. */\nfunction areCoordsEqual(coord1: Coords, coord2: Coords): boolean {\n\treturn coord1[0] === coord2[0] && coord1[1] === coord2[1];\n}\n\n/** Returns true if the BigDecimal coordinates are equal. */\nfunction areBDCoordsEqual(coord1: BDCoords, coord2: BDCoords): boolean {\n\treturn bd.areEqual(coord1[0], coord2[0]) && bd.areEqual(coord1[1], coord2[1]);\n}\n\n/**\n * Adds two coordinate pairs together component-wise.\n */\nfunction addCoords(coord1: Coords, coord2: Coords): Coords {\n\treturn [coord1[0] + coord2[0], coord1[1] + coord2[1]];\n}\n\n/** Adds two BigDecimal coordinates together. */\nfunction addBDCoords(coord1: BDCoords, coord2: BDCoords): BDCoords {\n\treturn [bd.add(coord1[0], coord2[0]), bd.add(coord1[1], coord2[1])];\n}\n\n/**\n * Subtracts two coordinate pairs together component-wise.\n * @param minuendCoord - The first coordinate pair [x1, y1] to start with.\n * @param subtrahendCoord - The second coordinate pair [x2, y2] to subtract from the minuend.\n * @returns The resulting coordinate pair after subtracting.\n */\nfunction subtractCoords(minuendCoord: Coords, subtrahendCoord: Coords): Coords {\n\treturn [minuendCoord[0] - subtrahendCoord[0], minuendCoord[1] - subtrahendCoord[1]];\n}\n\n/**\n * Subtracts two coordinate pairs together component-wise.\n * @param minuendCoord - The first coordinate pair [x1, y1] to start with.\n * @param subtrahendCoord - The second coordinate pair [x2, y2] to subtract from the minuend.\n * @returns The resulting coordinate pair after subtracting.\n */\nfunction subtractBDCoords(minuendCoord: BDCoords, subtrahendCoord: BDCoords): BDCoords {\n\treturn [\n\t\tbd.subtract(minuendCoord[0], subtrahendCoord[0]),\n\t\tbd.subtract(minuendCoord[1], subtrahendCoord[1]),\n\t];\n}\n\n/**\n * Subtracts two coordinate pairs together component-wise.\n * @param minuendCoord - The first coordinate pair [x1, y1] to start with.\n * @param subtrahendCoord - The second coordinate pair [x2, y2] to subtract from the minuend.\n * @returns The resulting coordinate pair after subtracting.\n */\nfunction subtractDoubleCoords(\n\tminuendCoord: DoubleCoords,\n\tsubtrahendCoord: DoubleCoords,\n): DoubleCoords {\n\treturn [minuendCoord[0] - subtrahendCoord[0], minuendCoord[1] - subtrahendCoord[1]];\n}\n\n/**\n * Makes a deep copy of the provided coordinates\n */\nfunction copyCoords(coords: Coords): Coords {\n\treturn [...coords] as Coords;\n}\n\n/**\n * Makes a deep copy of the provided BigDecimal coordinates\n */\nfunction copyBDCoords(coords: BDCoords): BDCoords {\n\treturn [bd.clone(coords[0]), bd.clone(coords[1])];\n}\n\n/**\n * [FLOATING] Interpolates between two coordinates.\n * Fixed mantissa bit number.\n * Doesn't work well for very large distances\n * if you also need high decimal precision.\n * @param start - The starting coordinate.\n * @param end - The ending coordinate.\n * @param t - The interpolation value (between 0 and 1).\n */\nfunction lerpCoords(start: BDCoords, end: BDCoords, t: number): BDCoords {\n\tconst bddiff: BDCoords = subtractBDCoords(end, start);\n\tconst bdt: BigDecimal = bd.fromNumber(t);\n\t// console.log('bdt:', bd.toApproximateString(bdt), 't:', t);\n\tconst travelX = bd.multiplyFloating(bddiff[0], bdt);\n\tconst travelY = bd.multiplyFloating(bddiff[1], bdt);\n\n\treturn [bd.add(start[0], travelX), bd.add(start[1], travelY)];\n}\n\n/**\n * {@link lerpCoords} but for DoubleCoords.\n */\nfunction lerpCoordsDouble(start: DoubleCoords, end: DoubleCoords, t: number): DoubleCoords {\n\tconst diffX = end[0] - start[0];\n\tconst diffY = end[1] - start[1];\n\tconst travelX = diffX * t;\n\tconst travelY = diffY * t;\n\n\treturn [start[0] + travelX, start[1] + travelY];\n}\n\n// Debugging --------------------------------------------------------------------\n\n/** [DEBUG] Stringifies a pair of bigint coordinates into a human-readable string. */\nfunction stringifyCoords(coords: Coords): string {\n\treturn `(${coords[0]}, ${coords[1]})`;\n}\n\n/** [DEBUG] Stringifies a pair of BigDecimal coordinates into their exact representation. SLOW. */\nfunction stringifyBDCoords(coords: BDCoords): string {\n\t// return `(${bd.toNumber(coords[0])}, ${bd.toNumber(coords[1])})`;\n\treturn `(${bd.toExactString(coords[0])}, ${bd.toExactString(coords[1])})`;\n\t// return `(${bd.toApproximateString(coords[0])}, ${bd.toApproximateString(coords[1])})`;\n}\n\n// Exports --------------------------------------------------------------------\n\nexport default {\n\tgetKeyFromCoords,\n\tgetCoordsFromKey,\n\tareCoordsEqual,\n\tareBDCoordsEqual,\n\taddCoords,\n\taddBDCoords,\n\tsubtractCoords,\n\tsubtractBDCoords,\n\tsubtractDoubleCoords,\n\tcopyCoords,\n\tcopyBDCoords,\n\tlerpCoords,\n\tlerpCoordsDouble,\n\t// Debugging\n\tstringifyCoords,\n\tstringifyBDCoords,\n};\n\nexport type { Coords, BDCoords, DoubleCoords, CoordsKey };\n"
  },
  {
    "path": "src/shared/chess/util/gamefileutility.ts",
    "content": "// src/shared/chess/util/gamefileutility.ts\n\n/**\n * This script contains many utility methods for working with gamefiles.\n */\n\nimport type { Coords } from './coordutil.js';\nimport type { Player } from './typeutil.js';\nimport type { Game, Board, FullGame } from '../logic/gamefile.js';\nimport type { GameruleWinCondition, GameConclusion } from './winconutil.js';\n\nimport typeutil from './typeutil.js';\nimport moveutil from './moveutil.js';\nimport gamerules from './gamerules.js';\nimport winconutil from './winconutil.js';\nimport metadatautil from './metadatautil.js';\nimport wincondition from '../logic/wincondition.js'; // THIS IS ONLY USED FOR GAME-OVER CHECKMATE TESTS and inflates this files dependancy list!!!\n\n// Methods -------------------------------------------------------------\n\n/** Returns true if the game is over. */\nfunction isGameOver(basegame: Game): boolean {\n\treturn basegame.gameConclusion !== undefined;\n}\n\n/**\n * Returns true if the currently-viewed position of the game file is in check\n */\nfunction isCurrentViewedPositionInCheck(boardsim: Board): boolean {\n\treturn boardsim.state.local.inCheck !== false;\n}\n\n/**\n * Returns a list of coordinates of all royals\n * in check in the currently-viewed position.\n */\nfunction getCheckCoordsOfCurrentViewedPosition(boardsim: Board): Coords[] {\n\treturn boardsim.state.local.inCheck || []; // Return an empty array if we're not in check.\n}\n\n/**\n * Sets the conclusion of the game, and sets/clears\n * the `Termination` `Result` and metadata accordingly.\n * If the conclusion is undefined, it removes the metadata,\n * essentially un-concluding the game if it was already concluded.\n */\nfunction setConclusion(basegame: Game, conclusion: GameConclusion | undefined): void {\n\tbasegame.gameConclusion = conclusion;\n\n\tif (conclusion !== undefined) {\n\t\tbasegame.metadata.Termination = winconutil.getTerminationInEnglish(\n\t\t\tbasegame.gameRules,\n\t\t\tconclusion.condition,\n\t\t);\n\t\tbasegame.metadata.Result = metadatautil.getResultFromVictor(conclusion.victor);\n\t} else {\n\t\tdelete basegame.metadata.Result;\n\t\tdelete basegame.metadata.Termination;\n\t}\n}\n\n/**\n * Tests if the color's opponent can win from the specified win condition.\n * @param basegame\n * @param friendlyColor - The color of friendlies.\n * @param winCondition - The win condition to check against.\n * @returns True if the opponent can win from the specified win condition, otherwise false.\n */\nfunction isOpponentUsingWinCondition(\n\tbasegame: Game,\n\tfriendlyColor: Player,\n\twinCondition: GameruleWinCondition,\n): boolean {\n\tconst oppositeColor = typeutil.invertPlayer(friendlyColor)!;\n\treturn gamerules.doesColorHaveWinCondition(basegame.gameRules, oppositeColor, winCondition);\n}\n\n// FUNCTIONS THAT SHOULD BE MOVED ELSEWHERE!!!!! They introduce too many dependancies ----------------------------------!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n/**\n * Tests if the game is over by the used win condition, andif so,\n * sets the `gameConclusion` property according to how the game was terminated,\n * and adds the respective mate flag on the last move played.\n */\nfunction doGameOverChecks(gamefile: FullGame): void {\n\tconst conclusion = wincondition.getGameConclusion(gamefile);\n\tsetConclusion(gamefile.basegame, conclusion);\n\tif (conclusion !== undefined && winconutil.isConclusionMoveTriggered(conclusion.condition))\n\t\tmoveutil.flagLastMoveAsMate(gamefile.boardsim);\n}\n\n/** Returns the number of players in the game (unique players in the turnOrder). */\nfunction getPlayerCount(basegame: Game): number {\n\treturn new Set(basegame.gameRules.turnOrder).size;\n}\n\n/** Calculates the unique players in the turn order, in the order they appear. */\nfunction getUniquePlayersInTurnOrder(turnOrder: Player[]): Player[] {\n\t// Using a Set removes duplicates before converting to an array\n\treturn [...new Set(turnOrder)];\n}\n\n// ---------------------------------------------------------------------------------------------------------------------!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\nexport default {\n\tisGameOver,\n\tisCurrentViewedPositionInCheck,\n\tgetCheckCoordsOfCurrentViewedPosition,\n\tsetConclusion,\n\tisOpponentUsingWinCondition,\n\tdoGameOverChecks,\n\tgetPlayerCount,\n\tgetUniquePlayersInTurnOrder,\n};\n"
  },
  {
    "path": "src/shared/chess/util/gamerules.ts",
    "content": "// src/shared/chess/util/gamerules.ts\n\n/**\n * This script contains the GameRules interface definition,\n * and contains utility methods for working with them.\n */\n\nimport type { UnboundedRectangle } from '../../util/math/bounds.js';\nimport type { GameruleWinCondition } from './winconutil.js';\nimport type { Player, RawType, PlayerGroup } from './typeutil.js';\n\ninterface GameRules {\n\t/** An object containing lists of what win conditions each color can win by. */\n\twinConditions: PlayerGroup<GameruleWinCondition[]>;\n\t/** A list of players that make up one full turn cycle. */\n\tturnOrder: Player[];\n\t/**\n\t * Contains a list of all promotion ranks each color promotes at, if they can promote.\n\t * If neither side can promote, this should be left as undefined.\n\t */\n\tpromotionRanks?: PlayerGroup<bigint[]>;\n\t/**\n\t * An object containing arrays of raw types white and\n\t * black can promote to, if it's legal for them to promote.\n\t * If one color can't promote, their list should be left undefined.\n\t */\n\tpromotionsAllowed?: PlayerGroup<RawType[]>;\n\t/**\n\t * How many plies (half-moves) can pass with no\n\t * captures or pawn pushes until a draw is declared.\n\t * Also known as the \"50-move rule\".\n\t */\n\tmoveRule?: number;\n\t/** The maximum number of steps any sliding piece can take. */\n\tslideLimit?: bigint;\n\t/**\n\t * IF a world border is present, this is a bounding box\n\t * containing all integer coordinates that are inside the\n\t * playing area, not on or outside the world border.\n\t * All pieces must be within this box.\n\t * The inclusive playable region of the board.\n\t */\n\tworldBorder?: UnboundedRectangle;\n}\n\n/** Checks if a specified color has a given win condition. */\nfunction doesColorHaveWinCondition(\n\tgameRules: GameRules,\n\tcolor: Player,\n\twinCondition: GameruleWinCondition,\n): boolean {\n\treturn !!gameRules.winConditions[color]?.includes(winCondition);\n\t// The `!!` converts the result (true/false/undefined) strictly to boolean (true/false).\n}\n\n/** Gets the count of win conditions for a specified color in the game rules. */\nfunction getWinConditionCountOfColor(gameRules: GameRules, player: Player): number {\n\treturn gameRules.winConditions[player]?.length ?? 0;\n}\n\n/**\n * Swaps the \"checkmate\" win condition for \"royalcapture\" in the gameRules if applicable.\n * Modifies the gameRules object in place.\n */\nfunction swapCheckmateForRoyalCapture(gameRules: GameRules): void {\n\tlet changeMade = false;\n\tfor (const winConditions of Object.values(gameRules.winConditions)) {\n\t\t// Remove \"checkmate\" if it exists\n\t\tconst indexOf = winConditions.indexOf('checkmate');\n\t\tif (indexOf !== -1) {\n\t\t\twinConditions.splice(indexOf, 1); // Remove \"checkmate'\"\n\t\t\twinConditions.push('royalcapture'); // Add \"royalcapture\"\n\t\t\tchangeMade = true;\n\t\t}\n\t}\n\n\tif (changeMade) console.log('Swapped checkmate win conditions for royalcapture.');\n}\n\nexport default {\n\tdoesColorHaveWinCondition,\n\tgetWinConditionCountOfColor,\n\tswapCheckmateForRoyalCapture,\n};\n\nexport type { GameRules };\n"
  },
  {
    "path": "src/shared/chess/util/metadatautil.ts",
    "content": "// src/shared/chess/util/metadatautil.ts\n\n/**\n * This script stores the type definition for a game's metadata.\n *\n * ICN (Infinite Chess Notation) is inspired from PGN notation.\n * https://github.com/tsevasa/infinite-chess-notation\n */\n\nimport type { Player } from './typeutil.js';\nimport type { MetaData, Rating } from '../../types.js';\n\nimport { players as p } from './typeutil.js';\n\n// Types --------------------------------------------------------------------------\n\n/** All valid metadata names. */\nexport type MetadataKey = keyof MetaData;\n\n// Constants -----------------------------------------------------------------------\n\n/** Canonical display name used for guest players in ICN metadata. Metadata is always in English. */\nconst GUEST_NAME_ICN_METADATA = '(Guest)' as const;\n\n// Functions -----------------------------------------------------------------------\n\n/**\n * Returns the value of the game's Result metadata, depending on the victor.\n * @param victor - The victor of the game, in player number. Or none if undefined.\n * @returns The result of the game in the format '1-0', '0-1', '1/2-1/2', or '*' (aborted).\n */\nfunction getResultFromVictor(victor?: Player | null): string {\n\tif (victor === p.WHITE) return '1-0';\n\telse if (victor === p.BLACK) return '0-1';\n\telse if (victor === null) return '1/2-1/2';\n\telse if (victor === undefined) return '*';\n\tthrow new Error(`Cannot get game result from unsupported victor ${victor}!`);\n}\n\n/** Rounds the elo. And, if we're not confident about its value, appends a question mark \"?\" to it. */\nfunction getFormattedElo(rating: Rating): string {\n\tconst roundedElo = Math.round(rating.value);\n\treturn rating.confident ? `${roundedElo}` : `${roundedElo}?`;\n}\n\n/**\n * Takes elo change, calculates the string that should go into\n * the WhiteRatingDiff or BlackRatingDiff fields of the metadata.\n */\nfunction getWhiteBlackRatingDiff(eloChange: number): string {\n\tconst isPositive = eloChange >= 0;\n\teloChange = Math.round(eloChange);\n\treturn isPositive ? `+${eloChange}` : `${eloChange}`; // negative numbers are already negative\n}\n\nexport default {\n\tGUEST_NAME_ICN_METADATA,\n\tgetResultFromVictor,\n\tgetFormattedElo,\n\tgetWhiteBlackRatingDiff,\n};\n"
  },
  {
    "path": "src/shared/chess/util/moveutil.ts",
    "content": "// src/shared/chess/util/moveutil.ts\n\n/**\n * This script contains utility methods for working with the gamefile's moves list.\n */\n\nimport type { Coords } from './coordutil.js';\nimport type { Player } from './typeutil.js';\nimport type { MoveFull } from '../logic/movepiece.js';\nimport type { MoveCoords } from '../logic/icn/icnconverter.js';\nimport type { Game, Board } from '../logic/gamefile.js';\nimport type { CoordsTagged } from '../logic/movepiece.js';\n\nimport coordutil from './coordutil.js';\n\n// Functions ------------------------------------------------------------------------------\n\n/**\n * Returns *true* if it is legal to forward the provided gamefile by 1 move, *false* if we're at the front of the game.\n */\nfunction isIncrementingLegal(boardsim: Board): boolean {\n\tconst incrementedIndex = boardsim.state.local.moveIndex + 1;\n\treturn !isIndexOutOfRange(boardsim.moves, incrementedIndex);\n}\n\n/**\n * Returns *true* if it is legal to rewind the provided gamefile by 1 move, *false* if we're at the beginning of the game.\n */\nfunction isDecrementingLegal(boardsim: Board): boolean {\n\tconst decrementedIndex = boardsim.state.local.moveIndex - 1;\n\treturn !isIndexOutOfRange(boardsim.moves, decrementedIndex);\n}\n\n/**\n * Tests if the provided index is out of range of the moves list length\n */\nfunction isIndexOutOfRange(moves: MoveCoords[], index: number): boolean {\n\treturn index < -1 || index >= moves.length;\n}\n\n/**\n * Returns the very last move played in the moves list, if there is one. Otherwise, returns undefined.\n */\nfunction getLastMove(moves: MoveFull[]): MoveFull | undefined {\n\tconst finalIndex = moves.length - 1;\n\tif (finalIndex < 0) return;\n\treturn moves[finalIndex];\n}\n\n/**\n * Returns the move we're currently viewing in the provided gamefile.\n */\nfunction getCurrentMove(boardsim: Board): MoveFull | undefined {\n\tconst index = boardsim.state.local.moveIndex;\n\tif (index < 0) return;\n\treturn boardsim.moves[index];\n}\n\n/**\n * Gets the move from the moves list at the specified index\n */\nfunction getMoveFromIndex(moves: MoveFull[], index: number): MoveFull {\n\tif (isIndexOutOfRange(moves, index)) throw Error('Cannot get next move when index overflow');\n\treturn moves[index]!;\n}\n\n/**\n * Tests if the provided gamefile is viewing the front of the game, or the latest move.\n */\nfunction areWeViewingLatestMove(boardsim: Board): boolean {\n\tconst moveIndex = boardsim.state.local.moveIndex;\n\tconst finalIndex = boardsim.moves.length - 1;\n\treturn moveIndex === finalIndex;\n}\n\n/**\n * Returns total ply count (or half-moves) of the game so far.\n */\nfunction getPlyCount(moves: MoveFull[]): number {\n\treturn moves.length;\n}\n\n/**\n * Flags the gamefile's very last move as a \"mate\".\n */\nfunction flagLastMoveAsMate(boardsim: Board): void {\n\tconst lastMove = getLastMove(boardsim.moves);\n\tif (lastMove === undefined) return; // No moves, can't flag last move as mate (this can happen when pasting a game that's over)\n\tlastMove.flags.mate = true;\n}\n\n/**\n * Returns whether the game is resignable (at least 2 moves have been played).\n * If not, then the game is considered abortable.\n */\nfunction isGameResignable(game: Game | Board): boolean {\n\treturn game.moves.length > 1;\n}\n\n/**\n * Returns the color of the player that played the provided index within the moves list.\n */\nfunction getColorThatPlayedMoveIndex(basegame: Game, index: number): Player {\n\tconst turnOrder = basegame.gameRules.turnOrder;\n\t// If the starting position of the game is in check, then the player very last in the turnOrder is considered the one who *gave* the check.\n\tif (index === -1) return turnOrder[turnOrder.length - 1]!;\n\treturn turnOrder[index % turnOrder.length]!;\n}\n\n/**\n * Returns the color whos turn it is after the specified move index was played.\n */\nfunction getWhosTurnAtMoveIndex(basegame: Game, moveIndex: number): Player {\n\treturn getColorThatPlayedMoveIndex(basegame, moveIndex + 1);\n}\n\n/**\n * Returns true if any player in the turn order ever gets to turn in a row.\n */\nfunction doesAnyPlayerGet2TurnsInARow(basegame: Game): boolean {\n\t// If one player ever gets 2 turns in a row, then that also allows the capture of the king.\n\tconst turnOrder = basegame.gameRules.turnOrder;\n\tfor (let i = 0; i < turnOrder.length; i++) {\n\t\tconst thisColor = turnOrder[i];\n\t\tconst nextColorIndex = i === turnOrder.length - 1 ? 0 : i + 1; // If the color is last, then the next color is the first color of the turn order.\n\t\tconst nextColor = turnOrder[nextColorIndex];\n\t\tif (thisColor === nextColor) return true;\n\t}\n\treturn false;\n}\n\n/**\n * Strips the coordinates of any special move properties. NON-MUTATING, returns new coords.\n */\nfunction stripSpecialMoveTagsFromCoords(coords: CoordsTagged): Coords {\n\treturn coordutil.copyCoords(coords); // Does not copy non-enumerable properties\n}\n\n// ------------------------------------------------------------------------------\n\nexport default {\n\tisIncrementingLegal,\n\tisDecrementingLegal,\n\tgetLastMove,\n\tgetCurrentMove,\n\tgetMoveFromIndex,\n\tareWeViewingLatestMove,\n\tgetPlyCount,\n\tflagLastMoveAsMate,\n\tisGameResignable,\n\tgetColorThatPlayedMoveIndex,\n\tgetWhosTurnAtMoveIndex,\n\tdoesAnyPlayerGet2TurnsInARow,\n\tstripSpecialMoveTagsFromCoords,\n};\n"
  },
  {
    "path": "src/shared/chess/util/typeutil.ts",
    "content": "// src/shared/chess/util/typeutil.ts\n\n/**\n * This script contains lists of all piece types and players,\n * and utility methods for working with them.\n */\n\nimport * as z from 'zod';\n\n// Constants --------------------------------------------------------------------------------\n\n/**\n * Every raw type of piece supported in the game.\n *\n * This exact arrangement affects the order of which\n * the checkmate algorithm searches for legal moves,\n * and it affects the order the miniimages of the\n * pieces are rendered when zoomed out.\n */\nconst rawTypes = {\n\tVOID: 0,\n\tOBSTACLE: 1,\n\tKING: 2,\n\tGIRAFFE: 3,\n\tCAMEL: 4,\n\tZEBRA: 5,\n\tKNIGHTRIDER: 6,\n\tAMAZON: 7,\n\tQUEEN: 8,\n\tROYALQUEEN: 9,\n\tHAWK: 10,\n\tCHANCELLOR: 11,\n\tARCHBISHOP: 12,\n\tCENTAUR: 13,\n\tROYALCENTAUR: 14,\n\tROSE: 15,\n\tKNIGHT: 16,\n\tGUARD: 17,\n\tHUYGEN: 18,\n\tROOK: 19,\n\tBISHOP: 20,\n\tPAWN: 21,\n} as const;\n\nconst neutralRawTypes: RawType[] = [rawTypes.VOID, rawTypes.OBSTACLE];\n\n/** All player colors suppored in the game. Multiply the raw type by this to get the colored type. */\nconst players = {\n\tNEUTRAL: 0,\n\tWHITE: 1,\n\tBLACK: 2,\n\t// Colored players\n\tRED: 3,\n\tBLUE: 4,\n\tYELLOW: 5,\n\tGREEN: 6,\n} as const;\n\nconst numTypes = Object.keys(rawTypes).length;\n\n/** Color extensions of all players. Add this to a raw type to get the colored type. */\nconst ext = {\n\tN: players.NEUTRAL * numTypes,\n\tW: players.WHITE * numTypes,\n\tB: players.BLACK * numTypes,\n\t// Colored players\n\tR: players.RED * numTypes,\n\tBU: players.BLUE * numTypes,\n\tY: players.YELLOW * numTypes,\n\tG: players.GREEN * numTypes,\n} as const;\n\n/**\n * The string representations of each raw type.\n *\n * MUST BE IN THE EXACT SAME ORDER AS {@link rawTypes}!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n */\nconst strtypes = [\n\t'void',\n\t'obstacle',\n\t'king',\n\t'giraffe',\n\t'camel',\n\t'zebra',\n\t'knightrider',\n\t'amazon',\n\t'queen',\n\t'royalQueen',\n\t'hawk',\n\t'chancellor',\n\t'archbishop',\n\t'centaur',\n\t'royalCentaur',\n\t'rose',\n\t'knight',\n\t'guard',\n\t'huygen',\n\t'rook',\n\t'bishop',\n\t'pawn',\n] as const;\n\n/** A list of the royals that are compatible with checkmate. If a royal can slide, DO NOT put it in here, put it in {@link slidingRoyals} instead! */\nconst jumpingRoyals: RawType[] = [rawTypes.KING, rawTypes.ROYALCENTAUR];\n/**\n * A list of the royals that the checkmate algorithm cannot detect when they are in checkmate,\n * however it still is illegal to move into check.\n *\n * Players have to voluntarily resign if they\n * believe their sliding royal is in checkmate.\n */\nconst slidingRoyals: RawType[] = [rawTypes.ROYALQUEEN];\n/**\n * A list of the royal pieces, without the color appended.\n * THIS SHOULD NOT CONTAIN DUPLICATES\n */\nconst royals: RawType[] = [...jumpingRoyals, ...slidingRoyals];\n\n/**\n * The string representations of each player color.\n *\n * MUST BE IN THE EXACT SAME ORDER AS {@link players}!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n */\nconst strcolors = ['neutral', 'white', 'black', 'red', 'blue', 'yellow', 'green'] as const;\n\n/** Raw piece types that don't have an SVG */\nconst SVGLESS_TYPES: Set<RawType> = new Set([rawTypes.VOID]);\n\n// Zod Schemas --------------------------------------------------------------------------------\n\n/** Zod schema for a player color. */\nconst PlayerSchema = z.literal(Object.values(players));\n\n/** Returns the Zod schema corresponding to {@link PlayerGroup}, accepting the schema of the values as an argument. */\nfunction GenPlayerGroupSchema<T extends z.ZodTypeAny>(\n\tvalueSchema: T,\n): z.ZodObject<{ [K in Player]: z.ZodOptional<T> }> {\n\tconst shape = Object.fromEntries(\n\t\tObject.values(players).map((p) => [p, valueSchema.optional()]),\n\t);\n\treturn z.strictObject(shape as { [K in Player]: z.ZodOptional<T> });\n}\n\n// Types --------------------------------------------------------------------------------------\n\ntype StrPlayer = (typeof strcolors)[number];\ntype RawType = (typeof rawTypes)[keyof typeof rawTypes];\ntype Player = (typeof players)[keyof typeof players];\n\n/** A dictionary type with raw types for keys */\ntype RawTypeGroup<T> = {\n\t[_t in RawType]?: T;\n};\n\n/** A dictionary type with all types for keys */\ntype TypeGroup<T> = { [t: number]: T };\n\n/** A dictionary type with player colors for keys */\ntype PlayerGroup<T> = {\n\t[_p in Player]?: T;\n};\n\n// Functions --------------------------------------------------------------------------------\n\nfunction getRawType(type: number): RawType {\n\treturn (type % numTypes) as RawType;\n}\n\nfunction getColorFromType(type: number): Player {\n\treturn Math.floor(type / numTypes) as Player;\n}\n\nfunction buildType(type: RawType, color: Player): number {\n\treturn type + color * numTypes;\n}\n\n/** Splits a type into its raw type and player */\nfunction splitType(type: number): [RawType, Player] {\n\treturn [getRawType(type), getColorFromType(type)];\n}\n\n/** Repeats each rawTypes for player color provided. */\nfunction buildAllTypesForPlayers(players: Player[], rawTypes: RawType[]): number[] {\n\tconst builtTypes: number[] = [];\n\tfor (let i = players.length - 1; i >= 0; i--) {\n\t\tfor (const r of rawTypes) {\n\t\t\tbuiltTypes.push(buildType(r, players[i]!));\n\t\t}\n\t}\n\treturn builtTypes;\n}\n\nfunction forEachPieceType(\n\tcallback: (_pieceType: number) => void,\n\tplayers: Player[],\n\tincludePieces: RawType[],\n): void {\n\tfor (let i = players.length - 1; i >= 0; i--) {\n\t\tfor (const r of includePieces) {\n\t\t\tcallback(buildType(r, players[i]!));\n\t\t}\n\t}\n}\n\n/** Inverts the type so it belongs to the opposite color. */\nfunction invertType(type: number): number {\n\tconst [r, p] = splitType(type);\n\tconst newp = invertPlayer(p); // This will throw an error if the type is not invertible because of its color. (We should never attempt to invert it anyway)\n\treturn buildType(r, newp);\n}\n\n/**\n * Inverts the player id.\n * Neutral gets inverted to neutral.\n */\nfunction invertPlayer(player: Player): Player {\n\t// prettier-ignore\n\treturn player === players.NEUTRAL ? players.NEUTRAL :\n\t\tplayer === players.WHITE ? players.BLACK :\n\t\tplayer === players.BLACK ? players.WHITE :\n\t\t((): never => { throw Error(`Cannot invert player ${player}!`); })(); // No downsides to adding this, only more protection.\n}\n\nfunction getRawTypeStr(type: RawType): string {\n\treturn strtypes[type];\n}\n\nfunction getPlayerFromString(string: StrPlayer): Player {\n\treturn strcolors.indexOf(string) as Player;\n}\n\n/**\n * Deletes for pieces that aren't included in this game.\n */\nfunction deleteUnusedFromRawTypeGroup<T>(\n\texistingRawTypes: RawType[],\n\texclude: RawTypeGroup<T>,\n): void {\n\tfor (const key in exclude) {\n\t\tconst rawType = Number(key) as RawType;\n\t\tif (!existingRawTypes.includes(rawType)) delete exclude[rawType];\n\t}\n}\n\n/**\n * Returns the english string of a piece type.\n * 30 => \"[30] queen(white)\"\n */\nfunction debugType(type: number): string {\n\tconst [raw, c] = splitType(type);\n\treturn `[${type}] ${getRawTypeStr(raw)}(${strcolors[c]})`;\n}\n\nexport type { RawType, Player, RawTypeGroup, TypeGroup, PlayerGroup };\n\nexport { rawTypes, neutralRawTypes, ext, numTypes, players };\n\nexport default {\n\t// Constants\n\tjumpingRoyals,\n\tslidingRoyals,\n\troyals,\n\tSVGLESS_TYPES,\n\tstrcolors,\n\t// Schemas\n\tPlayerSchema,\n\tGenPlayerGroupSchema,\n\t// Functions\n\tgetRawType,\n\tgetColorFromType,\n\tbuildType,\n\tsplitType,\n\tinvertType,\n\tbuildAllTypesForPlayers,\n\tforEachPieceType,\n\tgetRawTypeStr,\n\tinvertPlayer,\n\tgetPlayerFromString,\n\tdebugType,\n\tdeleteUnusedFromRawTypeGroup,\n};\n"
  },
  {
    "path": "src/shared/chess/util/validcheckmates.ts",
    "content": "// src/shared/chess/util/validcheckmates.ts\n\n/**\n * This script stores the list of valid checkmates for the practice mode, and is used for verification clientside and serverside\n * It should have no dependencies at all.\n */\n\nconst validCheckmates = {\n\teasy: [\n\t\t'2Q-1k',\n\t\t'3R-1k',\n\t\t'1Q1R1B-1k',\n\t\t'1Q1R1N-1k',\n\t\t'1K2R-1k',\n\t\t'1Q1CH-1k',\n\t\t'2CH-1k',\n\t\t'3B3B-1k',\n\t\t'1K2B2B-1k',\n\t\t'3AR-1k',\n\t\t'1K1AM-1k',\n\t],\n\tmedium: [\n\t\t'1K1Q1B-1k',\n\t\t'1K1Q1N-1k',\n\t\t'1Q1B1B-1k',\n\t\t'1K1N2B1B-1k',\n\t\t'1K2N1B1B-1k',\n\t\t'1K1R1B1B-1k',\n\t\t'1K1R1N1B-1k',\n\t\t'1K1AR1R-1k',\n\t\t'2R1N1P-1k',\n\t\t'2AM-1rc',\n\t],\n\thard: [\n\t\t'1Q1N1B-1k',\n\t\t'1Q2N-1k',\n\t\t'1K1R2N-1k',\n\t\t'2K1R-1k',\n\t\t'1K2N6B-1k',\n\t\t'1K2AR-1k',\n\t\t'1K2HA1B-1k',\n\t\t'1K1CH1N-1k',\n\t\t'5HU-1k',\n\t],\n\tinsane: ['1K1Q1P-1k', '1K3HA-1k', '1K3NR-1k'],\n\n\t// superhuman (way too hard):\n\t// \"1K1AR1HA1P-1k\" (the white pawn only exists in order to mitigate zugzwang for white)\n\t// \"2B60N-1k\" (fewer knights suffice but exact amount unknown, see proof in https://chess.stackexchange.com/q/45998/35006 )\n};\n\n// Export ------------------------------------------------------------------------------\n\nexport default {\n\tvalidCheckmates,\n};\n"
  },
  {
    "path": "src/shared/chess/util/winconutil.ts",
    "content": "// src/shared/chess/util/winconutil.ts\n\n/**\n * This script contains lists of compatible win conditions in the game.\n * And contains a few utility methods for them.\n *\n */\n\nimport type { GameRules } from './gamerules.js';\n\nimport * as z from 'zod';\n\nimport typeutil from './typeutil.js';\n\n// Constants -----------------------------------------------------------------\n\n/**\n * Win conditions that are valid gamerule options for either color.\n * These are triggered by a move being made.\n * This excludes action-based wins like time forfeit, resignation, and disconnect.\n */\nconst GAMERULE_WIN_CONDITIONS = [\n\t'checkmate',\n\t'royalcapture',\n\t'allroyalscaptured',\n\t'allpiecescaptured',\n\t'koth', // King of the Hill\n] as const;\n\n/**\n * Conditions where one player wins (victor is a Player).\n * Covers both move-triggered wins and action-based wins.\n */\nconst WIN_CONDITIONS = [...GAMERULE_WIN_CONDITIONS, 'time', 'resignation', 'disconnect'] as const;\n\n/** Draw conditions that are triggered by a move being made. */\nconst MOVE_TRIGGERED_DRAW_CONDITIONS = [\n\t'stalemate',\n\t'moverule',\n\t'repetition',\n\t'insuffmat', // Insufficient material\n] as const;\n\n/** Conditions that result in a draw (victor is null). */\nconst DRAW_CONDITIONS = [...MOVE_TRIGGERED_DRAW_CONDITIONS, 'agreement'] as const;\n\n/**\n * List of all conclusions that are triggered by a move being made.\n * This excludes conclusions such as resignation, time, aborted, disconnect, and agreement,\n * which can happen at any point in time.\n */\nconst MOVE_TRIGGERED_CONCLUSIONS = [\n\t...GAMERULE_WIN_CONDITIONS,\n\t...MOVE_TRIGGERED_DRAW_CONDITIONS,\n] as const;\n\n// Types --------------------------------------------------------------------------\n\n/** Condition where one player wins. victor will be a Player. */\nexport type WinCondition = (typeof WIN_CONDITIONS)[number];\n/** Win condition that is a valid gamerule option for either color. */\nexport type GameruleWinCondition = (typeof GAMERULE_WIN_CONDITIONS)[number];\n/** Condition that results in a draw. victor will be null. */\nexport type DrawCondition = (typeof DRAW_CONDITIONS)[number];\n/** Condition that aborts the game. victor will be undefined. */\ntype AbortCondition = 'aborted';\ntype MoveTriggeredCondition = (typeof MOVE_TRIGGERED_CONCLUSIONS)[number];\n\n/**\n * Union type of all possible game conclusion conditions.\n * Represents how a game can be terminated.\n */\nexport type Condition = WinCondition | DrawCondition | AbortCondition;\n\n// Schemas --------------------------------------------------------------------------\n\n/** Stores the results of a game, including how it was terminated, and who won. */\nexport type GameConclusion = z.infer<typeof gameConclusionSchema>;\nconst gameConclusionSchema = z.discriminatedUnion('condition', [\n\tz.strictObject({\n\t\tcondition: z.enum(WIN_CONDITIONS),\n\t\tvictor: typeutil.PlayerSchema,\n\t}),\n\tz.strictObject({\n\t\tcondition: z.enum(DRAW_CONDITIONS),\n\t\tvictor: z.literal(null),\n\t}),\n\tz.strictObject({\n\t\tcondition: z.literal('aborted'),\n\t\tvictor: z.undefined().optional(), // Allows accidental inclusion of undefined victor\n\t}),\n]);\n\n// Constants --------------------------------------------------------------------------\n\n/**\n * Maps each game conclusion condition to its English termination string.\n * Always English by convention, since ICN metadata should only ever be in English.\n */\nconst TERMINATION_IN_ENGLISH = {\n\tcheckmate: 'Checkmate',\n\tstalemate: 'Stalemate',\n\trepetition: 'Threefold repetition',\n\t/** The move count is inserted before this string. e.g. \"50-move rule\" */\n\tmoverule: '-move rule',\n\tinsuffmat: 'Insufficient material',\n\troyalcapture: 'Royal capture',\n\tallroyalscaptured: 'All royals captured',\n\tallpiecescaptured: 'All pieces captured',\n\tkoth: 'King of the hill',\n\tresignation: 'Resignation',\n\tagreement: 'Agreement',\n\ttime: 'Time forfeit',\n\taborted: 'Aborted',\n\tdisconnect: 'Abandoned',\n} as const;\n\n// Functions --------------------------------------------------------------------------\n\n/**\n * Calculates if the provided condition is move-triggered.\n * This is any conclusion that can happen after a move is made.\n * Excludes conclusions like resignation, time, aborted, disconnect,\n * and agreement, which can happen at any point in time.\n * @param condition - The `condition` property of a `GameConclusion` object.\n * @returns *true* if the condition is move-triggered.\n */\nfunction isConclusionMoveTriggered(condition: Condition): boolean {\n\treturn MOVE_TRIGGERED_CONCLUSIONS.includes(condition as MoveTriggeredCondition);\n}\n\n/**\n * Returns the termination of the game in english language.\n * @param gameRules\n * @param condition - The 2nd half of the gameConclusion: checkmate/stalemate/repetition/moverule/insuffmat/allpiecescaptured/royalcapture/allroyalscaptured/resignation/time/aborted/disconnect\n */\nfunction getTerminationInEnglish(gameRules: GameRules, condition: Condition): string {\n\tif (condition === 'moverule') {\n\t\t// One exception - the move rule termination includes the number of moves until the auto-draw is triggered. For example, \"50-move rule\".\n\t\tconst numbWholeMovesUntilAutoDraw = gameRules.moveRule! / 2;\n\t\treturn `${numbWholeMovesUntilAutoDraw}${TERMINATION_IN_ENGLISH.moverule}`;\n\t}\n\treturn TERMINATION_IN_ENGLISH[condition];\n}\n\nexport default {\n\tgameConclusionSchema,\n\n\tGAMERULE_WIN_CONDITIONS,\n\n\tisConclusionMoveTriggered,\n\tgetTerminationInEnglish,\n};\n"
  },
  {
    "path": "src/shared/chess/variants/fourdimensionalgenerator.ts",
    "content": "// src/shared/chess/variants/fourdimensionalgenerator.ts\n\n/**\n * This script dynamically generates the positions of 4 dimensional variants\n * with varying number of boards, board sizes, and positions on each board.\n *\n * Also generates their moveset, and specialVicinity, overrides.\n */\n\nimport type { Coords, CoordsKey } from '../util/coordutil.js';\nimport type { Movesets, RawMovesets } from '../logic/movesets.js';\n\nimport bimath from '../../util/math/bimath.js';\nimport movesets from '../logic/movesets.js';\nimport coordutil from '../util/coordutil.js';\nimport icnconverter from '../logic/icn/icnconverter.js';\nimport fourdimensionalmoves from '../logic/fourdimensionalmoves.js';\nimport { rawTypes as r, ext as e } from '../util/typeutil.js';\n\n/** An object that contains all relevant quantities for the size of a single 4D chess board. */\ntype Dimensions = {\n\t/** The spacing of the timelike boards - should be equal to (sidelength of a 2D board) + 1 */\n\tBOARD_SPACING: bigint;\n\t/** Number of 2D boards in x direction */\n\tBOARDS_X: bigint;\n\t/** Number of 2D boards in y direction */\n\tBOARDS_Y: bigint;\n\t/** Board edges on the real chessboard */\n\tMIN_X: bigint;\n\t/** Board edges on the real chessboard */\n\tMAX_X: bigint;\n\t/** Board edges on the real chessboard */\n\tMIN_Y: bigint;\n\t/** Board edges on the real chessboard */\n\tMAX_Y: bigint;\n};\n\n// Variables ------------------------------------------------------------------------------------------------\n\n/** Contains all relevant quantities for the size of the 4D chess board. */\nlet dim: Dimensions | undefined;\n\n/**\n * mov: Contains all relevant parameters for movement logic on the 4D board\n */\nconst mov = {\n\t/** true: allow quadragonal and triagonal king and queen movement. false: do not allow it. */\n\tSTRONG_KINGS_AND_QUEENS: false,\n\t/**\n\t * true: pawns can capture along any forward-sideways diagonal, like brawns in  5D chess.\n\t * false: pawns can only capture along strictly spacelike or timelike diagonals, like pawns in 5D chess.\n\t */\n\tSTRONG_PAWNS: true,\n};\n\n// Utility ---------------------------------------------------------------------------------------------------------\n\nfunction set4DBoardDimensions(boards_x: bigint, boards_y: bigint, board_spacing: bigint): void {\n\tconst MIN_X = 0n;\n\tconst MIN_Y = 0n;\n\tdim = {\n\t\tBOARDS_X: boards_x,\n\t\tBOARDS_Y: boards_y,\n\t\tBOARD_SPACING: board_spacing,\n\t\tMIN_X,\n\t\tMAX_X: MIN_X + boards_x * board_spacing,\n\t\tMIN_Y,\n\t\tMAX_Y: MIN_Y + boards_y * board_spacing,\n\t};\n}\n\nfunction get4DBoardDimensions(): Dimensions {\n\treturn dim!;\n}\n\nfunction setMovementType(strong_kings_and_queens: boolean, strong_pawns: boolean): void {\n\tmov.STRONG_KINGS_AND_QUEENS = strong_kings_and_queens;\n\tmov.STRONG_PAWNS = strong_pawns;\n}\n\n/**\n * Returns the type of queen, king, and pawn movements in the last loaded 4 dimension variant.\n * Triagonal? Quadragonal? Brawn?\n */\nfunction getMovementType(): { STRONG_KINGS_AND_QUEENS: boolean; STRONG_PAWNS: boolean } {\n\treturn mov;\n}\n\n// Generation ---------------------------------------------------------------------------------------------------------\n\n/**\n * Generate 4D chess position\n * @param boards_x - Number of 2D boards in x direction\n * @param boards_y - Number of 2D boards in y direction\n * @param board_spacing - The spacing of the 2D boards - should be equal to (sidelength of a 2D board) + 1\n * @param input_position - If this is a position string, populate all 2D boards with it. If it is a dictionary, populate the boards according to it\n * @returns\n */\nfunction gen4DPosition(\n\tboards_x: bigint,\n\tboards_y: bigint,\n\tboard_spacing: bigint,\n\tinput_position: string | { [key: string]: string },\n): Map<CoordsKey, number> {\n\tset4DBoardDimensions(boards_x, boards_y, board_spacing);\n\tconst resultPos = new Map<CoordsKey, number>();\n\n\t// position is string and should identically populate all 2D boards\n\tif (typeof input_position === 'string') {\n\t\tconst input_position_long: Map<CoordsKey, number> =\n\t\t\ticnconverter.generatePositionFromShortForm(input_position).position;\n\n\t\t// Loop through from the leftmost column that should be voids to the right most, and also vertically\n\t\tfor (let i = dim!.MIN_X; i <= dim!.MAX_X; i++) {\n\t\t\tfor (let j = dim!.MIN_Y; j <= dim!.MAX_Y; j++) {\n\t\t\t\t// Only the edges of boards should be voids\n\t\t\t\tif (i % dim!.BOARD_SPACING === 0n || j % dim!.BOARD_SPACING === 0n) {\n\t\t\t\t\tresultPos.set(coordutil.getKeyFromCoords([i, j]), r.VOID + e.N);\n\t\t\t\t\t// Add input_position_long to the board\n\t\t\t\t\tif (\n\t\t\t\t\t\ti < dim!.MAX_X &&\n\t\t\t\t\t\ti % dim!.BOARD_SPACING === 0n &&\n\t\t\t\t\t\tj < dim!.MAX_Y &&\n\t\t\t\t\t\tj % dim!.BOARD_SPACING === 0n\n\t\t\t\t\t) {\n\t\t\t\t\t\tfor (const [key, value] of input_position_long) {\n\t\t\t\t\t\t\tconst coords = coordutil.getCoordsFromKey(key);\n\t\t\t\t\t\t\tconst newKey = coordutil.getKeyFromCoords([\n\t\t\t\t\t\t\t\tcoords[0] + i,\n\t\t\t\t\t\t\t\tcoords[1] + j,\n\t\t\t\t\t\t\t]);\n\t\t\t\t\t\t\tresultPos.set(newKey, value);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t// position is object and should populate 2D boards according to its entries\n\telse if (typeof input_position === 'object') {\n\t\t// Loop through from the leftmost column that should be voids to the right most, and also vertically\n\t\tfor (let i = dim!.MIN_X; i <= dim!.MAX_X; i++) {\n\t\t\tfor (let j = dim!.MIN_Y; j <= dim!.MAX_Y; j++) {\n\t\t\t\t// Only the edges of boards should be voids\n\t\t\t\tif (\n\t\t\t\t\ti % dim!.BOARD_SPACING === 0n ||\n\t\t\t\t\ti % dim!.BOARD_SPACING === 9n ||\n\t\t\t\t\tj % dim!.BOARD_SPACING === 0n ||\n\t\t\t\t\tj % dim!.BOARD_SPACING === 9n\n\t\t\t\t) {\n\t\t\t\t\tresultPos.set(coordutil.getKeyFromCoords([i, j]), r.VOID + e.N);\n\t\t\t\t\t// Add the subposition to the correct board\n\t\t\t\t\tif (\n\t\t\t\t\t\ti < dim!.MAX_X &&\n\t\t\t\t\t\ti % dim!.BOARD_SPACING === 0n &&\n\t\t\t\t\t\tj < dim!.MAX_Y &&\n\t\t\t\t\t\tj % dim!.BOARD_SPACING === 0n\n\t\t\t\t\t) {\n\t\t\t\t\t\tconst sub_position_short =\n\t\t\t\t\t\t\tinput_position[`${i / dim!.BOARD_SPACING},${j / dim!.BOARD_SPACING}`];\n\t\t\t\t\t\tconst sub_position_long: Map<CoordsKey, number> = sub_position_short\n\t\t\t\t\t\t\t? icnconverter.generatePositionFromShortForm(sub_position_short)\n\t\t\t\t\t\t\t\t\t.position\n\t\t\t\t\t\t\t: new Map<CoordsKey, number>();\n\t\t\t\t\t\tfor (const [key, value] of sub_position_long) {\n\t\t\t\t\t\t\tconst coords = coordutil.getCoordsFromKey(key);\n\t\t\t\t\t\t\tconst newKey = coordutil.getKeyFromCoords([\n\t\t\t\t\t\t\t\tcoords[0] + i,\n\t\t\t\t\t\t\t\tcoords[1] + j,\n\t\t\t\t\t\t\t]);\n\t\t\t\t\t\t\tresultPos.set(newKey, value);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn resultPos;\n}\n\n// Moveset Overrides --------------------------------------------------------------------------------------------------\n\n/**\n * Generates the moveset for the sliding pieces\n * @param boards_x - Number of 2D boards in x direction\n * @param boards_y - Number of 2D boards in y direction\n * @param board_spacing - The spacing of the 2D boards - should be equal to (sidelength of a 2D board) + 1\n * @param strong_kings_and_queens - true: allow quadragonal and triagonal movement. false: do not allow it\n * @param strong_pawns - true: pawns can capture along any diagonal. false: pawns can only capture along strictly spacelike or timelike diagonals\n * @returns\n */\nfunction gen4DMoveset(\n\tboards_x: bigint,\n\tboards_y: bigint,\n\tboard_spacing: bigint,\n\tstrong_kings_and_queens: boolean,\n\tstrong_pawns: boolean,\n): Movesets {\n\tset4DBoardDimensions(boards_x, boards_y, board_spacing);\n\tsetMovementType(strong_kings_and_queens, strong_pawns);\n\n\tconst rawMovesets: RawMovesets = {\n\t\t[r.QUEEN]: {\n\t\t\tindividual: [],\n\t\t\tsliding: {},\n\t\t\t// Not needed if a worldBorder of 0n is added.\n\t\t\t// ignore: (startCoords: Coords, endCoords: Coords) => {\n\t\t\t// \treturn (endCoords[0] > dim!.MIN_X && endCoords[0] < dim!.MAX_X && endCoords[1] > dim!.MIN_Y && endCoords[1] < dim!.MAX_Y);\n\t\t\t// }\n\t\t},\n\t\t[r.BISHOP]: {\n\t\t\tindividual: [],\n\t\t\tsliding: {},\n\t\t\t// Not needed if a worldBorder of 0n is added.\n\t\t\t// ignore: (startCoords: Coords, endCoords: Coords) => {\n\t\t\t// \treturn (endCoords[0] > dim!.MIN_X && endCoords[0] < dim!.MAX_X && endCoords[1] > dim!.MIN_Y && endCoords[1] < dim!.MAX_Y);\n\t\t\t// }\n\t\t},\n\t\t[r.ROOK]: {\n\t\t\tindividual: [],\n\t\t\tsliding: {},\n\t\t\t// Not needed if a worldBorder of 0n is added.\n\t\t\t// ignore: (startCoords: Coords, endCoords: Coords) => {\n\t\t\t// \treturn (endCoords[0] > dim!.MIN_X && endCoords[0] < dim!.MAX_X && endCoords[1] > dim!.MIN_Y && endCoords[1] < dim!.MAX_Y);\n\t\t\t// }\n\t\t},\n\t\t[r.KING]: {\n\t\t\tindividual: [],\n\t\t\tspecial: fourdimensionalmoves.fourDimensionalKingMove,\n\t\t},\n\t\t[r.KNIGHT]: {\n\t\t\tindividual: [],\n\t\t\tspecial: fourdimensionalmoves.fourDimensionalKnightMove,\n\t\t},\n\t\t[r.PAWN]: {\n\t\t\tindividual: [],\n\t\t\tspecial: fourdimensionalmoves.fourDimensionalPawnMove,\n\t\t},\n\t};\n\n\tfor (let baseH = 1n; baseH >= -1n; baseH--) {\n\t\tfor (let baseV = 1n; baseV >= -1n; baseV--) {\n\t\t\tfor (let offsetH = 1n; offsetH >= -1n; offsetH--) {\n\t\t\t\tfor (let offsetV = 1n; offsetV >= -1n; offsetV--) {\n\t\t\t\t\tconst x = dim!.BOARD_SPACING * baseH + offsetH;\n\t\t\t\t\tconst y = dim!.BOARD_SPACING * baseV + offsetV;\n\n\t\t\t\t\tif (x < 0n) continue; // If the x coordinate is negative, skip this iteration\n\t\t\t\t\tif (x === 0n && y <= 0n) continue; // Skip if x is 0 and y is negative\n\t\t\t\t\t// Add the moves\n\n\t\t\t\t\t// allow any queen move if STRONG_KINGS_AND_QUEENS, else group her with bishops and rooks\n\t\t\t\t\tif (mov.STRONG_KINGS_AND_QUEENS)\n\t\t\t\t\t\trawMovesets[r.QUEEN]!.sliding![coordutil.getKeyFromCoords([x, y])] = [\n\t\t\t\t\t\t\tnull,\n\t\t\t\t\t\t\tnull,\n\t\t\t\t\t\t];\n\n\t\t\t\t\t// Only add a bishop move if the move moves in two dimensions\n\t\t\t\t\tif (\n\t\t\t\t\t\tbaseH * baseH + baseV * baseV + offsetH * offsetH + offsetV * offsetV ===\n\t\t\t\t\t\t2n\n\t\t\t\t\t) {\n\t\t\t\t\t\trawMovesets[r.BISHOP]!.sliding![coordutil.getKeyFromCoords([x, y])] = [\n\t\t\t\t\t\t\tnull,\n\t\t\t\t\t\t\tnull,\n\t\t\t\t\t\t];\n\t\t\t\t\t\tif (!mov.STRONG_KINGS_AND_QUEENS)\n\t\t\t\t\t\t\trawMovesets[r.QUEEN]!.sliding![coordutil.getKeyFromCoords([x, y])] = [\n\t\t\t\t\t\t\t\tnull,\n\t\t\t\t\t\t\t\tnull,\n\t\t\t\t\t\t\t];\n\t\t\t\t\t}\n\t\t\t\t\t// Only add a rook move if the move moves in one dimension\n\t\t\t\t\tif (\n\t\t\t\t\t\tbaseH * baseH + baseV * baseV + offsetH * offsetH + offsetV * offsetV ===\n\t\t\t\t\t\t1n\n\t\t\t\t\t) {\n\t\t\t\t\t\trawMovesets[r.ROOK]!.sliding![coordutil.getKeyFromCoords([x, y])] = [\n\t\t\t\t\t\t\tnull,\n\t\t\t\t\t\t\tnull,\n\t\t\t\t\t\t];\n\t\t\t\t\t\tif (!mov.STRONG_KINGS_AND_QUEENS)\n\t\t\t\t\t\t\trawMovesets[r.QUEEN]!.sliding![coordutil.getKeyFromCoords([x, y])] = [\n\t\t\t\t\t\t\t\tnull,\n\t\t\t\t\t\t\t\tnull,\n\t\t\t\t\t\t\t];\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn movesets.convertRawMovesetsToPieceMovesets(rawMovesets);\n}\n\n// Special Vicinity Overrides -----------------------------------------------------------------------------------------\n\n/**\n * Sets the specialVicinity object for the pawn\n * @param board_spacing - The spacing of the timelike boards - should be equal to (sidelength of a 2D board) + 1.\n * @param strong_pawns - true: pawns can capture along any forward-sideways diagonal.\n * \t\t\t\t\t\t false: pawns can only capture along strictly spacelike or timelike diagonals, like in 5D chess\n * @returns\n */\nfunction getPawnVicinity(board_spacing: bigint, strong_pawns: boolean): Coords[] {\n\tconst individualMoves: Coords[] = [];\n\n\tfor (let baseH = 1n; baseH >= -1n; baseH--) {\n\t\tfor (let baseV = 1n; baseV >= -1n; baseV--) {\n\t\t\tfor (let offsetH = 1n; offsetH >= -1n; offsetH--) {\n\t\t\t\tfor (let offsetV = 1n; offsetV >= -1n; offsetV--) {\n\t\t\t\t\t// only allow changing two things at once\n\t\t\t\t\tif (\n\t\t\t\t\t\tbaseH * baseH + baseV * baseV + offsetH * offsetH + offsetV * offsetV !==\n\t\t\t\t\t\t2n\n\t\t\t\t\t)\n\t\t\t\t\t\tcontinue;\n\n\t\t\t\t\t// do not allow two moves forward\n\t\t\t\t\tif (baseH * baseH + offsetH * offsetH === 2n) continue;\n\n\t\t\t\t\t// do not allow two moves sideways\n\t\t\t\t\tif (baseV * baseV + offsetV * offsetV === 2n) continue;\n\n\t\t\t\t\t// disallow strong captures if pawns are weak\n\t\t\t\t\tif (\n\t\t\t\t\t\t!strong_pawns &&\n\t\t\t\t\t\t(bimath.abs(baseH) !== bimath.abs(baseV) ||\n\t\t\t\t\t\t\tbimath.abs(offsetH) !== bimath.abs(offsetV))\n\t\t\t\t\t)\n\t\t\t\t\t\tcontinue;\n\n\t\t\t\t\tconst x = board_spacing * baseH + offsetH;\n\t\t\t\t\tconst y = board_spacing * baseV + offsetV;\n\t\t\t\t\tconst endCoords = [x, y] as Coords;\n\n\t\t\t\t\tindividualMoves.push(endCoords);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn individualMoves;\n}\n\n/**\n * Sets the specialVicinity object for the knight\n * @param board_spacing - The spacing of the timelike boards - should be equal to (sidelength of a 2D board) + 1.\n * @returns\n */\nfunction getKnightVicinity(board_spacing: bigint): Coords[] {\n\tconst individualMoves: Coords[] = [];\n\n\tfor (let baseH = 2n; baseH >= -2n; baseH--) {\n\t\tfor (let baseV = 2n; baseV >= -2n; baseV--) {\n\t\t\tfor (let offsetH = 2n; offsetH >= -2n; offsetH--) {\n\t\t\t\tfor (let offsetV = 2n; offsetV >= -2n; offsetV--) {\n\t\t\t\t\t// If the squared distance to the tile is 5, then add the move\n\t\t\t\t\tif (\n\t\t\t\t\t\tbaseH * baseH + baseV * baseV + offsetH * offsetH + offsetV * offsetV ===\n\t\t\t\t\t\t5n\n\t\t\t\t\t) {\n\t\t\t\t\t\tconst x = board_spacing * baseH + offsetH;\n\t\t\t\t\t\tconst y = board_spacing * baseV + offsetV;\n\t\t\t\t\t\tconst endCoords = [x, y] as Coords;\n\t\t\t\t\t\tindividualMoves.push(endCoords);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn individualMoves;\n}\n\n/**\n * Sets the specialVicinity object for the king\n * @param board_spacing - The spacing of the timelike boards - should be equal to (sidelength of a 2D board) + 1.\n * @param strong_kings_and_queens - true: allow quadragonal and triagonal king and queen movement. false: do not allow it\n * @returns\n */\nfunction getKingVicinity(board_spacing: bigint, strong_kings_and_queens: boolean): Coords[] {\n\tconst individualMoves: Coords[] = [];\n\n\tfor (let baseH = 1n; baseH >= -1n; baseH--) {\n\t\tfor (let baseV = 1n; baseV >= -1n; baseV--) {\n\t\t\tfor (let offsetH = 1n; offsetH >= -1n; offsetH--) {\n\t\t\t\tfor (let offsetV = 1n; offsetV >= -1n; offsetV--) {\n\t\t\t\t\t// only allow moves that change one or two dimensions if triagonals and diagonals are disabled\n\t\t\t\t\tif (\n\t\t\t\t\t\t!strong_kings_and_queens &&\n\t\t\t\t\t\tbaseH * baseH + baseV * baseV + offsetH * offsetH + offsetV * offsetV > 2n\n\t\t\t\t\t)\n\t\t\t\t\t\tcontinue;\n\n\t\t\t\t\tconst x = board_spacing * baseH + offsetH;\n\t\t\t\t\tconst y = board_spacing * baseV + offsetV;\n\t\t\t\t\tif (x === 0n && y === 0n) continue;\n\t\t\t\t\tconst endCoords = [x, y] as Coords;\n\n\t\t\t\t\tindividualMoves.push(endCoords);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn individualMoves;\n}\n\n// Exports ------------------------------------------------------------------------------------------------------------\n\nexport default {\n\tget4DBoardDimensions,\n\tgetMovementType,\n\tgen4DPosition,\n\tgen4DMoveset,\n\tgetPawnVicinity,\n\tgetKnightVicinity,\n\tgetKingVicinity,\n};\n"
  },
  {
    "path": "src/shared/chess/variants/omega3generator.ts",
    "content": "// src/shared/chess/variants/omega3generator.ts\n\n/**\n * Here lies the position generator for the Omega^3 Showcase variant.\n */\n\nimport type { BoundingBox } from '../../util/math/bounds.js';\nimport type { Coords, CoordsKey } from '../util/coordutil.js';\n\nimport coordutil from '../util/coordutil.js';\nimport { ext as e, rawTypes as r } from '../util/typeutil.js';\n\n/**\n * Generates the Omega^3 position example\n * @returns The position in keys format\n */\nfunction genPositionOfOmegaCubed(): Map<CoordsKey, number> {\n\tconst dist = 500n; // Generate Omega^3 up to a distance of 1000 tiles away\n\n\tconst startingPos: Map<CoordsKey, number> = new Map();\n\n\tstartingPos.set(coordutil.getKeyFromCoords([3n, 15n]), r.KING + e.W);\n\tstartingPos.set(coordutil.getKeyFromCoords([4n, 13n]), r.ROOK + e.B);\n\n\t// First few pawn walls\n\tappendPawnTower(startingPos, 7n, -dist, dist);\n\tappendPawnTower(startingPos, 8n, -dist, dist);\n\n\t// Third pawn wall\n\tappendPawnTower(startingPos, 9n, -dist, dist);\n\tstartingPos.set(coordutil.getKeyFromCoords([9n, 10n]), r.BISHOP + e.W); // Overwrite with bishop\n\tsetAir(startingPos, [9n, 11n]);\n\n\t// Black king wall\n\tappendPawnTower(startingPos, 10n, -dist, dist);\n\tstartingPos.set(coordutil.getKeyFromCoords([10n, 12n]), r.KING + e.B); // Overwrite with king\n\n\t// Spawn rook towers\n\tspawnAllRookTowers(startingPos, 11n, 8n, dist, dist);\n\n\tstartingPos.set(coordutil.getKeyFromCoords([11n, 6n]), r.BISHOP + e.W);\n\tappendPawnTower(startingPos, 11n, -dist, 5n);\n\n\tappendPawnTower(startingPos, 12n, -dist, 7n);\n\tstartingPos.set(coordutil.getKeyFromCoords([12n, 8n]), r.PAWN + e.B);\n\n\tstartingPos.set(coordutil.getKeyFromCoords([13n, 9n]), r.PAWN + e.B);\n\tstartingPos.set(coordutil.getKeyFromCoords([13n, 8n]), r.PAWN + e.W);\n\tstartingPos.set(coordutil.getKeyFromCoords([13n, 6n]), r.BISHOP + e.B);\n\n\tstartingPos.set(coordutil.getKeyFromCoords([14n, 10n]), r.PAWN + e.B);\n\tstartingPos.set(coordutil.getKeyFromCoords([14n, 9n]), r.PAWN + e.W);\n\tstartingPos.set(coordutil.getKeyFromCoords([14n, 6n]), r.PAWN + e.B);\n\tstartingPos.set(coordutil.getKeyFromCoords([14n, 5n]), r.PAWN + e.B);\n\tstartingPos.set(coordutil.getKeyFromCoords([14n, 4n]), r.PAWN + e.W);\n\n\tgenBishopTunnel(startingPos, 15n, 6n, dist, dist);\n\n\tsurroundPositionInVoidBox(startingPos, { left: -500n, right: 500n, bottom: -500n, top: 500n });\n\n\t// Bottom blip of pawns to prevent black rook from capturing them\n\tstartingPos.set(coordutil.getKeyFromCoords([499n, 492n]), r.VOID + e.N);\n\tstartingPos.set(coordutil.getKeyFromCoords([7n, -500n]), r.PAWN + e.W);\n\tstartingPos.set(coordutil.getKeyFromCoords([8n, -500n]), r.PAWN + e.W);\n\tstartingPos.set(coordutil.getKeyFromCoords([9n, -500n]), r.PAWN + e.W);\n\tstartingPos.set(coordutil.getKeyFromCoords([10n, -500n]), r.PAWN + e.W);\n\tstartingPos.set(coordutil.getKeyFromCoords([11n, -500n]), r.PAWN + e.W);\n\tstartingPos.set(coordutil.getKeyFromCoords([12n, -500n]), r.PAWN + e.W);\n\tstartingPos.set(coordutil.getKeyFromCoords([6n, -501n]), r.VOID + e.N);\n\tstartingPos.set(coordutil.getKeyFromCoords([7n, -501n]), r.VOID + e.N);\n\tstartingPos.set(coordutil.getKeyFromCoords([8n, -501n]), r.VOID + e.N);\n\tstartingPos.set(coordutil.getKeyFromCoords([9n, -501n]), r.VOID + e.N);\n\tstartingPos.set(coordutil.getKeyFromCoords([10n, -501n]), r.VOID + e.N);\n\tstartingPos.set(coordutil.getKeyFromCoords([11n, -501n]), r.VOID + e.N);\n\tstartingPos.set(coordutil.getKeyFromCoords([12n, -501n]), r.VOID + e.N);\n\tstartingPos.set(coordutil.getKeyFromCoords([13n, -501n]), r.VOID + e.N);\n\n\t// Bishop box that prevents black stalemate ideas\n\tstartingPos.set(coordutil.getKeyFromCoords([497n, -497n]), r.VOID + e.N);\n\tstartingPos.set(coordutil.getKeyFromCoords([498n, -497n]), r.VOID + e.N);\n\tstartingPos.set(coordutil.getKeyFromCoords([499n, -497n]), r.VOID + e.N);\n\tstartingPos.set(coordutil.getKeyFromCoords([497n, -498n]), r.VOID + e.N);\n\tstartingPos.set(coordutil.getKeyFromCoords([497n, -499n]), r.VOID + e.N);\n\tstartingPos.set(coordutil.getKeyFromCoords([498n, -498n]), r.VOID + e.N);\n\tstartingPos.set(coordutil.getKeyFromCoords([499n, -499n]), r.VOID + e.N);\n\tstartingPos.set(coordutil.getKeyFromCoords([498n, -499n]), r.BISHOP + e.B);\n\n\treturn startingPos;\n\n\tfunction appendPawnTower(\n\t\tposition: Map<CoordsKey, number>,\n\t\tx: bigint,\n\t\tstartY: bigint,\n\t\tendY: bigint,\n\t): void {\n\t\tif (endY < startY) return; // Don't do negative pawn towers\n\t\tfor (let y = startY; y <= endY; y++) {\n\t\t\tconst thisCoords: Coords = [x, y];\n\t\t\tconst key = coordutil.getKeyFromCoords(thisCoords);\n\t\t\tposition.set(key, r.PAWN + e.W);\n\t\t}\n\t}\n\n\tfunction setAir(position: Map<CoordsKey, number>, coords: Coords): void {\n\t\tconst key = coordutil.getKeyFromCoords(coords);\n\t\tposition.delete(key);\n\t}\n\n\tfunction spawnRookTower(\n\t\tposition: Map<CoordsKey, number>,\n\t\txStart: bigint,\n\t\tyStart: bigint,\n\t\tdist: bigint,\n\t): void {\n\t\t// First wall with 4 bishops\n\t\tposition.set(coordutil.getKeyFromCoords([xStart, yStart]), r.BISHOP + e.W);\n\t\tposition.set(coordutil.getKeyFromCoords([xStart, yStart + 1n]), r.PAWN + e.W);\n\t\tposition.set(coordutil.getKeyFromCoords([xStart, yStart + 2n]), r.BISHOP + e.W);\n\t\tposition.set(coordutil.getKeyFromCoords([xStart, yStart + 4n]), r.BISHOP + e.W);\n\t\tposition.set(coordutil.getKeyFromCoords([xStart, yStart + 6n]), r.BISHOP + e.W);\n\t\tappendPawnTower(position, xStart, yStart + 8n, dist);\n\n\t\t// Second wall with rook\n\t\tposition.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 1n]), r.BISHOP + e.W);\n\t\tposition.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 3n]), r.BISHOP + e.W);\n\t\tposition.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 5n]), r.BISHOP + e.W);\n\t\tif (yStart + 7n <= dist)\n\t\t\tposition.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 7n]), r.BISHOP + e.W);\n\t\tif (yStart + 8n <= dist)\n\t\t\tposition.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 8n]), r.ROOK + e.B);\n\n\t\t// Third pawn wall\n\t\tappendPawnTower(position, xStart + 2n, yStart + 2n, dist);\n\t\tif (yStart + 7n <= dist)\n\t\t\tposition.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 7n]), r.PAWN + e.B);\n\t}\n\n\tfunction spawnAllRookTowers(\n\t\tposition: Map<CoordsKey, number>,\n\t\txStart: bigint,\n\t\tyStart: bigint,\n\t\txEnd: bigint,\n\t\tyEnd: bigint,\n\t): void {\n\t\tlet y = yStart;\n\t\tfor (let x = xStart; x < xEnd; x += 3n) {\n\t\t\tspawnRookTower(position, x, y, yEnd);\n\t\t\ty += 3n; // Increment y as well!\n\t\t}\n\t}\n\n\tfunction genBishopTunnel(\n\t\tposition: Map<CoordsKey, number>,\n\t\txStart: bigint,\n\t\tyStart: bigint,\n\t\txEnd: bigint,\n\t\tyEnd: bigint,\n\t): void {\n\t\tlet y = yStart;\n\t\tfor (let x = xStart; x < xEnd; x++) {\n\t\t\tposition.set(coordutil.getKeyFromCoords([x, y]), r.PAWN + e.W);\n\t\t\tposition.set(coordutil.getKeyFromCoords([x, y + 1n]), r.PAWN + e.B);\n\t\t\tposition.set(coordutil.getKeyFromCoords([x, y + 4n]), r.PAWN + e.W);\n\t\t\tposition.set(coordutil.getKeyFromCoords([x, y + 5n]), r.PAWN + e.B);\n\t\t\ty++; // Increment y as well!\n\t\t\tif (y > yEnd) return;\n\t\t}\n\t}\n}\n\n/**\n * Adds a huge void square around the provided pieces by key.\n * Then deletes any pieces outside it.\n * @param position - The position, in key format: Map with key/value pairs.\n * @param box - The rectangle to which to form the void box.\n */\nfunction surroundPositionInVoidBox(position: Map<CoordsKey, number>, box: BoundingBox): void {\n\tfor (let x = box.left; x <= box.right; x++) {\n\t\tlet key = coordutil.getKeyFromCoords([x, box.bottom]);\n\t\tposition.set(key, r.VOID + e.N);\n\t\tkey = coordutil.getKeyFromCoords([x, box.top]);\n\t\tposition.set(key, r.VOID + e.N);\n\t}\n\tfor (let y = box.bottom; y <= box.top; y++) {\n\t\tlet key = coordutil.getKeyFromCoords([box.left, y]);\n\t\tposition.set(key, r.VOID + e.N);\n\t\tkey = coordutil.getKeyFromCoords([box.right, y]);\n\t\tposition.set(key, r.VOID + e.N);\n\t}\n}\n\nexport default {\n\tgenPositionOfOmegaCubed,\n};\n"
  },
  {
    "path": "src/shared/chess/variants/omega4generator.ts",
    "content": "// src/shared/chess/variants/omega4generator.ts\n\n/**\n * Here lies the position generator for the Omega^4 Showcase variant.\n */\n\nimport { rawTypes as r, ext as e } from '../util/typeutil.js';\nimport coordutil, { CoordsKey, Coords } from '../util/coordutil.js';\n\n/**\n * Generates the Omega^4 position example\n * @returns {Map<CoordsKey, number>} The position in Map format\n */\nfunction genPositionOfOmegaFourth(): Map<CoordsKey, number> {\n\tconst dist = 500n; // Generate Omega^4 up to a distance of 50 tiles away\n\n\t// Create a Map for the starting position.\n\tconst startingPos: Map<CoordsKey, number> = new Map();\n\n\t// King chamber\n\tconst kingChamber: Record<string, number> = {\n\t\t'-14,17': r.PAWN + e.W,\n\t\t'-14,18': r.PAWN + e.B,\n\t\t'-13,14': r.PAWN + e.W,\n\t\t'-13,15': r.PAWN + e.B,\n\t\t'-13,16': r.PAWN + e.W,\n\t\t'-13,17': r.PAWN + e.B,\n\t\t'-13,20': r.PAWN + e.W,\n\t\t'-13,21': r.PAWN + e.B,\n\t\t'-13,22': r.PAWN + e.W,\n\t\t'-13,23': r.PAWN + e.B,\n\t\t'-13,24': r.PAWN + e.W,\n\t\t'-13,25': r.PAWN + e.B,\n\t\t'-13,26': r.PAWN + e.W,\n\t\t'-13,27': r.PAWN + e.B,\n\t\t'-12,16': r.BISHOP + e.B,\n\t\t'-12,25': r.BISHOP + e.W,\n\t\t'-11,14': r.PAWN + e.W,\n\t\t'-11,15': r.PAWN + e.B,\n\t\t'-11,16': r.KING + e.B,\n\t\t'-11,17': r.PAWN + e.B,\n\t\t'-11,24': r.PAWN + e.W,\n\t\t'-11,25': r.KING + e.W,\n\t\t'-11,26': r.PAWN + e.W,\n\t\t'-11,27': r.PAWN + e.B,\n\t\t'-10,16': r.BISHOP + e.B,\n\t\t'-10,25': r.BISHOP + e.W,\n\t\t'-9,14': r.PAWN + e.W,\n\t\t'-9,15': r.PAWN + e.B,\n\t\t'-9,16': r.PAWN + e.W,\n\t\t'-9,17': r.PAWN + e.B,\n\t\t'-9,18': r.PAWN + e.W,\n\t\t'-9,19': r.PAWN + e.B,\n\t\t'-9,20': r.PAWN + e.W,\n\t\t'-9,21': r.PAWN + e.B,\n\t\t'-9,22': r.PAWN + e.W,\n\t\t'-9,23': r.PAWN + e.B,\n\t\t'-9,24': r.PAWN + e.W,\n\t\t'-9,25': r.PAWN + e.B,\n\t\t'-9,26': r.PAWN + e.W,\n\t\t'-9,27': r.PAWN + e.B,\n\t};\n\tfor (const [key, value] of Object.entries(kingChamber)) {\n\t\tstartingPos.set(key as CoordsKey, value);\n\t}\n\n\t// Rook towers\n\tconst startOfRookTowers: Record<string, number> = {\n\t\t'0,3': r.PAWN + e.W,\n\t\t'0,4': r.PAWN + e.B,\n\t\t'0,5': r.PAWN + e.W,\n\t\t'0,6': r.PAWN + e.B,\n\t\t'0,11': r.PAWN + e.W,\n\t\t'0,12': r.PAWN + e.B,\n\t\t'1,4': r.BISHOP + e.W,\n\t\t'1,12': r.BISHOP + e.W,\n\t\t'1,13': r.ROOK + e.B,\n\t\t'2,1': r.PAWN + e.W,\n\t\t'2,2': r.PAWN + e.B,\n\t\t'2,3': r.PAWN + e.W,\n\t\t'2,4': r.PAWN + e.B,\n\t\t'2,5': r.PAWN + e.W,\n\t\t'2,6': r.PAWN + e.B,\n\t\t'2,7': r.PAWN + e.W,\n\t\t'2,8': r.PAWN + e.W,\n\t\t'2,9': r.PAWN + e.W,\n\t\t'2,10': r.PAWN + e.W,\n\t\t'2,11': r.PAWN + e.W,\n\t\t'2,12': r.PAWN + e.B,\n\t\t'3,2': r.BISHOP + e.W,\n\t\t'3,4': r.BISHOP + e.B,\n\t\t'3,6': r.PAWN + e.W,\n\t\t'3,7': r.PAWN + e.B,\n\t\t'3,8': r.BISHOP + e.W,\n\t\t'3,9': r.PAWN + e.W,\n\t\t'3,10': r.BISHOP + e.W,\n\t\t'3,12': r.BISHOP + e.W,\n\t\t'3,14': r.BISHOP + e.W,\n\t\t'4,1': r.PAWN + e.W,\n\t\t'4,2': r.PAWN + e.B,\n\t\t'4,3': r.PAWN + e.W,\n\t\t'4,4': r.PAWN + e.B,\n\t\t'4,7': r.PAWN + e.W,\n\t\t'4,8': r.PAWN + e.B,\n\t\t'4,9': r.BISHOP + e.W,\n\t\t'4,11': r.BISHOP + e.W,\n\t\t'4,13': r.BISHOP + e.W,\n\t\t'4,15': r.BISHOP + e.W,\n\t\t'4,16': r.ROOK + e.B,\n\t\t'5,4': r.PAWN + e.W,\n\t\t'5,5': r.PAWN + e.B,\n\t\t'5,8': r.PAWN + e.W,\n\t\t'5,9': r.PAWN + e.B,\n\t\t'5,10': r.PAWN + e.W,\n\t\t'5,11': r.PAWN + e.W,\n\t\t'5,12': r.PAWN + e.W,\n\t\t'5,13': r.PAWN + e.W,\n\t\t'5,14': r.PAWN + e.W,\n\t\t'5,15': r.PAWN + e.B,\n\t};\n\tfor (const [key, value] of Object.entries(startOfRookTowers)) {\n\t\tstartingPos.set(key as CoordsKey, value);\n\t}\n\n\tappendPawnTower(startingPos, 0n, 13n, dist);\n\tappendPawnTower(startingPos, 2n, 13n, dist);\n\tappendPawnTower(startingPos, 3n, 16n, dist);\n\tappendPawnTower(startingPos, 5n, 16n, dist);\n\n\tspawnAllRookTowers(startingPos, 6n, 3n, dist + 3n, dist);\n\n\t// Bishop Cannon Battery\n\tstartingPos.set(coordutil.getKeyFromCoords([0n, -6n]), r.PAWN + e.B);\n\tstartingPos.set(coordutil.getKeyFromCoords([0n, -7n]), r.PAWN + e.W);\n\n\tspawnAllBishopCannons(startingPos, 1n, -7n, dist, -dist);\n\tspawnAllWings(startingPos, -1n, -7n, -dist, -dist);\n\n\taddVoidSquaresToOmegaFourth(startingPos, -866n, 500n, 567n, -426n, -134n);\n\n\treturn startingPos;\n\n\tfunction appendPawnTower(\n\t\tstartingPos: Map<CoordsKey, number>,\n\t\tx: bigint,\n\t\tstartY: bigint,\n\t\tendY: bigint,\n\t): void {\n\t\tif (endY < startY) return; // Don't do negative pawn towers\n\t\tfor (let y = startY; y <= endY; y++) {\n\t\t\tconst thisCoords: Coords = [x, y];\n\t\t\tconst key: CoordsKey = coordutil.getKeyFromCoords(thisCoords);\n\t\t\tstartingPos.set(key, r.PAWN + e.W);\n\t\t}\n\t}\n\n\tfunction setAir(startingPos: Map<CoordsKey, number>, coords: Coords): void {\n\t\tconst key: CoordsKey = coordutil.getKeyFromCoords(coords);\n\t\tstartingPos.delete(key);\n\t}\n\n\t// prettier-ignore\n\tfunction spawnRookTower(\n\t\tstartingPos: Map<CoordsKey, number>,\n\t\txStart: bigint,\n\t\tyStart: bigint,\n\t\tdist: bigint,\n\t): void {\n\t\t// First wall with 4 bishops\n\t\tstartingPos.set(coordutil.getKeyFromCoords([xStart, yStart]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 1n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 2n]), r.PAWN + e.W);\n\t\tif (yStart + 3n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 3n]), r.PAWN + e.B);\n\t\tif (yStart + 6n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 6n]), r.PAWN + e.W);\n\t\tif (yStart + 7n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 7n]), r.PAWN + e.B);\n\t\tif (yStart + 8n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 8n]), r.BISHOP + e.W);\n\t\tif (yStart + 9n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 9n]), r.PAWN + e.W);\n\t\tif (yStart + 10n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 10n]), r.BISHOP + e.W);\n\t\tif (yStart + 12n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 12n]), r.BISHOP + e.W);\n\t\tif (yStart + 14n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 14n]), r.BISHOP + e.W);\n\t\tappendPawnTower(startingPos, xStart, yStart + 16n, dist);\n\n\t\t// Second wall with rook\n\t\tstartingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 1n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 2n]), r.PAWN + e.B);\n\t\tif (yStart + 3n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 3n]), r.PAWN + e.W);\n\t\tif (yStart + 4n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 4n]), r.PAWN + e.B);\n\t\tif (yStart + 7n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 7n]), r.PAWN + e.W);\n\t\tif (yStart + 8n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 8n]), r.PAWN + e.B);\n\t\tif (yStart + 9n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 9n]), r.BISHOP + e.W);\n\t\tif (yStart + 11n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 11n]), r.BISHOP + e.W);\n\t\tif (yStart + 13n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 13n]), r.BISHOP + e.W);\n\t\tif (yStart + 15n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 15n]), r.BISHOP + e.W);\n\t\tif (yStart + 16n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 16n]), r.ROOK + e.B);\n\n\t\t// Third pawn wall\n\t\tstartingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 2n]), r.PAWN + e.W);\n\t\tif (yStart + 3n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 3n]), r.PAWN + e.B);\n\t\tif (yStart + 4n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 4n]), r.PAWN + e.W);\n\t\tif (yStart + 5n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 5n]), r.PAWN + e.B);\n\t\tif (yStart + 8n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 8n]), r.PAWN + e.W);\n\t\tif (yStart + 9n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 9n]), r.PAWN + e.B);\n\t\tif (yStart + 10n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 10n]), r.PAWN + e.W);\n\t\tif (yStart + 11n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 11n]), r.PAWN + e.W);\n\t\tif (yStart + 12n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 12n]), r.PAWN + e.W);\n\t\tif (yStart + 13n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 13n]), r.PAWN + e.W);\n\t\tif (yStart + 14n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 14n]), r.PAWN + e.W);\n\t\tif (yStart + 15n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 15n]), r.PAWN + e.B);\n\t\tappendPawnTower(startingPos, xStart + 2n, yStart + 16n, dist);\n\t}\n\n\tfunction spawnAllRookTowers(\n\t\tstartingPos: Map<CoordsKey, number>,\n\t\txStart: bigint,\n\t\tyStart: bigint,\n\t\txEnd: bigint,\n\t\tyEnd: bigint,\n\t): void {\n\t\tlet y: bigint = yStart;\n\t\tfor (let x = xStart; x < xEnd; x += 3n) {\n\t\t\tspawnRookTower(startingPos, x, y, yEnd);\n\t\t\ty += 3n; // Increment y as well!\n\t\t}\n\t}\n\n\tfunction spawnAllBishopCannons(\n\t\tstartingPos: Map<CoordsKey, number>,\n\t\tstartX: bigint,\n\t\tstartY: bigint,\n\t\tendX: bigint,\n\t\tendY: bigint,\n\t): void {\n\t\tconst spacing = 7n;\n\t\tlet currX: bigint = startX;\n\t\tlet currY: bigint = startY;\n\t\tlet i = 0;\n\t\tdo {\n\t\t\tgenBishopCannon(startingPos, currX, currY, i);\n\t\t\tcurrX += spacing;\n\t\t\tcurrY -= spacing;\n\t\t\ti++;\n\t\t} while (currX < endX && currY > endY);\n\t}\n\n\t// prettier-ignore\n\tfunction genBishopCannon(\n\t\tstartingPos: Map<CoordsKey, number>,\n\t\tx: bigint,\n\t\ty: bigint,\n\t\ti: number,\n\t): void {\n\t\t// Pawn staples that never change\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x, y]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x, y - 1n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x + 1n, y - 1n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x + 1n, y - 2n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x + 2n, y - 2n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x + 2n, y - 3n]), r.PAWN + e.W);\n\t\tif (y - 3n - x + 3n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 3n, y - 3n]), r.PAWN + e.B);\n\t\tif (y - 4n - x + 3n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 3n, y - 4n]), r.PAWN + e.W);\n\t\tif (y - 5n - x + 4n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 4n, y - 4n]), r.PAWN + e.B);\n\t\tif (y - 3n - x + 4n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 4n, y - 5n]), r.PAWN + e.W);\n\t\tif (y - 4n - x + 5n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 5n, y - 3n]), r.PAWN + e.B);\n\t\tif (y - 4n - x + 5n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 5n, y - 4n]), r.PAWN + e.W);\n\t\tif (y - 2n - x + 6n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 6n, y - 2n]), r.PAWN + e.B);\n\t\tif (y - 3n - x + 6n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 6n, y - 3n]), r.PAWN + e.W);\n\t\tif (y - 1n - x + 7n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 7n, y - 1n]), r.PAWN + e.B);\n\t\tif (y - 2n - x + 7n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 7n, y - 2n]), r.PAWN + e.W);\n\t\tif (y + 1n - x + 7n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 7n, y + 1n]), r.PAWN + e.B);\n\t\tif (y + 0n - x + 7n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 7n, y + 0n]), r.PAWN + e.W);\n\t\tif (y - 2n - x + 8n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 8n, y - 2n]), r.BISHOP + e.B);\n\t\tif (y - 6n - x + 6n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 6n, y - 6n]), r.PAWN + e.B);\n\t\tif (y - 7n - x + 6n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 6n, y - 7n]), r.PAWN + e.W);\n\t\tif (y - 5n - x + 7n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 7n, y - 5n]), r.PAWN + e.B);\n\t\tif (y - 6n - x + 7n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 7n, y - 6n]), r.PAWN + e.W);\n\t\tif (y - 4n - x + 8n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 8n, y - 4n]), r.PAWN + e.B);\n\t\tif (y - 5n - x + 8n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 8n, y - 5n]), r.PAWN + e.W);\n\t\tif (y - 3n - x + 9n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 9n, y - 3n]), r.PAWN + e.B);\n\t\tif (y - 4n - x + 9n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 9n, y - 4n]), r.PAWN + e.W);\n\n\t\t// Generate bishop puzzle pieces.\n\t\t// it tells us how many to iteratively gen!\n\t\tconst count: number = i + 2;\n\t\tlet puzzleX: bigint = x + 8n;\n\t\tlet puzzleY: bigint = y + 2n;\n\t\tconst upDiag: bigint = puzzleY - puzzleX;\n\t\tif (upDiag > -990n) {\n\t\t\tfor (let a = 1; a <= count; a++) {\n\t\t\t\tconst isLastIndex: boolean = a === count;\n\t\t\t\tgenBishopPuzzlePiece(startingPos, puzzleX, puzzleY, isLastIndex);\n\t\t\t\tpuzzleX += 1n;\n\t\t\t\tpuzzleY += 1n;\n\t\t\t}\n\t\t}\n\n\t\t// White pawn strip\n\t\tlet pawnX: bigint = x + 4n;\n\t\tlet pawnY: bigint = y;\n\t\tfor (let a = 0; a < i; a++) {\n\t\t\tstartingPos.set(coordutil.getKeyFromCoords([pawnX, pawnY]), r.PAWN + e.W);\n\t\t\tpawnX++;\n\t\t\tpawnY++;\n\t\t}\n\t}\n\n\tfunction genBishopPuzzlePiece(\n\t\tstartingPos: Map<CoordsKey, number>,\n\t\tx: bigint,\n\t\ty: bigint,\n\t\tisLastIndex: boolean,\n\t): void {\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x, y]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x, y - 1n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x, y - 2n]), r.BISHOP + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x + 1n, y - 2n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x + 1n, y - 3n]), r.BISHOP + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x + 2n, y - 4n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x + 2n, y - 5n]), r.PAWN + e.W);\n\n\t\tif (!isLastIndex) return;\n\n\t\t// Is last index\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x + 1n, y - 2n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x + 1n, y - 1n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x + 2n, y - 3n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x + 2n, y - 2n]), r.PAWN + e.B);\n\t}\n\n\tfunction spawnAllWings(\n\t\tstartingPos: Map<CoordsKey, number>,\n\t\tstartX: bigint,\n\t\tstartY: bigint,\n\t\tendX: bigint,\n\t\tendY: bigint,\n\t): void {\n\t\tconst spacing = 8n;\n\t\tlet currX: bigint = startX;\n\t\tlet currY: bigint = startY;\n\t\tlet i = 0;\n\t\tdo {\n\t\t\tspawnWing(startingPos, currX, currY, i);\n\t\t\tcurrX -= spacing;\n\t\t\tcurrY -= spacing;\n\t\t\ti++;\n\t\t} while (currX > endX && currY > endY);\n\t}\n\n\tfunction spawnWing(startingPos: Map<CoordsKey, number>, x: bigint, y: bigint, i: number): void {\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x, y]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x, y - 1n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 1n, y - 1n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 1n, y - 2n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 2n, y - 2n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 2n, y - 3n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 3n, y - 3n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 3n, y - 4n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 4n, y - 4n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 4n, y - 5n]), r.PAWN + e.W);\n\n\t\t// Generate segments\n\t\tconst count: number = i + 1;\n\t\tconst segSpacing = 6n;\n\t\tlet segX: bigint = x - 5n;\n\t\tlet segY: bigint = y - 8n;\n\t\tfor (let a = 1; a <= count; a++) {\n\t\t\tconst isLastIndex: boolean = a === count;\n\t\t\tgenWingSegment(startingPos, segX, segY, isLastIndex);\n\t\t\tsegX -= segSpacing;\n\t\t\tsegY += segSpacing;\n\t\t}\n\n\t\tsetAir(startingPos, [x - 6n, y - 8n]);\n\t\tsetAir(startingPos, [x - 6n, y - 9n]);\n\t\tsetAir(startingPos, [x - 5n, y - 9n]);\n\t\tsetAir(startingPos, [x - 5n, y - 10n]);\n\t}\n\n\tfunction genWingSegment(\n\t\tstartingPos: Map<CoordsKey, number>,\n\t\tx: bigint,\n\t\ty: bigint,\n\t\tisLastIndex: boolean,\n\t): void {\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x, y - 2n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x, y - 1n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 1n, y - 1n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 1n, y + 0n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 0n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 1n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 3n, y + 1n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 3n, y + 2n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 4n, y + 2n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 4n, y + 3n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 3n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 4n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x, y + 2n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x, y + 3n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 1n, y + 3n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 1n, y + 4n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 4n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 5n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 6n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 7n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 8n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 9n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 10n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 11n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 3n, y + 11n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 3n, y + 12n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 4n, y + 12n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 4n, y + 13n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 11n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 12n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 10n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 9n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 8n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 7n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 4n, y + 7n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 4n, y + 6n]), r.PAWN + e.W);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 4n, y + 10n]), r.BISHOP + e.W);\n\n\t\tif (!isLastIndex) return;\n\n\t\t// Is last wing segment!\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 6n]), r.PAWN + e.B);\n\t\tstartingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 5n]), r.PAWN + e.W);\n\t}\n\n\tfunction addVoidSquaresToOmegaFourth(\n\t\tstartingPos: Map<CoordsKey, number>,\n\t\tleft: bigint,\n\t\ttop: bigint,\n\t\tright: bigint,\n\t\tbottomright: bigint,\n\t\tbottomleft: bigint,\n\t): void {\n\t\tfor (let x = left; x <= right; x++) {\n\t\t\tconst key: CoordsKey = coordutil.getKeyFromCoords([x, top]);\n\t\t\tstartingPos.set(key, r.VOID + e.N);\n\t\t}\n\t\tfor (let y = top; y >= bottomright; y--) {\n\t\t\tconst key: CoordsKey = coordutil.getKeyFromCoords([right, y]);\n\t\t\tstartingPos.set(key, r.VOID + e.N);\n\t\t}\n\t\tlet y: bigint = bottomright;\n\t\tfor (let x = right; x >= -3n; x--) {\n\t\t\tlet key: CoordsKey = coordutil.getKeyFromCoords([x, y]);\n\t\t\tstartingPos.set(key, r.VOID + e.N);\n\t\t\tkey = coordutil.getKeyFromCoords([x, y - 1n]);\n\t\t\tstartingPos.set(key, r.VOID + e.N);\n\t\t\ty--;\n\t\t}\n\t\tfor (let y = top; y >= bottomleft; y--) {\n\t\t\tconst key: CoordsKey = coordutil.getKeyFromCoords([left, y]);\n\t\t\tstartingPos.set(key, r.VOID + e.N);\n\t\t}\n\t\ty = bottomleft;\n\t\tfor (let x = left; x <= -4n; x++) {\n\t\t\tlet key: CoordsKey = coordutil.getKeyFromCoords([x, y]);\n\t\t\tstartingPos.set(key, r.VOID + e.N);\n\t\t\tkey = coordutil.getKeyFromCoords([x, y - 1n]);\n\t\t\tstartingPos.set(key, r.VOID + e.N);\n\t\t\ty--;\n\t\t}\n\t\tstartingPos.set(coordutil.getKeyFromCoords([492n, 493n]), r.VOID + e.N);\n\t}\n}\n\nexport default {\n\tgenPositionOfOmegaFourth,\n};\n"
  },
  {
    "path": "src/shared/chess/variants/servervalidation.ts",
    "content": "// src/shared/chess/variants/servervalidation.ts\n\n/**\n * This script defines which variants support server-side move legality validation.\n *\n * Variants with a position string length <= POSITION_STRING_THRESHOLD are considered\n * supported. Variants with large position strings (like Omega Squared and above) or\n * generator-based variants are excluded to avoid server hitches on legal move gen.\n */\n\nimport type { VariantCode } from './variantdictionary.js';\n\nimport variant from './variant.js';\n\n// Constants -----------------------------------------------------------------\n\n/**\n * The maximum position string length (in characters) for a variant to be\n * eligible for server-side move validation.\n * Obstocean (length 2425) is the largest supported variant.\n * Omega Squared and above (length > 2500) are excluded.\n */\nconst POSITION_STRING_THRESHOLD = 2500;\n\n// Functions -----------------------------------------------------------------\n\n/**\n * Returns `true` if the given variant supports server-side move legality validation.\n * Variants whose position string exceeds {@link POSITION_STRING_THRESHOLD} characters,\n * or that use position generators, are not supported.\n * @param variantCode - The variant code, if available.\n * @param timestamp - The game's start timestamp in ms since epoch.\n */\nfunction doesVariantSupportServerValidation(\n\tvariantCode: VariantCode | null,\n\ttimestamp: number,\n): boolean {\n\tif (variantCode === null) return false;\n\tconst positionString = variant.getVariantPositionString(variantCode, timestamp);\n\tif (positionString === undefined) return false; // Generator-based variant\n\treturn positionString.length <= POSITION_STRING_THRESHOLD;\n}\n\n/**\n * Returns `true` if the game is deleted instantly on conclusion — meaning the server\n * either validated every move (cheating is impossible) or it's a private game (cheat\n * reports are not allowed). In both cases:\n * - The server removes players from the active-games list immediately.\n * - Clients do not need to send `removefromplayersinactivegames`.\n * - Clients should not send cheat reports.\n * @param variantCode - The variant code, if available.\n * @param timestamp - The game's start timestamp in ms since epoch.\n * @param isPrivate - Whether the game is a private match.\n */\nfunction isGameInstantlyDeleted(\n\tvariantCode: VariantCode | null,\n\ttimestamp: number,\n\tisPrivate: boolean,\n): boolean {\n\treturn isPrivate || doesVariantSupportServerValidation(variantCode, timestamp);\n}\n\nexport { doesVariantSupportServerValidation, isGameInstantlyDeleted };\n"
  },
  {
    "path": "src/shared/chess/variants/validleaderboard.ts",
    "content": "// src/shared/chess/variants/validleaderboard.ts\n\n/**\n * This script stores all global variables related to our leaderboards.\n */\n\nimport type { VariantCode } from './variantdictionary.js';\n\nconst Leaderboards = {\n\t/**\n\t * The main leaderboard for all same-ish, infinity, variants.\n\t * Doesn't include any finite variants, or non-symmetrical ones.\n\t */\n\tINFINITY: 0,\n\t// Add more leaderboards here as needed\n} as const;\n\ntype Leaderboard = (typeof Leaderboards)[keyof typeof Leaderboards];\n\n/** Maps variants to the leaderboard they belong to, if they have one. */\nconst VariantLeaderboards: Partial<Record<VariantCode, Leaderboard>> = {\n\tClassical: Leaderboards.INFINITY,\n\tConfined_Classical: Leaderboards.INFINITY,\n\tClassical_Plus: Leaderboards.INFINITY,\n\tCoaIP: Leaderboards.INFINITY,\n\tCoaIP_HO: Leaderboards.INFINITY,\n\tCoaIP_RO: Leaderboards.INFINITY,\n\tCoaIP_NO: Leaderboards.INFINITY,\n\tPalace: Leaderboards.INFINITY,\n\tPawndard: Leaderboards.INFINITY,\n\tCore: Leaderboards.INFINITY,\n\tStandarch: Leaderboards.INFINITY,\n\tSpace_Classic: Leaderboards.INFINITY,\n\tSpace: Leaderboards.INFINITY,\n\tAbundance: Leaderboards.INFINITY,\n\t// Add more variants and their corresponding leaderboard here\n};\n\nexport { Leaderboard, Leaderboards, VariantLeaderboards };\n"
  },
  {
    "path": "src/shared/chess/variants/variant.ts",
    "content": "// src/shared/chess/variants/variant.ts\n\n/**\n * This script contains methods for retrieving the game rules, or movesets of any given variant.\n */\n\nimport type { BaseRay } from '../../util/math/geometry.js';\nimport type { GameRules } from '../util/gamerules.js';\nimport type { CoordsKey, Coords } from '../util/coordutil.js';\nimport type { GameruleWinCondition } from '../util/winconutil.js';\nimport type { Movesets, PieceMoveset } from '../logic/movesets.js';\nimport type { RawType, RawTypeGroup, PlayerGroup } from '../util/typeutil.js';\nimport type { SpecialMoveFunction, SpecialVicinity } from '../logic/specialmove.js';\nimport type {\n\tVariantCode,\n\tGameRuleModifications,\n\tTimeVariantProperty,\n\tVariant,\n} from './variantdictionary.js';\n\nimport jsutil from '../../util/jsutil.js';\nimport movesets from '../logic/movesets.js';\nimport specialmove from '../logic/specialmove.js';\nimport icnconverter from '../logic/icn/icnconverter.js';\nimport { players as p } from '../util/typeutil.js';\nimport variantDictionary from './variantdictionary.js';\n\n// Constants -------------------------------------------------------------------------------\n\nconst defaultWinConditions: PlayerGroup<GameruleWinCondition[]> = {\n\t[p.WHITE]: ['checkmate'],\n\t[p.BLACK]: ['checkmate'],\n};\nconst defaultTurnOrder = [p.WHITE, p.BLACK];\n\n/** Tuple of all valid variant code strings, for use in runtime validation (e.g. Zod schemas). */\nexport const variantCodes = Object.keys(variantDictionary) as VariantCode[];\n\n// Functions ---------------------------------------------------------------------------------\n\n/**\n * Tests if the provided variant is a valid variant.\n * Acts as a type guard, narrowing the input to {@link VariantCode}.\n * @param variantName - The name of the variant\n * @returns Whether the variant is a valid variant\n */\nfunction isVariantValid(variantName: string | undefined): variantName is VariantCode {\n\tif (variantName === undefined) return false;\n\treturn variantName in variantDictionary;\n}\n\n/**\n * Resolves a variant string (English name or code) sourced from metadata into a {@link VariantCode}.\n * Warns if the variant is not recognized.\n * @param variantName - The variant string from metadata (may be an English name, code, or undefined).\n * @returns The corresponding {@link VariantCode}, or `null` if the input is not recognized.\n */\nfunction resolveVariantCode(variantName: string | undefined): VariantCode | null {\n\tif (variantName === undefined) return null;\n\t// Direct code match\n\tif (variantName in variantDictionary) return variantName as VariantCode;\n\t// Search by English display name\n\tfor (const [code, variantEntry] of Object.entries(variantDictionary) as [\n\t\tVariantCode,\n\t\tVariant,\n\t][]) {\n\t\tif (variantEntry.name === variantName) return code;\n\t}\n\tconsole.warn(`Variant \"${variantName}\" is not recognized. Treating as no variant.`);\n\treturn null;\n}\n\n/**\n * Resolves the variant from the metadata, normalizes the metadata's `Variant` property to the\n * English display name (if recognized), or deletes it (if not recognized), then returns the\n * resolved {@link VariantCode}.\n * @param metadata - The metadata of the game with the optional `Variant` property. MUST BE A DIRECT REFERENCE (not a copy)\n * @returns The resolved {@link VariantCode}, or `null` if no valid variant was found.\n */\nfunction resolveAndNormalizeVariantInMetadata(metadata: { Variant?: string }): VariantCode | null {\n\tif (!metadata.Variant) return null;\n\tconst resolved = resolveVariantCode(metadata.Variant);\n\tif (resolved !== null) {\n\t\t// Normalize to English display name\n\t\tmetadata.Variant = variantDictionary[resolved].name;\n\t} else {\n\t\t// Unrecognized Variant: Treat as if no variant was specified\n\t\tdelete metadata.Variant;\n\t}\n\treturn resolved;\n}\n\n/**\n * Given the variant code and timestamp, calculates the starting position and specialRights.\n * @param variantCode - The variant code.\n * @param timestamp - The game's start timestamp in ms since epoch.\n * @returns An object containing 2 properties: `position`, and `specialRights`.\n */\nfunction getStartingPositionOfVariant(\n\tvariantCode: VariantCode,\n\ttimestamp: number,\n): {\n\tposition: Map<CoordsKey, number>;\n\tspecialRights: Set<CoordsKey>;\n} {\n\tconst variantEntry = variantDictionary[variantCode];\n\n\tlet positionString: string;\n\tlet position: Map<CoordsKey, number>;\n\n\t// Does the entry have a `positionString` property, or a `generator` property?\n\tif (variantEntry.positionString !== undefined) {\n\t\tpositionString = getApplicableTimestampEntry(variantEntry.positionString, timestamp);\n\t\treturn icnconverter.generatePositionFromShortForm(positionString);\n\t} else {\n\t\t// Generate the starting position\n\t\tposition = variantEntry.generator.algorithm();\n\t\tconst specialRights = icnconverter.generateSpecialRights(\n\t\t\tposition,\n\t\t\tvariantEntry.generator.rules.pawnDoublePush,\n\t\t\tvariantEntry.generator.rules.castleWith,\n\t\t);\n\t\treturn { position, specialRights };\n\t}\n}\n\n/**\n * Returns the variant's gamerules at the provided timestamp.\n * @param variantCode - The variant code.\n * @param timestamp - The game's start timestamp in ms since epoch.\n * @returns The gamerules object for the variant.\n */\nfunction getGameRulesOfVariant(variantCode: VariantCode, timestamp: number): GameRules {\n\tconst gameruleModifications: GameRuleModifications = jsutil.deepCopyObject(\n\t\tgetVariantGameRuleModifications(variantCode, timestamp),\n\t);\n\n\treturn getGameRules(gameruleModifications);\n}\n\n/** Returns the gamerule modifications for the given variant at the given timestamp. */\nfunction getVariantGameRuleModifications(\n\tvariantCode: VariantCode,\n\ttimestamp: number,\n): GameRuleModifications {\n\tconst variantEntry = variantDictionary[variantCode];\n\n\t// Does the gameruleModifications entry have multiple UTC timestamps? Or just one?\n\n\treturn getApplicableTimestampEntry(variantEntry.gameruleModifications, timestamp);\n}\n\n/**\n * Returns default gamerules with provided modifications\n * @param modifications - The modifications to the default gamerules.\n * @returns The gamerules\n */\nfunction getGameRules(modifications: GameRuleModifications = {}): GameRules {\n\t// { slideLimit, promotionRanks, position }\n\tconst gameRules: GameRules = {\n\t\t// REQUIRED gamerules\n\t\twinConditions: modifications.winConditions || jsutil.deepCopyObject(defaultWinConditions),\n\t\tturnOrder: modifications.turnOrder || jsutil.deepCopyObject(defaultTurnOrder),\n\t};\n\n\t// GameRules that have a dedicated ICN spot...\n\tif (modifications.promotionRanks !== null) {\n\t\t// Either undefined (use default), or custom\n\t\tgameRules.promotionRanks = modifications.promotionRanks || {\n\t\t\t[p.WHITE]: [8n],\n\t\t\t[p.BLACK]: [1n],\n\t\t};\n\t\tif (!modifications.promotionsAllowed)\n\t\t\tthrow new Error(\n\t\t\t\t'When overriding promotionRanks, you must also override promotionsAllowed!',\n\t\t\t);\n\t\tgameRules.promotionsAllowed = modifications.promotionsAllowed;\n\t}\n\tif (modifications.moveRule !== null) gameRules.moveRule = modifications.moveRule || 100;\n\n\t// GameRules that DON'T have a dedicated ICN spot...\n\tif (modifications.slideLimit !== undefined) gameRules.slideLimit = modifications.slideLimit;\n\n\treturn jsutil.deepCopyObject(gameRules) as GameRules; // Copy it so the game doesn't modify the values in this module.\n}\n\n/**\n * Returns the bare-minimum gamerules a game needs to function.\n * @returns {GameRules} The gameRules object\n */\nfunction getBareMinimumGameRules(): GameRules {\n\treturn getGameRules({ promotionRanks: null, moveRule: null }); // Erase the defaults to end up with only the required's\n}\n\n/**\n * Accepts a time-variant property and a timestamp, returns the value that should be used for that point in time.\n * @param object - A time-variant property (positionString, gameruleModifications, etc.)\n * @param timestamp - The timestamp in ms since epoch to select the appropriate value.\n */\nfunction getApplicableTimestampEntry<Inner>(\n\tobject: TimeVariantProperty<Inner>,\n\ttimestamp: number,\n): Inner {\n\t// Each of these checks are needed to determine whether ANY TimeVariantProperty has timestamp entries\n\tif (typeof object !== 'object' || object === null || !object.hasOwnProperty(0)) {\n\t\treturn object as Inner;\n\t}\n\n\tlet timeStampKeys = Object.keys(object as Object);\n\n\ttimeStampKeys = timeStampKeys.sort().reverse(); // [1709017200000, 0]\n\tlet timestampToUse: number;\n\tfor (const ts of timeStampKeys) {\n\t\tconst thisTimestamp = Number.parseInt(ts);\n\t\tif (thisTimestamp <= timestamp) {\n\t\t\ttimestampToUse = thisTimestamp;\n\t\t\tbreak;\n\t\t}\n\t}\n\treturn (object as { [timestamp: number]: Inner })[timestampToUse!]!;\n}\n\n/**\n * Gets the piece movesets for the given variant and timestamp.\n * @param variantCode - The variant code, or null for pasted games with no variant specified.\n * @param timestamp - The game's start timestamp in ms since epoch.\n * @param slideLimit - If provided, overrides the slideLimit gamerule of the variant. Only meaningful for variants without a movesetGenerator (i.e. those that use default movesets), because custom movesets define their own slide ranges explicitly and don't inherit a global slide limit.\n * @returns The pieceMovesets property of the gamefile.\n */\nfunction getMovesetsOfVariant(\n\tvariantCode: VariantCode | null,\n\ttimestamp: number,\n\tslideLimit?: bigint,\n): RawTypeGroup<() => PieceMoveset> {\n\t// Pasted games with no variant specified use the default movesets\n\tif (variantCode === null) return getMovesets(undefined, slideLimit);\n\tconst variantEntry = variantDictionary[variantCode];\n\n\tlet movesetModifications: Movesets;\n\n\tif (!variantEntry.movesetGenerator) {\n\t\tmovesetModifications = {};\n\t\tslideLimit =\n\t\t\tslideLimit ??\n\t\t\tgetApplicableTimestampEntry(variantEntry.gameruleModifications, timestamp).slideLimit;\n\t} else {\n\t\tmovesetModifications = getApplicableTimestampEntry(\n\t\t\tvariantEntry.movesetGenerator,\n\t\t\ttimestamp,\n\t\t)();\n\t}\n\n\treturn getMovesets(movesetModifications, slideLimit);\n}\n\n/**\n * Returns default movesets with provided modifications such that each piece contains a function returning a copy of its moveset (to avoid modifying originals).\n * Any piece type present in the modifications will replace the default move that for that piece.\n * The slidelimit gamerule will only be applied to default movesets, not modified ones.\n * @param movesetModifications - The modifications to the default movesets.\n * @param [defaultSlideLimitForOldVariants] Optional. The slidelimit to use for default movesets, if applicable.\n * @returns The pieceMovesets property of the gamefile.\n */\nfunction getMovesets(\n\tmovesetModifications: Movesets = {},\n\tdefaultSlideLimitForOldVariants?: bigint,\n): RawTypeGroup<() => PieceMoveset> {\n\tconst origMoveset = movesets.getPieceDefaultMovesets(defaultSlideLimitForOldVariants);\n\t// The running piece movesets property of the gamefile.\n\tconst pieceMovesets: RawTypeGroup<() => PieceMoveset> = {};\n\n\tfor (const [piece, moves] of Object.entries(origMoveset)) {\n\t\tconst intPiece = Number(piece) as RawType;\n\t\tpieceMovesets[intPiece] = movesetModifications[intPiece]\n\t\t\t? (): PieceMoveset => jsutil.deepCopyObject(movesetModifications[intPiece]!)\n\t\t\t: (): PieceMoveset => jsutil.deepCopyObject(moves);\n\t}\n\n\treturn pieceMovesets;\n}\n\n/** Returns the special moves for the given variant at the specified timestamp. */\nfunction getSpecialMovesOfVariant(\n\tvariantCode: VariantCode | null,\n\ttimestamp: number,\n): RawTypeGroup<SpecialMoveFunction> {\n\tconst defaultSpecialMoves = jsutil.deepCopyObject(specialmove.defaultSpecialMoves);\n\t// Pasted games with no variant specified use the default\n\tif (variantCode === null) return defaultSpecialMoves;\n\tconst variantEntry = variantDictionary[variantCode];\n\n\tif (variantEntry.specialMoves === undefined) return defaultSpecialMoves;\n\n\tconst overrides = getApplicableTimestampEntry(variantEntry.specialMoves, timestamp);\n\tjsutil.copyPropertiesToObject(overrides, defaultSpecialMoves);\n\treturn defaultSpecialMoves;\n}\n\n/** Returns the special vicinity for the given variant at the specified timestamp. */\nfunction getSpecialVicinityOfVariant(\n\tvariantCode: VariantCode | null,\n\ttimestamp: number,\n): SpecialVicinity {\n\tconst defaultSpecialVicinityByPiece = specialmove.getDefaultSpecialVicinitiesByPiece();\n\t// Pasted games with no variant specified use the default\n\tif (variantCode === null) return defaultSpecialVicinityByPiece;\n\tconst variantEntry = variantDictionary[variantCode];\n\n\tif (variantEntry.specialVicinity === undefined) return defaultSpecialVicinityByPiece;\n\n\tconst overrides = getApplicableTimestampEntry(variantEntry.specialVicinity, timestamp);\n\tjsutil.copyPropertiesToObject(overrides, defaultSpecialVicinityByPiece);\n\treturn defaultSpecialVicinityByPiece;\n}\n\n/** Returns the preset square annotations for the given variant, if they have any. */\nfunction getSquarePresets(variantCode: VariantCode | null): Coords[] {\n\tif (variantCode === null) return [];\n\tconst square_presets = variantDictionary[variantCode].annotePresets?.squares;\n\treturn square_presets ? icnconverter.parsePresetSquares(square_presets) : [];\n}\n\n/** Returns the preset ray annotations for the given variant, if they have any. */\nfunction getRayPresets(variantCode: VariantCode | null): BaseRay[] {\n\tif (variantCode === null) return [];\n\tconst ray_presets = variantDictionary[variantCode].annotePresets?.rays;\n\treturn ray_presets ? icnconverter.parsePresetRays(ray_presets) : [];\n}\n\n/** Returns the worldBorder property for the given variant, if they have one. */\nfunction getVariantWorldBorder(variantCode: VariantCode | null): bigint | undefined {\n\tif (variantCode === null) return undefined;\n\treturn variantDictionary[variantCode].worldBorderDist;\n}\n\n/**\n * Returns the position string for the given variant at the specified timestamp,\n * or `undefined` if the variant uses a generator (no fixed position string).\n * @param variantCode - The variant code.\n * @param timestamp - The game's start timestamp in ms since epoch.\n */\nfunction getVariantPositionString(variantCode: VariantCode, timestamp: number): string | undefined {\n\tconst variantEntry = variantDictionary[variantCode];\n\n\tif (!variantEntry.positionString) return undefined; // Generator-based variant\n\n\t// Multiple position strings for different timestamps\n\treturn getApplicableTimestampEntry(variantEntry.positionString, timestamp);\n}\n\n/** Returns the English display name of the given variant, as stored in the variant dictionary. */\nfunction getVariantName(variantCode: VariantCode): string {\n\treturn variantDictionary[variantCode].name;\n}\n\n// Exports ------------------------------------------------------------------\n\nexport default {\n\tisVariantValid,\n\tresolveVariantCode,\n\tresolveAndNormalizeVariantInMetadata,\n\tgetStartingPositionOfVariant,\n\tgetGameRulesOfVariant,\n\tgetMovesetsOfVariant,\n\tgetSpecialMovesOfVariant,\n\tgetSpecialVicinityOfVariant,\n\tgetBareMinimumGameRules,\n\tgetSquarePresets,\n\tgetRayPresets,\n\tgetVariantWorldBorder,\n\tgetVariantPositionString,\n\tgetVariantName,\n};\n"
  },
  {
    "path": "src/shared/chess/variants/variantdictionary.ts",
    "content": "// src/shared/chess/variants/variantdictionary.ts\n\n/**\n * This script stores the variant dictionary: the source of truth for every\n * variant's starting position, gamerule overrides, and moveset/special-move overrides.\n */\n\nimport type { Movesets } from '../logic/movesets.js';\nimport type { CoordsKey } from '../util/coordutil.js';\nimport type { GameruleWinCondition } from '../util/winconutil.js';\nimport type { RawType, Player, PlayerGroup } from '../util/typeutil.js';\nimport type { SpecialMoveFunction, SpecialVicinity } from '../logic/specialmove.js';\n\nimport omega3generator from './omega3generator.js';\nimport omega4generator from './omega4generator.js';\nimport fourdimensionalmoves from '../logic/fourdimensionalmoves.js';\nimport fourdimensionalgenerator from './fourdimensionalgenerator.js';\nimport { rawTypes as r, players as p } from '../util/typeutil.js';\n\n// Types -------------------------------------------------------------------------------\n\n/** An object that describes what modifications to make to default gamerules in a variant. */\nexport interface GameRuleModifications {\n\tpromotionRanks?: { [color: string]: bigint[] } | null;\n\tmoveRule?: number | null;\n\tturnOrder?: Player[];\n\tpromotionsAllowed?: PlayerGroup<RawType[]>;\n\twinConditions?: PlayerGroup<GameruleWinCondition[]>;\n\tslideLimit?: bigint;\n}\n\n/** Keys (if present) should be timestamps */\nexport type TimeVariantProperty<T> =\n\t| T\n\t| {\n\t\t\t[timestamp: number]: T;\n\t  };\n\n/** A single variant entry object in the variant dictionary */\nexport type Variant = {\n\t/** The English display name of the variant, used in game metadata (e.g. \"Chess on an Infinite Plane\"). */\n\tname: string;\n\t/**\n\t * A function that returns the movesetModifications for the variant.\n\t * The movesetModifications do NOT need to contain the movesets of every piece,\n\t * but only of the pieces you do not want to use their default movement!\n\t */\n\tmovesetGenerator?: TimeVariantProperty<() => Movesets>;\n\tgameruleModifications: TimeVariantProperty<GameRuleModifications>;\n\t/** Special Move overrides */\n\tspecialMoves?: TimeVariantProperty<{\n\t\t[piece: string]: SpecialMoveFunction;\n\t}>;\n\t/**\n\t * Used for check calculation.\n\t * If we have any overrides for specialMoves, we should have overrides for\n\t * this, because it means the piece could make captures on different locations.\n\t */\n\tspecialVicinity?: TimeVariantProperty<SpecialVicinity>;\n\t/**\n\t * Permanent preset annotations. Can't be erased.\n\t * Helpful for emphasizing important lines/squares in showcasings.\n\t */\n\tannotePresets?: {\n\t\t/** In compacted string form: '23,94|23,76' */\n\t\tsquares?: string;\n\t\t/** In compacted string form: '23,94>-1,0|23,76>-1,0' */\n\t\trays?: string;\n\t};\n\t/** If present, its how many squares of padding exist between the furthest piece on each side to the world border. */\n\tworldBorderDist?: bigint;\n} & (\n\t| {\n\t\t\t/** The position string of the variant, in the same format as ICN. */\n\t\t\tpositionString: TimeVariantProperty<string>;\n\t\t\tgenerator?: never;\n\t  }\n\t| {\n\t\t\t/** A function that generates the starting position of the variant, in key format `{ 'x,y':'type' }`. */\n\t\t\tgenerator: {\n\t\t\t\talgorithm: () => Map<CoordsKey, number>;\n\t\t\t\trules: {\n\t\t\t\t\tpawnDoublePush: boolean;\n\t\t\t\t\tcastleWith?: RawType;\n\t\t\t\t};\n\t\t\t};\n\t\t\tpositionString?: never;\n\t  }\n);\n\n/** Union of all valid variant codes, derived from the keys of {@link variantDictionary}. */\nexport type VariantCode = keyof typeof variantDictionary;\n\n// Constants -------------------------------------------------------------------------------\n\nconst positionStringOfClassical =\n\t'P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|R1,1+|R8,1+|r1,8+|r8,8+|N2,1|N7,1|n2,8|n7,8|B3,1|B6,1|b3,8|b6,8|Q4,1|q4,8|K5,1+|k5,8+';\nconst positionStringOfCoaIP =\n\t'P-2,1+|P-1,2+|P0,2+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P9,2+|P10,2+|P11,1+|P-4,-6+|P-3,-5+|P-2,-4+|P-1,-5+|P0,-6+|P9,-6+|P10,-5+|P11,-4+|P12,-5+|P13,-6+|p-2,8+|p-1,7+|p0,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p9,7+|p10,7+|p11,8+|p-4,15+|p-3,14+|p-2,13+|p-1,14+|p0,15+|p9,15+|p10,14+|p11,13+|p12,14+|p13,15+|HA-2,-6|HA11,-6|ha-2,15|ha11,15|R-1,1|R10,1|r-1,8|r10,8|CH0,1|CH9,1|ch0,8|ch9,8|GU1,1+|GU8,1+|gu1,8+|gu8,8+|N2,1|N7,1|n2,8|n7,8|B3,1|B6,1|b3,8|b6,8|Q4,1|q4,8|K5,1+|k5,8+';\n\n// const KOTHWinConditions: PlayerGroup<GameruleWinCondition[]> = { [p.WHITE]: ['checkmate','koth'], [p.BLACK]: ['checkmate','koth'] };\nconst royalCaptureWinConditions: PlayerGroup<GameruleWinCondition[]> = {\n\t[p.WHITE]: ['royalcapture'],\n\t[p.BLACK]: ['royalcapture'],\n};\n\nconst defaultPromotions = [r.KNIGHT, r.BISHOP, r.ROOK, r.QUEEN];\nconst defaultPromotionsAllowed = repeatPromotionsAllowedForEachColor(defaultPromotions);\nconst coaIPPromotions = [...defaultPromotions, r.GUARD, r.CHANCELLOR, r.HAWK];\nconst coaIPPromotionsAllowed = repeatPromotionsAllowedForEachColor(coaIPPromotions);\n\nconst gameruleModificationsOfOmegaShowcasings: GameRuleModifications = {\n\tpromotionRanks: null,\n\tmoveRule: null,\n\tturnOrder: [p.BLACK, p.WHITE],\n}; // No promotions, no 50-move rule, and reversed turn order.\n\n// ====================================== VARIANT DICTIONARY ======================================\n\n/**\n * An object that contains each variant's positional and gamerule information:\n *\n * A variant may contain either the `positionString` property, or `algorithm` property,\n * and may contain a `gameruleModifications` property (if not specified, default gamerules are used).\n *\n * `positionString` is in the same format as ICN.\n * `algorithm` needs to contain properties `algorithm`, and `rules`, the first of which points to a function\n * that returns a position in key format `{ 'x,y':'type' }`, and the second of which is an object which may\n * contain `pawnDoublePush` and `castleWith` properties, seeing as that info is not present in positional data.\n *\n * If either `positionString` or `gameruleModifications` has different values for different points\n * in time (variant has received an update), then it may contain nested UTC timestamps representing\n * the new values after that point in time.\n */\nconst variantDictionary = buildVariantDictionary({\n\tClassical: {\n\t\tname: 'Classical',\n\t\tpositionString: positionStringOfClassical,\n\t\tgameruleModifications: { promotionsAllowed: defaultPromotionsAllowed },\n\t\t// Enable to test world border in Classical variant\n\t\t// worldBorder: 4n,\n\t\t// worldBorder: BigInt(1e4),\n\t\t// annotePresets: {\n\t\t// \trays: '19,-1000>0,1|39,-1000>0,1|59,-1000>0,1|79,-1000>0,1|99,-1000>0,1|119,-1000>0,1|139,-1000>0,1|159,-1000>0,1|179,-1000>0,1|199,-1000>0,1|219,-1000>0,1|239,-1000>0,1'\n\t\t// }\n\t},\n\tCore: {\n\t\tname: 'Core',\n\t\tpositionString:\n\t\t\t'p-1,10+|p3,10+|p4,10+|p5,10+|p6,10+|p10,10+|p0,9+|p9,9+|n0,8|r1,8+|n2,8|b3,8|q4,8|k5,8+|b6,8|n7,8|r8,8+|n9,8|p-2,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p11,7+|p-3,6+|p12,6+|p1,5+|P2,5+|P7,5+|p8,5+|P1,4+|p2,4+|p7,4+|P8,4+|P-3,3+|P12,3+|P-2,2+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P11,2+|N0,1|R1,1+|N2,1|B3,1|Q4,1|K5,1+|B6,1|N7,1|R8,1+|N9,1|P0,0+|P9,0+|P-1,-1+|P3,-1+|P4,-1+|P5,-1+|P6,-1+|P10,-1+',\n\t\tgameruleModifications: { promotionsAllowed: defaultPromotionsAllowed },\n\t},\n\tStandarch: {\n\t\tname: 'Standarch',\n\t\tpositionString:\n\t\t\t'p4,11+|p5,11+|p1,10+|p2,10+|p3,10+|p6,10+|p7,10+|p8,10+|p0,9+|ar4,9|ch5,9|p9,9+|p0,8+|r1,8+|n2,8|b3,8|q4,8|k5,8+|b6,8|n7,8|r8,8+|p9,8+|p0,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p9,7+|P0,2+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P9,2+|P0,1+|R1,1+|N2,1|B3,1|Q4,1|K5,1+|B6,1|N7,1|R8,1+|P9,1+|P0,0+|AR4,0|CH5,0|P9,0+|P1,-1+|P2,-1+|P3,-1+|P6,-1+|P7,-1+|P8,-1+|P4,-2+|P5,-2+',\n\t\tgameruleModifications: {\n\t\t\tpromotionsAllowed: repeatPromotionsAllowedForEachColor([\n\t\t\t\t...defaultPromotions,\n\t\t\t\tr.CHANCELLOR,\n\t\t\t\tr.ARCHBISHOP,\n\t\t\t]),\n\t\t},\n\t},\n\tSpace_Classic: {\n\t\tname: 'Space Classic',\n\t\tpositionString: {\n\t\t\t// March 12, 2024, 12:00 AM - Swapped black king & queen so they are on the same side as white king & queen.\n\t\t\t1710201600000:\n\t\t\t\t'p-3,18+|r2,18|b4,18|b5,18|r7,18|p12,18+|p-4,17+|p13,17+|p-5,16+|p14,16+|p3,9+|p4,9+|p5,9+|p6,9+|n3,8|k4,8|q5,8|n6,8|p-6,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p-8,6+|p-7,6+|p16,6+|p17,6+|p-9,5+|p18,5+|P-9,4+|P18,4+|P-8,3+|P-7,3+|P16,3+|P17,3+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P15,2+|N3,1|K4,1|Q5,1|N6,1|P3,0+|P4,0+|P5,0+|P6,0+|P-5,-7+|P14,-7+|P-4,-8+|P13,-8+|P-3,-9+|R2,-9|B4,-9|B5,-9|R7,-9|P12,-9+',\n\t\t\t// UTC Feb 27, 2024, 7:00 AM - Rebalanced. No more queen-bishop skewer.\n\t\t\t1709017200000:\n\t\t\t\t'p-3,18+|r2,18|b4,18|b5,18|r7,18|p12,18+|p-4,17+|p13,17+|p-5,16+|p14,16+|p3,9+|p4,9+|p5,9+|p6,9+|n3,8|q4,8|k5,8|n6,8|p-6,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p-8,6+|p-7,6+|p16,6+|p17,6+|p-9,5+|p18,5+|P-9,4+|P18,4+|P-8,3+|P-7,3+|P16,3+|P17,3+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P15,2+|N3,1|K4,1|Q5,1|N6,1|P3,0+|P4,0+|P5,0+|P6,0+|P-5,-7+|P14,-7+|P-4,-8+|P13,-8+|P-3,-9+|R2,-9|B4,-9|B5,-9|R7,-9|P12,-9+',\n\t\t\t// Original. Queen & rook were easily skewer'able\n\t\t\t0: 'p-3,15+|q4,15|p11,15+|p-4,14+|b4,14|p12,14+|p-5,13+|r2,13|b4,13|r6,13|p13,13+|p3,5+|p4,5+|p5,5+|n3,4|k4,4|n5,4|p-6,3+|p1,3+|p2,3+|p3,3+|p4,3+|p5,3+|p6,3+|p7,3+|p-8,2+|p-7,2+|p15,2+|p16,2+|p-9,1+|p17,1+|P-9,0+|P17,0+|P-8,-1+|P-7,-1+|P15,-1+|P16,-1+|P1,-2+|P2,-2+|P3,-2+|P4,-2+|P5,-2+|P6,-2+|P7,-2+|P14,-2+|N3,-3|K4,-3|N5,-3|P3,-4+|P4,-4+|P5,-4+|P-5,-12+|R2,-12|B4,-12|R6,-12|P13,-12+|P-4,-13+|B4,-13|P12,-13+|P-3,-14+|Q4,-14|P11,-14+',\n\t\t},\n\t\tgameruleModifications: {\n\t\t\t// UTC Feb 27, 2024, 7:00 AM\n\t\t\t1709017200000: { promotionsAllowed: defaultPromotionsAllowed }, // Use standard [8,1] promotion lines\n\t\t\t0: {\n\t\t\t\tpromotionRanks: { [p.WHITE]: [4n], [p.BLACK]: [-3n] },\n\t\t\t\tpromotionsAllowed: defaultPromotionsAllowed,\n\t\t\t},\n\t\t},\n\t},\n\tCoaIP: {\n\t\tname: 'Chess on an Infinite Plane',\n\t\tpositionString: positionStringOfCoaIP,\n\t\tgameruleModifications: { promotionsAllowed: coaIPPromotionsAllowed },\n\t},\n\tPawn_Horde: {\n\t\tname: 'Pawn Horde',\n\t\tpositionString: {\n\t\t\t// UTC Jan 25, 2024, 4:00 AM - 1 pawn was removed on the sides, for a total of 2 added.\n\t\t\t// Win rates now show it's relatively balanced, white winning slightly above 50%,\n\t\t\t// however, high level players are confident they can always win with black.\n\t\t\t1706155200000:\n\t\t\t\t'k5,2+|q4,2|r1,2+|n7,2|n2,2|r8,2+|b3,2|b6,2|P2,-1+|P3,-1+|P6,-1+|P7,-1+|P1,-2+|P2,-2+|P4,-2+|P5,-2+|P6,-2+|P7,-2+|P8,-2+|P1,-3+|P2,-3+|P4,-3+|P5,-3+|P6,-3+|P7,-3+|P8,-3+|P1,-4+|P2,-4+|P4,-4+|P5,-4+|P6,-4+|P7,-4+|P8,-4+|P1,-5+|P2,-5+|P4,-5+|P5,-5+|P6,-5+|P7,-5+|P8,-5+|P1,-6+|P2,-6+|P4,-6+|P5,-6+|P6,-6+|P7,-6+|P8,-6+|P3,-2+|P3,-3+|P3,-4+|P3,-5+|P3,-6+|P1,-7+|P2,-7+|P3,-7+|P4,-7+|P5,-7+|P6,-7+|P7,-7+|P8,-7+|P0,-6+|P0,-7+|P9,-6+|P9,-7+|p9,2+|p1,1+|p2,1+|p3,1+|p4,1+|p5,1+|p6,1+|p7,1+|p8,1+|p0,2+',\n\t\t\t// UTC Nov 17, 2023, 12:00 AM - 3 more pawns were added on sides. White has slight advantage.\n\t\t\t1700179200000:\n\t\t\t\t'k5,2+|q4,2|r1,2+|n7,2|n2,2|r8,2+|b3,2|b6,2|P2,-1+|P3,-1+|P6,-1+|P7,-1+|P1,-2+|P2,-2+|P4,-2+|P5,-2+|P6,-2+|P7,-2+|P8,-2+|P1,-3+|P2,-3+|P4,-3+|P5,-3+|P6,-3+|P7,-3+|P8,-3+|P1,-4+|P2,-4+|P4,-4+|P5,-4+|P6,-4+|P7,-4+|P8,-4+|P1,-5+|P2,-5+|P4,-5+|P5,-5+|P6,-5+|P7,-5+|P8,-5+|P1,-6+|P2,-6+|P4,-6+|P5,-6+|P6,-6+|P7,-6+|P8,-6+|P3,-2+|P3,-3+|P3,-4+|P3,-5+|P3,-6+|P1,-7+|P2,-7+|P3,-7+|P4,-7+|P5,-7+|P6,-7+|P7,-7+|P8,-7+|P0,-6+|P0,-7+|P9,-6+|P9,-7+|P0,-5+|P9,-5+|p9,2+|p1,1+|p2,1+|p3,1+|p4,1+|p5,1+|p6,1+|p7,1+|p8,1+|p0,2+',\n\t\t\t// No pawns on the side. These games go back as far as we started logging games. White has massive disadvantage.\n\t\t\t0: 'k5,2+|q4,2|r1,2+|n7,2|n2,2|r8,2+|b3,2|b6,2|P2,-1+|P3,-1+|P6,-1+|P7,-1+|P1,-2+|P2,-2+|P4,-2+|P5,-2+|P6,-2+|P7,-2+|P8,-2+|P1,-3+|P2,-3+|P4,-3+|P5,-3+|P6,-3+|P7,-3+|P8,-3+|P1,-4+|P2,-4+|P4,-4+|P5,-4+|P6,-4+|P7,-4+|P8,-4+|P1,-5+|P2,-5+|P4,-5+|P5,-5+|P6,-5+|P7,-5+|P8,-5+|P1,-6+|P2,-6+|P4,-6+|P5,-6+|P6,-6+|P7,-6+|P8,-6+|P3,-2+|P3,-3+|P3,-4+|P3,-5+|P3,-6+|P1,-7+|P2,-7+|P3,-7+|P4,-7+|P5,-7+|P6,-7+|P7,-7+|P8,-7+|p9,2+|p1,1+|p2,1+|p3,1+|p4,1+|p5,1+|p6,1+|p7,1+|p8,1+|p0,2+',\n\t\t\t// Pawn Horde USED to have a massive ticking time bomb tower on the side,\n\t\t\t// but those games were back far enough when we weren't logging games.\n\t\t},\n\t\tgameruleModifications: {\n\t\t\twinConditions: { [p.WHITE]: ['checkmate'], [p.BLACK]: ['allpiecescaptured'] },\n\t\t\tpromotionRanks: { [p.WHITE]: [2n], [p.BLACK]: [-7n] },\n\t\t\tpromotionsAllowed: defaultPromotionsAllowed,\n\t\t},\n\t},\n\tSpace: {\n\t\tname: 'Space',\n\t\tpositionString:\n\t\t\t'q4,31|ch4,23|p-12,18+|b4,18|p20,18+|p-11,17+|ar-10,17|p0,17+|b4,17|p8,17+|ar18,17|p19,17+|p-11,16+|p-10,16+|p-1,16+|p9,16+|p18,16+|p19,16+|p-1,15+|r0,15|ha4,15|r8,15|p9,15+|p3,6+|p4,6+|p5,6+|p2,5+|k4,5|p6,5+|n1,4|ce4,4|n7,4|p-10,3+|p-1,3+|p0,3+|p2,3+|p3,3+|p4,3+|p5,3+|p6,3+|p8,3+|p9,3+|p-12,2+|p-11,2+|p19,2+|p20,2+|p-13,1+|p21,1+|P-13,0+|P21,0+|P-12,-1+|P-11,-1+|P19,-1+|P20,-1+|P-1,-2+|P0,-2+|P2,-2+|P3,-2+|P4,-2+|P5,-2+|P6,-2+|P8,-2+|P9,-2+|P18,-2+|N1,-3|CE4,-3|N7,-3|P2,-4+|K4,-4|P6,-4+|P3,-5+|P4,-5+|P5,-5+|P-1,-14+|R0,-14|HA4,-14|R8,-14|P9,-14+|P-11,-15+|P-10,-15+|P-1,-15+|P9,-15+|P18,-15+|P19,-15+|P-11,-16+|AR-10,-16|P0,-16+|B4,-16|P8,-16+|AR18,-16|P19,-16+|P-12,-17+|B4,-17|P20,-17+|CH4,-22|Q4,-30',\n\t\tgameruleModifications: {\n\t\t\tpromotionRanks: { [p.WHITE]: [4n], [p.BLACK]: [-3n] },\n\t\t\tpromotionsAllowed: repeatPromotionsAllowedForEachColor([\n\t\t\t\t...defaultPromotions,\n\t\t\t\tr.HAWK,\n\t\t\t\tr.CENTAUR,\n\t\t\t\tr.ARCHBISHOP,\n\t\t\t\tr.CHANCELLOR,\n\t\t\t]),\n\t\t},\n\t},\n\tObstocean: {\n\t\tname: 'Obstocean',\n\t\tpositionString:\n\t\t\t'ob-6,12|ob-5,12|ob-4,12|ob-3,12|ob-2,12|ob-1,12|ob0,12|ob1,12|ob2,12|ob3,12|ob4,12|ob5,12|ob6,12|ob7,12|ob8,12|ob9,12|ob10,12|ob11,12|ob12,12|ob13,12|ob14,12|ob15,12|ob-6,11|ob-5,11|ob-4,11|ob-3,11|ob-2,11|ob-1,11|ob0,11|ob1,11|ob2,11|ob3,11|ob4,11|ob5,11|ob6,11|ob7,11|ob8,11|ob9,11|ob10,11|ob11,11|ob12,11|ob13,11|ob14,11|ob15,11|ob-6,10|ob-5,10|ob-4,10|ob-3,10|ob-2,10|ob-1,10|ob0,10|ob1,10|ob2,10|ob3,10|ob4,10|ob5,10|ob6,10|ob7,10|ob8,10|ob9,10|ob10,10|ob11,10|ob12,10|ob13,10|ob14,10|ob15,10|ob-6,9|ob-5,9|ob-4,9|ob-3,9|ob-2,9|ob-1,9|ob0,9|ob1,9|ob2,9|ob3,9|ob4,9|ob5,9|ob6,9|ob7,9|ob8,9|ob9,9|ob10,9|ob11,9|ob12,9|ob13,9|ob14,9|ob15,9|ob-6,8|ob-5,8|ob-4,8|ob-3,8|ob-2,8|ob-1,8|ob0,8|r1,8+|n2,8|b3,8|q4,8|k5,8+|b6,8|n7,8|r8,8+|ob9,8|ob10,8|ob11,8|ob12,8|ob13,8|ob14,8|ob15,8|ob-6,7|ob-5,7|ob-4,7|ob-3,7|ob-2,7|ob-1,7|ob0,7|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|ob9,7|ob10,7|ob11,7|ob12,7|ob13,7|ob14,7|ob15,7|ob-6,6|ob-5,6|ob-4,6|ob-3,6|ob-2,6|ob-1,6|ob0,6|ob1,6|ob2,6|ob3,6|ob4,6|ob5,6|ob6,6|ob7,6|ob8,6|ob9,6|ob10,6|ob11,6|ob12,6|ob13,6|ob14,6|ob15,6|ob-6,5|ob-5,5|ob-4,5|ob-3,5|ob-2,5|ob-1,5|ob0,5|ob1,5|ob2,5|ob3,5|ob4,5|ob5,5|ob6,5|ob7,5|ob8,5|ob9,5|ob10,5|ob11,5|ob12,5|ob13,5|ob14,5|ob15,5|ob-6,4|ob-5,4|ob-4,4|ob-3,4|ob-2,4|ob-1,4|ob0,4|ob1,4|ob2,4|ob3,4|ob4,4|ob5,4|ob6,4|ob7,4|ob8,4|ob9,4|ob10,4|ob11,4|ob12,4|ob13,4|ob14,4|ob15,4|ob-6,3|ob-5,3|ob-4,3|ob-3,3|ob-2,3|ob-1,3|ob0,3|ob1,3|ob2,3|ob3,3|ob4,3|ob5,3|ob6,3|ob7,3|ob8,3|ob9,3|ob10,3|ob11,3|ob12,3|ob13,3|ob14,3|ob15,3|ob-6,2|ob-5,2|ob-4,2|ob-3,2|ob-2,2|ob-1,2|ob0,2|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|ob9,2|ob10,2|ob11,2|ob12,2|ob13,2|ob14,2|ob15,2|ob-6,1|ob-5,1|ob-4,1|ob-3,1|ob-2,1|ob-1,1|ob0,1|R1,1+|N2,1|B3,1|Q4,1|K5,1+|B6,1|N7,1|R8,1+|ob9,1|ob10,1|ob11,1|ob12,1|ob13,1|ob14,1|ob15,1|ob-6,0|ob-5,0|ob-4,0|ob-3,0|ob-2,0|ob-1,0|ob0,0|ob1,0|ob2,0|ob3,0|ob4,0|ob5,0|ob6,0|ob7,0|ob8,0|ob9,0|ob10,0|ob11,0|ob12,0|ob13,0|ob14,0|ob15,0|ob-6,-1|ob-5,-1|ob-4,-1|ob-3,-1|ob-2,-1|ob-1,-1|ob0,-1|ob1,-1|ob2,-1|ob3,-1|ob4,-1|ob5,-1|ob6,-1|ob7,-1|ob8,-1|ob9,-1|ob10,-1|ob11,-1|ob12,-1|ob13,-1|ob14,-1|ob15,-1|ob-6,-2|ob-5,-2|ob-4,-2|ob-3,-2|ob-2,-2|ob-1,-2|ob0,-2|ob1,-2|ob2,-2|ob3,-2|ob4,-2|ob5,-2|ob6,-2|ob7,-2|ob8,-2|ob9,-2|ob10,-2|ob11,-2|ob12,-2|ob13,-2|ob14,-2|ob15,-2|ob-6,-3|ob-5,-3|ob-4,-3|ob-3,-3|ob-2,-3|ob-1,-3|ob0,-3|ob1,-3|ob2,-3|ob3,-3|ob4,-3|ob5,-3|ob6,-3|ob7,-3|ob8,-3|ob9,-3|ob10,-3|ob11,-3|ob12,-3|ob13,-3|ob14,-3|ob15,-3',\n\t\tgameruleModifications: { promotionsAllowed: defaultPromotionsAllowed },\n\t\tworldBorderDist: 0n,\n\t},\n\tAbundance: {\n\t\tname: 'Abundance',\n\t\tpositionString:\n\t\t\t'p-3,10+|ha-2,10|ha-1,10|r0,10|ha1,10|ha2,10|p3,10+|p-2,9+|p-1,9+|p1,9+|p2,9+|p-5,6+|gu-4,6|r-3,6+|b-2,6|b-1,6|k0,6+|b1,6|b2,6|r3,6+|gu4,6|p5,6+|p-4,5+|gu-3,5|n-1,5|q0,5|n1,5|gu3,5|p4,5+|p-3,4+|p-2,4+|gu-1,4|ch0,4|gu1,4|p2,4+|p3,4+|p-1,3+|p0,3+|p1,3+|P-1,-3+|P0,-3+|P1,-3+|P-3,-4+|P-2,-4+|GU-1,-4|CH0,-4|GU1,-4|P2,-4+|P3,-4+|P-4,-5+|GU-3,-5|N-1,-5|Q0,-5|N1,-5|GU3,-5|P4,-5+|P-5,-6+|GU-4,-6|R-3,-6+|B-2,-6|B-1,-6|K0,-6+|B1,-6|B2,-6|R3,-6+|GU4,-6|P5,-6+|P-2,-9+|P-1,-9+|P1,-9+|P2,-9+|P-3,-10+|HA-2,-10|HA-1,-10|R0,-10|HA1,-10|HA2,-10|P3,-10+',\n\t\tgameruleModifications: {\n\t\t\tpromotionRanks: { [p.WHITE]: [6n], [p.BLACK]: [-6n] },\n\t\t\tpromotionsAllowed: repeatPromotionsAllowedForEachColor([\n\t\t\t\t...defaultPromotions,\n\t\t\t\tr.GUARD,\n\t\t\t\tr.HAWK,\n\t\t\t\tr.CHANCELLOR,\n\t\t\t]),\n\t\t},\n\t},\n\t// Amazon_Chandelier: {\n\t// \tpositionString: 'p-1,26+|p1,26+|p-2,25+|p-1,25+|p0,25+|p1,25+|p2,25+|p-2,24+|p-1,24+|am0,24|p1,24+|p2,24+|p-2,23+|p-1,23+|p0,23+|p1,23+|p2,23+|p-2,22+|p-1,22+|p1,22+|p2,22+|p-5,21+|p-4,21+|p-3,21+|p-2,21+|p-1,21+|p1,21+|p2,21+|p3,21+|p4,21+|p5,21+|p-5,20+|q-4,20|p-3,20+|p-2,20+|p-1,20+|p1,20+|p2,20+|p3,20+|q4,20|p5,20+|p-5,19+|p-4,19+|p-3,19+|p-2,19+|p-1,19+|p1,19+|p2,19+|p3,19+|p4,19+|p5,19+|p-5,18+|p-3,18+|p-2,18+|p-1,18+|p1,18+|p2,18+|p3,18+|p5,18+|p-8,17+|p-5,17+|p-3,17+|p-2,17+|p-1,17+|p1,17+|p2,17+|p3,17+|p5,17+|p8,17+|p-11,16+|p-10,16+|gu-9,16|ha-8,16|p-7,16+|gu-6,16|p-5,16+|p-3,16+|p-2,16+|p-1,16+|p1,16+|p2,16+|p3,16+|p5,16+|gu6,16|p7,16+|ha8,16|gu9,16|p10,16+|p11,16+|p-11,15+|r-10,15|p-9,15+|p-8,15+|r-7,15|p-6,15+|p-5,15+|p-3,15+|p-2,15+|p-1,15+|p1,15+|p2,15+|p3,15+|p5,15+|p6,15+|r7,15|p8,15+|p9,15+|r10,15|p11,15+|gu-12,14|p-11,14+|p-10,14+|p-9,14+|p-8,14+|p-7,14+|p-6,14+|p-5,14+|p-3,14+|p-2,14+|p-1,14+|p1,14+|p2,14+|p3,14+|p5,14+|p6,14+|p7,14+|p8,14+|p9,14+|p10,14+|p11,14+|gu12,14|p-19,13+|p-17,13+|gu-16,13|p-14,13+|p-12,13+|p-11,13+|p-9,13+|p-8,13+|p-6,13+|p-5,13+|p-3,13+|p-2,13+|p-1,13+|p1,13+|p2,13+|p3,13+|p5,13+|p6,13+|p8,13+|p9,13+|p11,13+|p12,13+|p14,13+|gu16,13|p17,13+|p19,13+|p-19,12+|b-18,12|p-17,12+|gu-16,12|p-14,12+|b-13,12|p-12,12+|p-11,12+|p-9,12+|p-8,12+|p-6,12+|p-5,12+|p-3,12+|p-2,12+|p-1,12+|p1,12+|p2,12+|p3,12+|p5,12+|p6,12+|p8,12+|p9,12+|p11,12+|p12,12+|b13,12|p14,12+|gu16,12|p17,12+|b18,12|p19,12+|gu-20,11|p-19,11+|p-17,11+|p-14,11+|p-12,11+|p-11,11+|p-9,11+|p-8,11+|p-6,11+|p-5,11+|p-3,11+|p-2,11+|p-1,11+|p1,11+|p2,11+|p3,11+|p5,11+|p6,11+|p8,11+|p9,11+|p11,11+|p12,11+|p14,11+|p17,11+|p19,11+|gu20,11|ha-20,10|p-19,10+|p-17,10+|p-14,10+|p-12,10+|p-11,10+|p-9,10+|p-8,10+|p-6,10+|p-5,10+|p-3,10+|p-2,10+|p-1,10+|p1,10+|p2,10+|p3,10+|p5,10+|p6,10+|p8,10+|p9,10+|p11,10+|p12,10+|p14,10+|p17,10+|p19,10+|ha20,10|n-11,9|n11,9|n-10,7|gu-5,7|gu-4,7|gu4,7|gu5,7|n10,7|n-8,6|n8,6|n-6,5|n6,5|n-4,4|k0,4|n4,4|n-2,3|n2,3|n0,2|N0,-1|N-2,-2|N2,-2|N-4,-3|K0,-3|N4,-3|N-6,-4|N6,-4|N-8,-5|N8,-5|N-10,-6|GU-5,-6|GU-4,-6|GU4,-6|GU5,-6|N10,-6|N-11,-8|N11,-8|HA-20,-9|P-19,-9+|P-17,-9+|P-14,-9+|P-12,-9+|P-11,-9+|P-9,-9+|P-8,-9+|P-6,-9+|P-5,-9+|P-3,-9+|P-2,-9+|P-1,-9+|P1,-9+|P2,-9+|P3,-9+|P5,-9+|P6,-9+|P8,-9+|P9,-9+|P11,-9+|P12,-9+|P14,-9+|P17,-9+|P19,-9+|HA20,-9|GU-20,-10|P-19,-10+|P-17,-10+|P-14,-10+|P-12,-10+|P-11,-10+|P-9,-10+|P-8,-10+|P-6,-10+|P-5,-10+|P-3,-10+|P-2,-10+|P-1,-10+|P1,-10+|P2,-10+|P3,-10+|P5,-10+|P6,-10+|P8,-10+|P9,-10+|P11,-10+|P12,-10+|P14,-10+|P17,-10+|P19,-10+|GU20,-10|P-19,-11+|B-18,-11|P-17,-11+|GU-16,-11|P-14,-11+|B-13,-11|P-12,-11+|P-11,-11+|P-9,-11+|P-8,-11+|P-6,-11+|P-5,-11+|P-3,-11+|P-2,-11+|P-1,-11+|P1,-11+|P2,-11+|P3,-11+|P5,-11+|P6,-11+|P8,-11+|P9,-11+|P11,-11+|P12,-11+|B13,-11|P14,-11+|GU16,-11|P17,-11+|B18,-11|P19,-11+|P-19,-12+|P-17,-12+|GU-16,-12|P-14,-12+|P-12,-12+|P-11,-12+|P-9,-12+|P-8,-12+|P-6,-12+|P-5,-12+|P-3,-12+|P-2,-12+|P-1,-12+|P1,-12+|P2,-12+|P3,-12+|P5,-12+|P6,-12+|P8,-12+|P9,-12+|P11,-12+|P12,-12+|P14,-12+|GU16,-12|P17,-12+|P19,-12+|GU-12,-13|P-11,-13+|P-10,-13+|P-9,-13+|P-8,-13+|P-7,-13+|P-6,-13+|P-5,-13+|P-3,-13+|P-2,-13+|P-1,-13+|P1,-13+|P2,-13+|P3,-13+|P5,-13+|P6,-13+|P7,-13+|P8,-13+|P9,-13+|P10,-13+|P11,-13+|GU12,-13|P-11,-14+|R-10,-14|P-9,-14+|P-8,-14+|R-7,-14|P-6,-14+|P-5,-14+|P-3,-14+|P-2,-14+|P-1,-14+|P1,-14+|P2,-14+|P3,-14+|P5,-14+|P6,-14+|R7,-14|P8,-14+|P9,-14+|R10,-14|P11,-14+|P-11,-15+|P-10,-15+|GU-9,-15|HA-8,-15|P-7,-15+|GU-6,-15|P-5,-15+|P-3,-15+|P-2,-15+|P-1,-15+|P1,-15+|P2,-15+|P3,-15+|P5,-15+|GU6,-15|P7,-15+|HA8,-15|GU9,-15|P10,-15+|P11,-15+|P-8,-16+|P-5,-16+|P-3,-16+|P-2,-16+|P-1,-16+|P1,-16+|P2,-16+|P3,-16+|P5,-16+|P8,-16+|P-5,-17+|P-3,-17+|P-2,-17+|P-1,-17+|P1,-17+|P2,-17+|P3,-17+|P5,-17+|P-5,-18+|P-4,-18+|P-3,-18+|P-2,-18+|P-1,-18+|P1,-18+|P2,-18+|P3,-18+|P4,-18+|P5,-18+|P-5,-19+|Q-4,-19|P-3,-19+|P-2,-19+|P-1,-19+|P1,-19+|P2,-19+|P3,-19+|Q4,-19|P5,-19+|P-5,-20+|P-4,-20+|P-3,-20+|P-2,-20+|P-1,-20+|P1,-20+|P2,-20+|P3,-20+|P4,-20+|P5,-20+|P-2,-21+|P-1,-21+|P1,-21+|P2,-21+|P-2,-22+|P-1,-22+|P0,-22+|P1,-22+|P2,-22+|P-2,-23+|P-1,-23+|AM0,-23|P1,-23+|P2,-23+|P-2,-24+|P-1,-24+|P0,-24+|P1,-24+|P2,-24+|P-1,-25+|P1,-25+',\n\t// \tgameruleModifications: { promotionRanks: { [p.WHITE]: [10], [p.BLACK]: [-9] }, promotionsAllowed: repeatPromotionsAllowedForEachColor([...defaultPromotions, r.HAWK, r.GUARD, r.AMAZON]) }\n\t// },\n\t// Containment: {\n\t// \tpositionString: 'K5,-5|k5,14|Q4,-5|q4,14|HA1,-6|HA8,-6|ha1,15|ha8,15|CH-6,-6|CH15,-6|ch-6,15|ch15,15|AR-6,-5|AR15,-5|ar-6,14|ar15,14|N-1,0|N1,0|N2,0|N4,-1|N5,-1|N7,0|N8,0|N10,0|n-1,9|n1,9|n2,9|n4,10|n5,10|n7,9|n8,9|n10,9|GU-2,-2|GU1,-3|GU3,-4|GU6,-4|GU8,-3|GU11,-2|gu-2,11|gu1,12|gu3,13|gu6,13|gu8,12|gu11,11|R-5,-6|R-5,-5|R-4,-5|R-4,-6|R13,-6|R13,-5|R14,-5|R14,-6|r-5,15|r-5,14|r-4,14|r-4,15|r13,15|r13,14|r14,14|r14,15|B-5,-2|B-4,-3|B-3,-2|B12,-2|B13,-3|B14,-2|b-5,11|b-4,12|b-3,11|b12,11|b13,12|b14,11|P-9,-8+|P-9,-6+|P-9,-4+|P-9,-2+|P-9,0+|P-9,2+|P-9,4+|P-9,6+|P-9,8+|P-9,10+|P-9,12+|P-9,14+|P-9,16+|P-8,-7+|P-8,-5+|P-8,-3+|P-8,-1+|P-8,1+|P-8,3+|P-8,5+|P-8,7+|P-8,9+|P-8,11+|P-8,13+|P-8,15+|P-8,17+|P17,-8+|P17,-6+|P17,-4+|P17,-2+|P17,0+|P17,2+|P17,4+|P17,6+|P17,8+|P17,10+|P17,12+|P17,14+|P17,16+|P18,-7+|P18,-5+|P18,-3+|P18,-1+|P18,1+|P18,3+|P18,5+|P18,7+|P18,9+|P18,11+|P18,13+|P18,15+|P18,17+|P-7,-8+|P-5,-8+|P-3,-8+|P-1,-8+|P1,-8+|P3,-8+|P5,-8+|P7,-8+|P9,-8+|P11,-8+|P13,-8+|P15,-8+|P-6,-7+|P-4,-7+|P-2,-7+|P0,-7+|P2,-7+|P4,-7+|P6,-7+|P8,-7+|P10,-7+|P12,-7+|P14,-7+|P16,-7+|P-7,16+|P-5,16+|P-3,16+|P-1,16+|P1,16+|P3,16+|P5,16+|P7,16+|P9,16+|P11,16+|P13,16+|P15,16+|P-6,17+|P-4,17+|P-2,17+|P0,17+|P2,17+|P4,17+|P6,17+|P8,17+|P10,17+|P12,17+|P14,17+|P16,17+|P-7,-6+|P-7,-4+|P-7,-2+|P-6,-2+|P-6,-1+|P-5,-1+|P-5,0+|P-5,-4+|P-4,-4+|P-4,-2+|P-4,-1+|P-3,-6+|P-3,-5+|P-3,-1+|P-3,0+|P-2,0+|P-2,1+|P-1,1+|P-1,-4+|P0,-3+|P1,-2+|P0,-1+|P0,1+|P1,1+|P2,1+|P3,1+|P3,0+|P3,-3+|P3,-5+|P4,-4+|P4,1+|P5,1+|P5,-4+|P6,-5+|P6,-3+|P6,0+|P6,1+|P7,1+|P8,1+|P9,1+|P9,-1+|P8,-2+|P9,-3+|P10,-4+|P10,1+|P11,1+|P11,0+|P12,0+|P12,-1+|P12,-5+|P12,-6+|P13,-4+|P13,-2+|P13,-1+|P14,0+|P14,-1+|P14,-4+|P15,-2+|P15,-1+|P16,-1+|P16,-3+|P16,-5+|p-9,-7+|p-9,-5+|p-9,-3+|p-9,-1+|p-9,1+|p-9,3+|p-9,5+|p-9,7+|p-9,9+|p-9,11+|p-9,13+|p-9,15+|p-9,17+|p-8,-8+|p-8,-6+|p-8,-4+|p-8,-2+|p-8,0+|p-8,2+|p-8,4+|p-8,6+|p-8,8+|p-8,10+|p-8,12+|p-8,14+|p-8,16+|p17,-7+|p17,-5+|p17,-3+|p17,-1+|p17,1+|p17,3+|p17,5+|p17,7+|p17,9+|p17,11+|p17,13+|p17,15+|p17,17+|p18,-8+|p18,-6+|p18,-4+|p18,-2+|p18,0+|p18,2+|p18,4+|p18,6+|p18,8+|p18,10+|p18,12+|p18,14+|p18,16+|p-6,-8+|p-4,-8+|p-2,-8+|p0,-8+|p2,-8+|p4,-8+|p6,-8+|p8,-8+|p10,-8+|p12,-8+|p14,-8+|p16,-8+|p-7,-7+|p-5,-7+|p-3,-7+|p-1,-7+|p1,-7+|p3,-7+|p5,-7+|p7,-7+|p9,-7+|p11,-7+|p13,-7+|p15,-7+|p-6,16+|p-4,16+|p-2,16+|p0,16+|p2,16+|p4,16+|p6,16+|p8,16+|p10,16+|p12,16+|p14,16+|p16,16+|p-7,17+|p-5,17+|p-3,17+|p-1,17+|p1,17+|p3,17+|p5,17+|p7,17+|p9,17+|p11,17+|p13,17+|p15,17+|p-7,15+|p-7,13+|p-7,11+|p-6,11+|p-6,10+|p-5,10+|p-5,9+|p-5,13+|p-4,13+|p-4,11+|p-4,10+|p-3,15+|p-3,14+|p-3,10+|p-3,9+|p-2,9+|p-2,8+|p-1,8+|p-1,13+|p0,12+|p1,11+|p0,10+|p0,8+|p1,8+|p2,8+|p3,8+|p3,9+|p3,12+|p3,14+|p4,13+|p4,8+|p5,8+|p5,13+|p6,14+|p6,12+|p6,9+|p6,8+|p7,8+|p8,8+|p9,8+|p9,10+|p8,11+|p9,12+|p10,13+|p10,8+|p11,8+|p11,9+|p12,9+|p12,10+|p12,14+|p12,15+|p13,13+|p13,11+|p13,10+|p14,9+|p14,10+|p14,13+|p15,11+|p15,10+|p16,10+|p16,12+|p16,14+',\n\t// \tgameruleModifications: { promotionRanks: null }\n\t// },\n\t// Classical_Limit_7: {\n\t// \tpositionString: positionStringOfClassical,\n\t// \tgameruleModifications: { slideLimit: 7, promotionsAllowed: defaultPromotionsAllowed }\n\t// },\n\t// CoaIP_Limit_7: {\n\t// \tpositionString: positionStringOfCoaIP,\n\t// \tgameruleModifications: { slideLimit: 7, promotionsAllowed: coaIPPromotionsAllowed }\n\t// },\n\tChess: {\n\t\tname: 'Chess',\n\t\tpositionString: positionStringOfClassical,\n\t\tgameruleModifications: { promotionsAllowed: defaultPromotionsAllowed },\n\t\tworldBorderDist: 0n,\n\t},\n\t// Classical_KOTH: {\n\t// \tpositionString: positionStringOfClassical,\n\t// \tgameruleModifications: { winConditions: KOTHWinConditions, promotionsAllowed: defaultPromotionsAllowed }\n\t// },\n\t// CoaIP_KOTH: {\n\t// \tpositionString: positionStringOfCoaIP,\n\t// \tgameruleModifications: { winConditions: KOTHWinConditions, promotionsAllowed: coaIPPromotionsAllowed }\n\t// },\n\tConfined_Classical: {\n\t\tname: 'Confined Classical',\n\t\tpositionString:\n\t\t\t'P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|R1,1+|R8,1+|r1,8+|r8,8+|N2,1|N7,1|n2,8|n7,8|B3,1|B6,1|b3,8|b6,8|Q4,1|q4,8|K5,1+|k5,8+|ob0,0|ob0,1|ob0,2|ob0,7|ob0,8|ob0,9|ob9,0|ob9,1|ob9,2|ob9,7|ob9,8|ob9,9|ob1,0|ob2,0|ob3,0|ob4,0|ob5,0|ob6,0|ob7,0|ob8,0|ob1,9|ob2,9|ob3,9|ob4,9|ob5,9|ob6,9|ob7,9|ob8,9',\n\t\tgameruleModifications: { promotionsAllowed: defaultPromotionsAllowed },\n\t},\n\tClassical_Plus: {\n\t\tname: 'Classical+',\n\t\tpositionString:\n\t\t\t'p1,9+|p2,9+|p3,9+|p6,9+|p7,9+|p8,9+|p0,8+|r1,8+|n2,8|b3,8|q4,8|k5,8+|b6,8|n7,8|r8,8+|p9,8+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p3,5+|p6,5+|P3,4+|P6,4+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P0,1+|R1,1+|N2,1|B3,1|Q4,1|K5,1+|B6,1|N7,1|R8,1+|P9,1+|P1,0+|P2,0+|P3,0+|P6,0+|P7,0+|P8,0+',\n\t\tgameruleModifications: { promotionsAllowed: defaultPromotionsAllowed },\n\t},\n\tPawndard: {\n\t\tname: 'Pawndard',\n\t\tpositionString: {\n\t\t\t// March 31, 2026, 11:10AM UTC - Kings are no longer given special rights.\n\t\t\t1774955419082:\n\t\t\t\t'b4,14|b5,14|r4,12|r5,12|p2,10+|p3,10+|p6,10+|p7,10+|p1,9+|p8,9+|p0,8+|n2,8|n3,8|k4,8|q5,8|n6,8|n7,8|p9,8+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|P1,5+|p2,5+|P3,5+|p6,5+|P7,5+|p8,5+|p1,4+|P2,4+|p3,4+|P6,4+|p7,4+|P8,4+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P0,1+|N2,1|N3,1|Q4,1|K5,1|N6,1|N7,1|P9,1+|P1,0+|P8,0+|P2,-1+|P3,-1+|P6,-1+|P7,-1+|R4,-3|R5,-3|B4,-5|B5,-5',\n\t\t\t// Kings were originally given special rights.\n\t\t\t0: 'b4,14|b5,14|r4,12|r5,12|p2,10+|p3,10+|p6,10+|p7,10+|p1,9+|p8,9+|p0,8+|n2,8|n3,8|k4,8+|q5,8|n6,8|n7,8|p9,8+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|P1,5+|p2,5+|P3,5+|p6,5+|P7,5+|p8,5+|p1,4+|P2,4+|p3,4+|P6,4+|p7,4+|P8,4+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P0,1+|N2,1|N3,1|Q4,1|K5,1+|N6,1|N7,1|P9,1+|P1,0+|P8,0+|P2,-1+|P3,-1+|P6,-1+|P7,-1+|R4,-3|R5,-3|B4,-5|B5,-5',\n\t\t},\n\t\tgameruleModifications: { promotionsAllowed: defaultPromotionsAllowed },\n\t},\n\tKnightline: {\n\t\tname: 'Knightline',\n\t\tpositionString:\n\t\t\t'k5,8|n3,8|n4,8|n6,8|n7,8|p-5,7+|p-4,7+|p-3,7+|p-2,7+|p-1,7+|p0,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p9,7+|p10,7+|p11,7+|p12,7+|p13,7+|p14,7+|p15,7+|K5,1|N3,1|N4,1|N6,1|N7,1|P-5,2+|P-4,2+|P-3,2+|P-2,2+|P-1,2+|P0,2+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P9,2+|P10,2+|P11,2+|P12,2+|P13,2+|P14,2+|P15,2+',\n\t\tgameruleModifications: {\n\t\t\tpromotionsAllowed: repeatPromotionsAllowedForEachColor([r.KNIGHT, r.QUEEN]),\n\t\t},\n\t},\n\tPalace: {\n\t\tname: 'Palace',\n\t\tpositionString:\n\t\t\t'K4,1|Q5,1|P6,2+|P5,2+|P4,2+|P3,2+|P2,2+|P1,2+|p1,4+|p2,4+|p3,4+|p4,4+|p5,4+|p6,4+|N6,1|AM3,1|Q2,1|N1,1|n1,5|n6,5|k4,5|q5,5|q2,5|am3,5|P6,-1+|P7,-1+|P8,-1+|P9,-1+|P1,-1+|P0,-1+|P-1,-1+|P-2,-1+|P2,-2+|P-3,-2+|P5,-2+|P10,-2+|p7,7+|p6,7+|p8,7+|p9,7+|p1,7+|p0,7+|p-1,7+|p-2,7+|p-3,8+|p2,8+|p5,8+|p10,8+|r-1,8|r-2,8|r8,8|r9,8|R8,-2|R9,-2|R-1,-2|R-2,-2|B0,-2|B1,-2|B7,-2|B6,-2|b0,8|b1,8|b7,8|b6,8',\n\t\tgameruleModifications: {\n\t\t\tpromotionRanks: { [p.WHITE]: [4n], [p.BLACK]: [2n] },\n\t\t\tpromotionsAllowed: repeatPromotionsAllowedForEachColor([\n\t\t\t\t...defaultPromotions,\n\t\t\t\tr.AMAZON,\n\t\t\t]),\n\t\t},\n\t},\n\tOmega: {\n\t\tname: 'Showcase: Omega',\n\t\tpositionString: {\n\t\t\t// May 15, 2024, 12:00AM - Pawns could no longer double push, that was a bug.\n\t\t\t1715731200000:\n\t\t\t\t'r-2,4|r2,4|r-2,2|r2,2|r-2,0|r0,0|r2,0|k0,-1|R1,-2|P-2,-3|Q-1,-3|P2,-3|K0,-4',\n\t\t\t// Pawns could originally double push, as a bug.\n\t\t\t0: 'r-2,4|r2,4|r-2,2|r2,2|r-2,0|r0,0|r2,0|k0,-1|R1,-2|P-2,-3+|Q-1,-3|P2,-3+|K0,-4',\n\t\t},\n\t\tgameruleModifications: gameruleModificationsOfOmegaShowcasings,\n\t},\n\tOmega_Squared: {\n\t\tname: 'Showcase: Omega^2',\n\t\tpositionString: {\n\t\t\t// May 15, 2024, 12:00AM - Pawns could no longer double push, that was a bug.\n\t\t\t1715731200000:\n\t\t\t\t'K51,94|k46,80|Q30,148|Q32,148|Q29,3|q29,148|q24,98|q24,97|q24,92|q24,91|q24,86|q24,85|q24,80|q24,79|q46,78|q45,77|q46,77|q45,76|q46,76|q78,60|N15,84|n63,64|r53,96|r45,81|r46,81|r46,79|r47,79|r45,78|B27,152|B29,152|B27,151|B28,151|B30,151|B32,151|B27,150|B28,150|B29,150|B30,150|B31,150|B32,150|B32,149|B9,96|B11,96|B15,96|B20,96|B47,87|B43,86|B44,82|B50,82|B51,81|B8,79|B10,79|B8,78|B10,78|B14,78|B19,78|B49,77|B41,72|B43,72|B45,72|B47,72|B49,72|B51,72|B53,72|B68,72|B10,71|B14,71|B18,71|B20,71|B22,71|B24,71|B76,55|B78,55|B80,55|B82,55|B84,55|B27,20|B29,20|B29,4|b27,155|b29,155|b31,155|b32,154|b9,99|b11,99|b15,99|b20,97|b33,97|b24,96|b11,92|b13,92|b15,92|b19,92|b47,91|b48,91|b49,91|b50,91|b51,91|b24,90|b47,90|b49,90|b51,90|b48,89|b50,89|b51,89|b47,88|b49,88|b51,88|b37,87|b48,87|b50,87|b51,87|b19,86|b49,86|b51,86|b48,85|b50,85|b24,84|b49,84|b51,84|b9,83|b48,83|b50,83|b51,82|b18,80|b14,79|b24,78|b52,77|b53,77|b47,76|b49,76|b51,76|b52,76|b53,76|b66,76|b70,76|b45,75|b47,75|b49,75|b51,75|b53,75|b10,74|b14,74|b18,74|b20,74|b22,74|b24,74|b58,74|b75,71|b78,58|b80,58|b82,58|b84,58|b27,23|b29,23|P26,155|P28,155|P30,155|P32,155|P27,154|P29,154|P31,154|P33,154|P26,153|P28,153|P30,153|P32,153|P26,152|P28,152|P31,152|P33,152|P26,151|P29,151|P31,151|P33,151|P26,150|P33,150|P26,149|P27,149|P28,149|P29,149|P30,149|P31,149|P33,149|P31,148|P33,148|P26,147|P28,147|P30,147|P31,147|P32,147|P33,147|P15,146|P27,146|P29,146|P28,145|P25,111|P24,110|P23,109|P22,108|P21,107|P25,107|P20,106|P24,106|P19,105|P23,105|P20,104|P19,103|P25,103|P20,102|P24,102|P19,101|P23,101|P20,100|P4,99|P6,99|P8,99|P10,99|P12,99|P14,99|P16,99|P19,99|P3,98|P5,98|P7,98|P9,98|P11,98|P15,98|P20,98|P4,97|P6,97|P8,97|P10,97|P12,97|P14,97|P16,97|P19,97|P21,97|P32,97|P34,97|P3,96|P5,96|P8,96|P10,96|P12,96|P33,96|P35,96|P4,95|P6,95|P8,95|P9,95|P10,95|P11,95|P12,95|P14,95|P16,95|P19,95|P21,95|P32,95|P34,95|P36,95|P23,94|P33,94|P35,94|P37,94|P8,93|P9,93|P34,93|P36,93|P38,93|P4,92|P6,92|P8,92|P10,92|P12,92|P14,92|P16,92|P18,92|P20,92|P35,92|P37,92|P39,92|P3,91|P5,91|P7,91|P9,91|P11,91|P13,91|P15,91|P19,91|P21,91|P36,91|P38,91|P40,91|P4,90|P6,90|P8,90|P10,90|P12,90|P14,90|P16,90|P18,90|P20,90|P35,90|P39,90|P41,90|P3,89|P5,89|P7,89|P9,89|P11,89|P13,89|P15,89|P19,89|P21,89|P34,89|P40,89|P42,89|P4,88|P6,88|P8,88|P10,88|P12,88|P14,88|P16,88|P23,88|P33,88|P37,88|P41,88|P43,88|P46,88|P48,88|P3,87|P5,87|P7,87|P9,87|P11,87|P13,87|P15,87|P32,87|P36,87|P38,87|P42,87|P44,87|P4,86|P6,86|P8,86|P10,86|P12,86|P14,86|P18,86|P20,86|P31,86|P35,86|P37,86|P39,86|P42,86|P44,86|P46,86|P48,86|P3,85|P5,85|P7,85|P9,85|P11,85|P13,85|P15,85|P17,85|P19,85|P21,85|P32,85|P36,85|P38,85|P40,85|P42,85|P43,85|P44,85|P3,84|P5,84|P7,84|P9,84|P11,84|P13,84|P18,84|P20,84|P33,84|P37,84|P39,84|P42,84|P43,84|P44,84|P52,84|P4,83|P6,83|P8,83|P10,83|P12,83|P14,83|P16,83|P19,83|P21,83|P34,83|P38,83|P40,83|P42,83|P43,83|P44,83|P49,83|P51,83|P3,82|P5,82|P7,82|P9,82|P11,82|P13,82|P15,82|P23,82|P31,82|P35,82|P39,82|P42,82|P43,82|P52,82|P2,81|P4,81|P6,81|P8,81|P10,81|P12,81|P14,81|P32,81|P38,81|P40,81|P42,81|P43,81|P44,81|P49,81|P3,80|P5,80|P7,80|P9,80|P11,80|P17,80|P19,80|P21,80|P31,80|P33,80|P37,80|P39,80|P50,80|P52,80|P2,79|P4,79|P7,79|P9,79|P11,79|P13,79|P15,79|P18,79|P20,79|P32,79|P34,79|P36,79|P38,79|P40,79|P44,79|P3,78|P5,78|P7,78|P9,78|P11,78|P17,78|P21,78|P33,78|P35,78|P37,78|P39,78|P41,78|P43,78|P2,77|P4,77|P7,77|P8,77|P9,77|P10,77|P11,77|P13,77|P15,77|P18,77|P20,77|P34,77|P36,77|P38,77|P40,77|P42,77|P23,76|P35,76|P37,76|P39,76|P41,76|P65,76|P67,76|P69,76|P71,76|P7,75|P8,75|P36,75|P38,75|P40,75|P42,75|P64,75|P66,75|P70,75|P3,74|P5,74|P7,74|P9,74|P11,74|P13,74|P15,74|P17,74|P19,74|P21,74|P23,74|P25,74|P37,74|P39,74|P41,74|P57,74|P59,74|P63,74|P65,74|P67,74|P69,74|P71,74|P2,73|P4,73|P6,73|P8,73|P10,73|P14,73|P18,73|P20,73|P22,73|P24,73|P38,73|P40,73|P42,73|P44,73|P46,73|P48,73|P50,73|P52,73|P54,73|P58,73|P62,73|P64,73|P66,73|P70,73|P72,73|P3,72|P5,72|P7,72|P9,72|P11,72|P13,72|P15,72|P17,72|P19,72|P21,72|P23,72|P25,72|P39,72|P57,72|P59,72|P61,72|P63,72|P65,72|P71,72|P2,71|P4,71|P6,71|P8,71|P40,71|P42,71|P44,71|P46,71|P48,71|P50,71|P52,71|P54,71|P58,71|P62,71|P64,71|P66,71|P70,71|P72,71|P74,71|P76,71|P3,70|P5,70|P7,70|P9,70|P11,70|P13,70|P15,70|P17,70|P19,70|P21,70|P23,70|P25,70|P57,70|P59,70|P61,70|P63,70|P65,70|P71,70|P75,70|P77,70|P56,69|P58,69|P62,69|P64,69|P72,69|P74,69|P76,69|P78,69|P57,68|P59,68|P61,68|P63,68|P67,68|P69,68|P75,68|P77,68|P79,68|P56,67|P58,67|P62,67|P66,67|P70,67|P74,67|P76,67|P78,67|P80,67|P57,66|P59,66|P64,66|P67,66|P69,66|P71,66|P75,66|P77,66|P79,66|P81,66|P56,65|P59,65|P63,65|P66,65|P70,65|P76,65|P78,65|P80,65|P82,65|P57,64|P59,64|P62,64|P65,64|P67,64|P69,64|P71,64|P73,64|P77,64|P79,64|P81,64|P83,64|P56,63|P58,63|P66,63|P70,63|P74,63|P78,63|P80,63|P82,63|P84,63|P57,62|P59,62|P61,62|P63,62|P65,62|P67,62|P69,62|P71,62|P73,62|P75,62|P79,62|P81,62|P83,62|P85,62|P56,61|P58,61|P60,61|P62,61|P64,61|P66,61|P70,61|P74,61|P76,61|P82,61|P84,61|P57,60|P59,60|P61,60|P63,60|P65,60|P67,60|P69,60|P71,60|P73,60|P75,60|P80,60|P82,60|P56,59|P58,59|P60,59|P62,59|P64,59|P66,59|P70,59|P74,59|P57,58|P59,58|P61,58|P63,58|P65,58|P73,58|P75,58|P58,57|P60,57|P62,57|P64,57|P74,57|P73,56|P75,56|P77,56|P79,56|P81,56|P83,56|P85,56|P74,55|P75,54|P77,54|P79,54|P81,54|P83,54|P85,54|P26,23|P28,23|P30,23|P27,22|P29,22|P26,21|P28,21|P30,21|P26,19|P28,19|P30,19|P26,18|P30,18|P26,17|P30,17|P26,16|P28,16|P30,16|P26,15|P28,15|P30,15|P26,14|P28,14|P30,14|P26,13|P28,13|P30,13|P26,12|P28,12|P30,12|P26,11|P28,11|P30,11|P26,10|P28,10|P30,10|P26,9|P28,9|P30,9|P26,8|P28,8|P30,8|P26,7|P28,7|P30,7|P26,6|P28,6|P30,6|P26,5|P28,5|P30,5|P26,4|P28,4|P30,4|P26,3|P28,3|P30,3|P26,2|P27,2|P28,2|P29,2|P30,2|p26,156|p28,156|p30,156|p32,156|p33,155|p26,154|p28,154|p30,154|p31,153|p33,153|p15,147|p25,112|p24,111|p23,110|p22,109|p25,109|p21,108|p25,108|p20,107|p24,107|p19,106|p23,106|p20,105|p25,105|p19,104|p25,104|p20,103|p24,103|p19,102|p23,102|p20,101|p25,101|p4,100|p6,100|p8,100|p10,100|p12,100|p14,100|p16,100|p19,100|p24,100|p25,100|p3,99|p5,99|p7,99|p20,99|p23,99|p24,99|p25,99|p4,98|p6,98|p8,98|p10,98|p12,98|p14,98|p16,98|p19,98|p21,98|p23,98|p25,98|p32,98|p34,98|p3,97|p5,97|p15,97|p23,97|p25,97|p35,97|p4,96|p6,96|p14,96|p16,96|p19,96|p21,96|p23,96|p25,96|p32,96|p34,96|p36,96|p18,95|p23,95|p25,95|p33,95|p35,95|p37,95|p25,94|p34,94|p36,94|p38,94|p4,93|p6,93|p10,93|p12,93|p14,93|p16,93|p18,93|p20,93|p23,93|p24,93|p25,93|p35,93|p37,93|p39,93|p3,92|p5,92|p7,92|p9,92|p21,92|p23,92|p25,92|p36,92|p38,92|p40,92|p46,92|p47,92|p48,92|p49,92|p50,92|p51,92|p52,92|p4,91|p6,91|p8,91|p10,91|p12,91|p14,91|p16,91|p18,91|p20,91|p23,91|p25,91|p35,91|p39,91|p41,91|p46,91|p52,91|p3,90|p5,90|p7,90|p9,90|p11,90|p13,90|p15,90|p19,90|p21,90|p23,90|p25,90|p34,90|p40,90|p42,90|p46,90|p48,90|p50,90|p52,90|p4,89|p6,89|p8,89|p10,89|p12,89|p14,89|p16,89|p23,89|p25,89|p33,89|p37,89|p41,89|p43,89|p46,89|p52,89|p3,88|p5,88|p7,88|p9,88|p11,88|p13,88|p15,88|p25,88|p32,88|p36,88|p38,88|p42,88|p44,88|p50,88|p52,88|p4,87|p6,87|p8,87|p10,87|p12,87|p14,87|p18,87|p20,87|p23,87|p24,87|p25,87|p31,87|p35,87|p39,87|p46,87|p52,87|p3,86|p5,86|p7,86|p9,86|p11,86|p13,86|p15,86|p17,86|p21,86|p23,86|p25,86|p32,86|p36,86|p38,86|p40,86|p47,86|p50,86|p52,86|p18,85|p20,85|p23,85|p25,85|p33,85|p37,85|p39,85|p46,85|p47,85|p49,85|p52,85|p4,84|p6,84|p8,84|p10,84|p12,84|p14,84|p16,84|p19,84|p21,84|p23,84|p25,84|p34,84|p38,84|p40,84|p46,84|p47,84|p3,83|p5,83|p7,83|p11,83|p13,83|p15,83|p23,83|p25,83|p31,83|p35,83|p39,83|p46,83|p47,83|p52,83|p2,82|p4,82|p6,82|p8,82|p10,82|p12,82|p14,82|p25,82|p32,82|p38,82|p40,82|p46,82|p47,82|p49,82|p3,81|p5,81|p7,81|p9,81|p11,81|p13,81|p15,81|p17,81|p19,81|p21,81|p23,81|p24,81|p25,81|p31,81|p33,81|p37,81|p39,81|p47,81|p50,81|p52,81|p2,80|p4,80|p13,80|p15,80|p20,80|p23,80|p25,80|p32,80|p34,80|p36,80|p38,80|p40,80|p44,80|p47,80|p3,79|p5,79|p17,79|p19,79|p21,79|p23,79|p25,79|p33,79|p35,79|p37,79|p39,79|p41,79|p43,79|p45,79|p2,78|p4,78|p13,78|p15,78|p18,78|p20,78|p23,78|p25,78|p34,78|p36,78|p38,78|p40,78|p42,78|p44,78|p47,78|p49,78|p51,78|p52,78|p53,78|p54,78|p17,77|p23,77|p25,77|p35,77|p37,77|p39,77|p41,77|p44,77|p47,77|p48,77|p50,77|p51,77|p54,77|p65,77|p67,77|p69,77|p71,77|p25,76|p36,76|p38,76|p40,76|p42,76|p44,76|p48,76|p50,76|p54,76|p64,76|p3,75|p5,75|p9,75|p11,75|p13,75|p15,75|p17,75|p19,75|p21,75|p23,75|p25,75|p37,75|p39,75|p41,75|p44,75|p46,75|p48,75|p50,75|p52,75|p54,75|p57,75|p59,75|p63,75|p65,75|p67,75|p69,75|p71,75|p2,74|p4,74|p6,74|p8,74|p38,74|p40,74|p42,74|p44,74|p46,74|p48,74|p50,74|p52,74|p54,74|p62,74|p64,74|p66,74|p70,74|p72,74|p3,73|p5,73|p7,73|p9,73|p11,73|p13,73|p15,73|p17,73|p19,73|p21,73|p23,73|p25,73|p39,73|p41,73|p43,73|p45,73|p47,73|p49,73|p51,73|p53,73|p57,73|p59,73|p61,73|p63,73|p65,73|p71,73|p2,72|p4,72|p6,72|p8,72|p10,72|p14,72|p18,72|p20,72|p22,72|p24,72|p40,72|p42,72|p44,72|p46,72|p48,72|p50,72|p52,72|p54,72|p58,72|p62,72|p64,72|p66,72|p70,72|p72,72|p74,72|p76,72|p3,71|p5,71|p7,71|p9,71|p11,71|p13,71|p15,71|p17,71|p19,71|p21,71|p23,71|p25,71|p53,71|p57,71|p59,71|p61,71|p63,71|p65,71|p71,71|p77,71|p56,70|p58,70|p62,70|p64,70|p67,70|p69,70|p72,70|p74,70|p76,70|p78,70|p57,69|p59,69|p61,69|p63,69|p67,69|p69,69|p75,69|p77,69|p79,69|p56,68|p58,68|p62,68|p66,68|p70,68|p74,68|p76,68|p78,68|p80,68|p57,67|p59,67|p64,67|p67,67|p69,67|p71,67|p75,67|p77,67|p79,67|p81,67|p56,66|p63,66|p66,66|p70,66|p73,66|p76,66|p78,66|p80,66|p82,66|p57,65|p62,65|p65,65|p67,65|p69,65|p71,65|p73,65|p77,65|p79,65|p81,65|p83,65|p56,64|p58,64|p61,64|p66,64|p70,64|p74,64|p78,64|p80,64|p82,64|p84,64|p57,63|p59,63|p61,63|p63,63|p65,63|p67,63|p69,63|p71,63|p73,63|p75,63|p79,63|p81,63|p83,63|p85,63|p56,62|p58,62|p60,62|p62,62|p64,62|p66,62|p70,62|p74,62|p76,62|p80,62|p82,62|p84,62|p57,61|p59,61|p61,61|p63,61|p65,61|p67,61|p69,61|p71,61|p73,61|p75,61|p77,61|p78,61|p80,61|p56,60|p58,60|p60,60|p62,60|p64,60|p66,60|p70,60|p74,60|p77,60|p79,60|p57,59|p59,59|p61,59|p63,59|p65,59|p73,59|p75,59|p77,59|p78,59|p79,59|p80,59|p81,59|p82,59|p83,59|p84,59|p85,59|p58,58|p60,58|p62,58|p64,58|p74,58|p77,58|p79,58|p81,58|p83,58|p85,58|p73,57|p75,57|p77,57|p79,57|p81,57|p83,57|p85,57|p74,56|p76,56|p78,56|p80,56|p82,56|p84,56|p75,55|p77,55|p79,55|p81,55|p83,55|p85,55|p26,24|p28,24|p30,24|p26,22|p28,22|p30,22|p27,21|p29,21|p26,20|p28,20|p30,20|p28,17',\n\t\t\t// Pawns could originally double push, as a bug.\n\t\t\t0: 'K51,94|k46,80|Q30,148|Q32,148|Q29,3|q29,148|q24,98|q24,97|q24,92|q24,91|q24,86|q24,85|q24,80|q24,79|q46,78|q45,77|q46,77|q45,76|q46,76|q78,60|N15,84|n63,64|r53,96|r45,81|r46,81|r46,79|r47,79|r45,78|B27,152|B29,152|B27,151|B28,151|B30,151|B32,151|B27,150|B28,150|B29,150|B30,150|B31,150|B32,150|B32,149|B9,96|B11,96|B15,96|B20,96|B47,87|B43,86|B44,82|B50,82|B51,81|B8,79|B10,79|B8,78|B10,78|B14,78|B19,78|B49,77|B41,72|B43,72|B45,72|B47,72|B49,72|B51,72|B53,72|B68,72|B10,71|B14,71|B18,71|B20,71|B22,71|B24,71|B76,55|B78,55|B80,55|B82,55|B84,55|B27,20|B29,20|B29,4|b27,155|b29,155|b31,155|b32,154|b9,99|b11,99|b15,99|b20,97|b33,97|b24,96|b11,92|b13,92|b15,92|b19,92|b47,91|b48,91|b49,91|b50,91|b51,91|b24,90|b47,90|b49,90|b51,90|b48,89|b50,89|b51,89|b47,88|b49,88|b51,88|b37,87|b48,87|b50,87|b51,87|b19,86|b49,86|b51,86|b48,85|b50,85|b24,84|b49,84|b51,84|b9,83|b48,83|b50,83|b51,82|b18,80|b14,79|b24,78|b52,77|b53,77|b47,76|b49,76|b51,76|b52,76|b53,76|b66,76|b70,76|b45,75|b47,75|b49,75|b51,75|b53,75|b10,74|b14,74|b18,74|b20,74|b22,74|b24,74|b58,74|b75,71|b78,58|b80,58|b82,58|b84,58|b27,23|b29,23|P26,155+|P28,155+|P30,155+|P32,155+|P27,154+|P29,154+|P31,154+|P33,154+|P26,153+|P28,153+|P30,153+|P32,153+|P26,152+|P28,152+|P31,152+|P33,152+|P26,151+|P29,151+|P31,151+|P33,151+|P26,150+|P33,150+|P26,149+|P27,149+|P28,149+|P29,149+|P30,149+|P31,149+|P33,149+|P31,148+|P33,148+|P26,147+|P28,147+|P30,147+|P31,147+|P32,147+|P33,147+|P15,146+|P27,146+|P29,146+|P28,145+|P25,111+|P24,110+|P23,109+|P22,108+|P21,107+|P25,107+|P20,106+|P24,106+|P19,105+|P23,105+|P20,104+|P19,103+|P25,103+|P20,102+|P24,102+|P19,101+|P23,101+|P20,100+|P4,99+|P6,99+|P8,99+|P10,99+|P12,99+|P14,99+|P16,99+|P19,99+|P3,98+|P5,98+|P7,98+|P9,98+|P11,98+|P15,98+|P20,98+|P4,97+|P6,97+|P8,97+|P10,97+|P12,97+|P14,97+|P16,97+|P19,97+|P21,97+|P32,97+|P34,97+|P3,96+|P5,96+|P8,96+|P10,96+|P12,96+|P33,96+|P35,96+|P4,95+|P6,95+|P8,95+|P9,95+|P10,95+|P11,95+|P12,95+|P14,95+|P16,95+|P19,95+|P21,95+|P32,95+|P34,95+|P36,95+|P23,94+|P33,94+|P35,94+|P37,94+|P8,93+|P9,93+|P34,93+|P36,93+|P38,93+|P4,92+|P6,92+|P8,92+|P10,92+|P12,92+|P14,92+|P16,92+|P18,92+|P20,92+|P35,92+|P37,92+|P39,92+|P3,91+|P5,91+|P7,91+|P9,91+|P11,91+|P13,91+|P15,91+|P19,91+|P21,91+|P36,91+|P38,91+|P40,91+|P4,90+|P6,90+|P8,90+|P10,90+|P12,90+|P14,90+|P16,90+|P18,90+|P20,90+|P35,90+|P39,90+|P41,90+|P3,89+|P5,89+|P7,89+|P9,89+|P11,89+|P13,89+|P15,89+|P19,89+|P21,89+|P34,89+|P40,89+|P42,89+|P4,88+|P6,88+|P8,88+|P10,88+|P12,88+|P14,88+|P16,88+|P23,88+|P33,88+|P37,88+|P41,88+|P43,88+|P46,88+|P48,88+|P3,87+|P5,87+|P7,87+|P9,87+|P11,87+|P13,87+|P15,87+|P32,87+|P36,87+|P38,87+|P42,87+|P44,87+|P4,86+|P6,86+|P8,86+|P10,86+|P12,86+|P14,86+|P18,86+|P20,86+|P31,86+|P35,86+|P37,86+|P39,86+|P42,86+|P44,86+|P46,86+|P48,86+|P3,85+|P5,85+|P7,85+|P9,85+|P11,85+|P13,85+|P15,85+|P17,85+|P19,85+|P21,85+|P32,85+|P36,85+|P38,85+|P40,85+|P42,85+|P43,85+|P44,85+|P3,84+|P5,84+|P7,84+|P9,84+|P11,84+|P13,84+|P18,84+|P20,84+|P33,84+|P37,84+|P39,84+|P42,84+|P43,84+|P44,84+|P52,84+|P4,83+|P6,83+|P8,83+|P10,83+|P12,83+|P14,83+|P16,83+|P19,83+|P21,83+|P34,83+|P38,83+|P40,83+|P42,83+|P43,83+|P44,83+|P49,83+|P51,83+|P3,82+|P5,82+|P7,82+|P9,82+|P11,82+|P13,82+|P15,82+|P23,82+|P31,82+|P35,82+|P39,82+|P42,82+|P43,82+|P52,82+|P2,81+|P4,81+|P6,81+|P8,81+|P10,81+|P12,81+|P14,81+|P32,81+|P38,81+|P40,81+|P42,81+|P43,81+|P44,81+|P49,81+|P3,80+|P5,80+|P7,80+|P9,80+|P11,80+|P17,80+|P19,80+|P21,80+|P31,80+|P33,80+|P37,80+|P39,80+|P50,80+|P52,80+|P2,79+|P4,79+|P7,79+|P9,79+|P11,79+|P13,79+|P15,79+|P18,79+|P20,79+|P32,79+|P34,79+|P36,79+|P38,79+|P40,79+|P44,79+|P3,78+|P5,78+|P7,78+|P9,78+|P11,78+|P17,78+|P21,78+|P33,78+|P35,78+|P37,78+|P39,78+|P41,78+|P43,78+|P2,77+|P4,77+|P7,77+|P8,77+|P9,77+|P10,77+|P11,77+|P13,77+|P15,77+|P18,77+|P20,77+|P34,77+|P36,77+|P38,77+|P40,77+|P42,77+|P23,76+|P35,76+|P37,76+|P39,76+|P41,76+|P65,76+|P67,76+|P69,76+|P71,76+|P7,75+|P8,75+|P36,75+|P38,75+|P40,75+|P42,75+|P64,75+|P66,75+|P70,75+|P3,74+|P5,74+|P7,74+|P9,74+|P11,74+|P13,74+|P15,74+|P17,74+|P19,74+|P21,74+|P23,74+|P25,74+|P37,74+|P39,74+|P41,74+|P57,74+|P59,74+|P63,74+|P65,74+|P67,74+|P69,74+|P71,74+|P2,73+|P4,73+|P6,73+|P8,73+|P10,73+|P14,73+|P18,73+|P20,73+|P22,73+|P24,73+|P38,73+|P40,73+|P42,73+|P44,73+|P46,73+|P48,73+|P50,73+|P52,73+|P54,73+|P58,73+|P62,73+|P64,73+|P66,73+|P70,73+|P72,73+|P3,72+|P5,72+|P7,72+|P9,72+|P11,72+|P13,72+|P15,72+|P17,72+|P19,72+|P21,72+|P23,72+|P25,72+|P39,72+|P57,72+|P59,72+|P61,72+|P63,72+|P65,72+|P71,72+|P2,71+|P4,71+|P6,71+|P8,71+|P40,71+|P42,71+|P44,71+|P46,71+|P48,71+|P50,71+|P52,71+|P54,71+|P58,71+|P62,71+|P64,71+|P66,71+|P70,71+|P72,71+|P74,71+|P76,71+|P3,70+|P5,70+|P7,70+|P9,70+|P11,70+|P13,70+|P15,70+|P17,70+|P19,70+|P21,70+|P23,70+|P25,70+|P57,70+|P59,70+|P61,70+|P63,70+|P65,70+|P71,70+|P75,70+|P77,70+|P56,69+|P58,69+|P62,69+|P64,69+|P72,69+|P74,69+|P76,69+|P78,69+|P57,68+|P59,68+|P61,68+|P63,68+|P67,68+|P69,68+|P75,68+|P77,68+|P79,68+|P56,67+|P58,67+|P62,67+|P66,67+|P70,67+|P74,67+|P76,67+|P78,67+|P80,67+|P57,66+|P59,66+|P64,66+|P67,66+|P69,66+|P71,66+|P75,66+|P77,66+|P79,66+|P81,66+|P56,65+|P59,65+|P63,65+|P66,65+|P70,65+|P76,65+|P78,65+|P80,65+|P82,65+|P57,64+|P59,64+|P62,64+|P65,64+|P67,64+|P69,64+|P71,64+|P73,64+|P77,64+|P79,64+|P81,64+|P83,64+|P56,63+|P58,63+|P66,63+|P70,63+|P74,63+|P78,63+|P80,63+|P82,63+|P84,63+|P57,62+|P59,62+|P61,62+|P63,62+|P65,62+|P67,62+|P69,62+|P71,62+|P73,62+|P75,62+|P79,62+|P81,62+|P83,62+|P85,62+|P56,61+|P58,61+|P60,61+|P62,61+|P64,61+|P66,61+|P70,61+|P74,61+|P76,61+|P82,61+|P84,61+|P57,60+|P59,60+|P61,60+|P63,60+|P65,60+|P67,60+|P69,60+|P71,60+|P73,60+|P75,60+|P80,60+|P82,60+|P56,59+|P58,59+|P60,59+|P62,59+|P64,59+|P66,59+|P70,59+|P74,59+|P57,58+|P59,58+|P61,58+|P63,58+|P65,58+|P73,58+|P75,58+|P58,57+|P60,57+|P62,57+|P64,57+|P74,57+|P73,56+|P75,56+|P77,56+|P79,56+|P81,56+|P83,56+|P85,56+|P74,55+|P75,54+|P77,54+|P79,54+|P81,54+|P83,54+|P85,54+|P26,23+|P28,23+|P30,23+|P27,22+|P29,22+|P26,21+|P28,21+|P30,21+|P26,19+|P28,19+|P30,19+|P26,18+|P30,18+|P26,17+|P30,17+|P26,16+|P28,16+|P30,16+|P26,15+|P28,15+|P30,15+|P26,14+|P28,14+|P30,14+|P26,13+|P28,13+|P30,13+|P26,12+|P28,12+|P30,12+|P26,11+|P28,11+|P30,11+|P26,10+|P28,10+|P30,10+|P26,9+|P28,9+|P30,9+|P26,8+|P28,8+|P30,8+|P26,7+|P28,7+|P30,7+|P26,6+|P28,6+|P30,6+|P26,5+|P28,5+|P30,5+|P26,4+|P28,4+|P30,4+|P26,3+|P28,3+|P30,3+|P26,2+|P27,2+|P28,2+|P29,2+|P30,2+|p26,156+|p28,156+|p30,156+|p32,156+|p33,155+|p26,154+|p28,154+|p30,154+|p31,153+|p33,153+|p15,147+|p25,112+|p24,111+|p23,110+|p22,109+|p25,109+|p21,108+|p25,108+|p20,107+|p24,107+|p19,106+|p23,106+|p20,105+|p25,105+|p19,104+|p25,104+|p20,103+|p24,103+|p19,102+|p23,102+|p20,101+|p25,101+|p4,100+|p6,100+|p8,100+|p10,100+|p12,100+|p14,100+|p16,100+|p19,100+|p24,100+|p25,100+|p3,99+|p5,99+|p7,99+|p20,99+|p23,99+|p24,99+|p25,99+|p4,98+|p6,98+|p8,98+|p10,98+|p12,98+|p14,98+|p16,98+|p19,98+|p21,98+|p23,98+|p25,98+|p32,98+|p34,98+|p3,97+|p5,97+|p15,97+|p23,97+|p25,97+|p35,97+|p4,96+|p6,96+|p14,96+|p16,96+|p19,96+|p21,96+|p23,96+|p25,96+|p32,96+|p34,96+|p36,96+|p18,95+|p23,95+|p25,95+|p33,95+|p35,95+|p37,95+|p25,94+|p34,94+|p36,94+|p38,94+|p4,93+|p6,93+|p10,93+|p12,93+|p14,93+|p16,93+|p18,93+|p20,93+|p23,93+|p24,93+|p25,93+|p35,93+|p37,93+|p39,93+|p3,92+|p5,92+|p7,92+|p9,92+|p21,92+|p23,92+|p25,92+|p36,92+|p38,92+|p40,92+|p46,92+|p47,92+|p48,92+|p49,92+|p50,92+|p51,92+|p52,92+|p4,91+|p6,91+|p8,91+|p10,91+|p12,91+|p14,91+|p16,91+|p18,91+|p20,91+|p23,91+|p25,91+|p35,91+|p39,91+|p41,91+|p46,91+|p52,91+|p3,90+|p5,90+|p7,90+|p9,90+|p11,90+|p13,90+|p15,90+|p19,90+|p21,90+|p23,90+|p25,90+|p34,90+|p40,90+|p42,90+|p46,90+|p48,90+|p50,90+|p52,90+|p4,89+|p6,89+|p8,89+|p10,89+|p12,89+|p14,89+|p16,89+|p23,89+|p25,89+|p33,89+|p37,89+|p41,89+|p43,89+|p46,89+|p52,89+|p3,88+|p5,88+|p7,88+|p9,88+|p11,88+|p13,88+|p15,88+|p25,88+|p32,88+|p36,88+|p38,88+|p42,88+|p44,88+|p50,88+|p52,88+|p4,87+|p6,87+|p8,87+|p10,87+|p12,87+|p14,87+|p18,87+|p20,87+|p23,87+|p24,87+|p25,87+|p31,87+|p35,87+|p39,87+|p46,87+|p52,87+|p3,86+|p5,86+|p7,86+|p9,86+|p11,86+|p13,86+|p15,86+|p17,86+|p21,86+|p23,86+|p25,86+|p32,86+|p36,86+|p38,86+|p40,86+|p47,86+|p50,86+|p52,86+|p18,85+|p20,85+|p23,85+|p25,85+|p33,85+|p37,85+|p39,85+|p46,85+|p47,85+|p49,85+|p52,85+|p4,84+|p6,84+|p8,84+|p10,84+|p12,84+|p14,84+|p16,84+|p19,84+|p21,84+|p23,84+|p25,84+|p34,84+|p38,84+|p40,84+|p46,84+|p47,84+|p3,83+|p5,83+|p7,83+|p11,83+|p13,83+|p15,83+|p23,83+|p25,83+|p31,83+|p35,83+|p39,83+|p46,83+|p47,83+|p52,83+|p2,82+|p4,82+|p6,82+|p8,82+|p10,82+|p12,82+|p14,82+|p25,82+|p32,82+|p38,82+|p40,82+|p46,82+|p47,82+|p49,82+|p3,81+|p5,81+|p7,81+|p9,81+|p11,81+|p13,81+|p15,81+|p17,81+|p19,81+|p21,81+|p23,81+|p24,81+|p25,81+|p31,81+|p33,81+|p37,81+|p39,81+|p47,81+|p50,81+|p52,81+|p2,80+|p4,80+|p13,80+|p15,80+|p20,80+|p23,80+|p25,80+|p32,80+|p34,80+|p36,80+|p38,80+|p40,80+|p44,80+|p47,80+|p3,79+|p5,79+|p17,79+|p19,79+|p21,79+|p23,79+|p25,79+|p33,79+|p35,79+|p37,79+|p39,79+|p41,79+|p43,79+|p45,79+|p2,78+|p4,78+|p13,78+|p15,78+|p18,78+|p20,78+|p23,78+|p25,78+|p34,78+|p36,78+|p38,78+|p40,78+|p42,78+|p44,78+|p47,78+|p49,78+|p51,78+|p52,78+|p53,78+|p54,78+|p17,77+|p23,77+|p25,77+|p35,77+|p37,77+|p39,77+|p41,77+|p44,77+|p47,77+|p48,77+|p50,77+|p51,77+|p54,77+|p65,77+|p67,77+|p69,77+|p71,77+|p25,76+|p36,76+|p38,76+|p40,76+|p42,76+|p44,76+|p48,76+|p50,76+|p54,76+|p64,76+|p3,75+|p5,75+|p9,75+|p11,75+|p13,75+|p15,75+|p17,75+|p19,75+|p21,75+|p23,75+|p25,75+|p37,75+|p39,75+|p41,75+|p44,75+|p46,75+|p48,75+|p50,75+|p52,75+|p54,75+|p57,75+|p59,75+|p63,75+|p65,75+|p67,75+|p69,75+|p71,75+|p2,74+|p4,74+|p6,74+|p8,74+|p38,74+|p40,74+|p42,74+|p44,74+|p46,74+|p48,74+|p50,74+|p52,74+|p54,74+|p62,74+|p64,74+|p66,74+|p70,74+|p72,74+|p3,73+|p5,73+|p7,73+|p9,73+|p11,73+|p13,73+|p15,73+|p17,73+|p19,73+|p21,73+|p23,73+|p25,73+|p39,73+|p41,73+|p43,73+|p45,73+|p47,73+|p49,73+|p51,73+|p53,73+|p57,73+|p59,73+|p61,73+|p63,73+|p65,73+|p71,73+|p2,72+|p4,72+|p6,72+|p8,72+|p10,72+|p14,72+|p18,72+|p20,72+|p22,72+|p24,72+|p40,72+|p42,72+|p44,72+|p46,72+|p48,72+|p50,72+|p52,72+|p54,72+|p58,72+|p62,72+|p64,72+|p66,72+|p70,72+|p72,72+|p74,72+|p76,72+|p3,71+|p5,71+|p7,71+|p9,71+|p11,71+|p13,71+|p15,71+|p17,71+|p19,71+|p21,71+|p23,71+|p25,71+|p53,71+|p57,71+|p59,71+|p61,71+|p63,71+|p65,71+|p71,71+|p77,71+|p56,70+|p58,70+|p62,70+|p64,70+|p67,70+|p69,70+|p72,70+|p74,70+|p76,70+|p78,70+|p57,69+|p59,69+|p61,69+|p63,69+|p67,69+|p69,69+|p75,69+|p77,69+|p79,69+|p56,68+|p58,68+|p62,68+|p66,68+|p70,68+|p74,68+|p76,68+|p78,68+|p80,68+|p57,67+|p59,67+|p64,67+|p67,67+|p69,67+|p71,67+|p75,67+|p77,67+|p79,67+|p81,67+|p56,66+|p63,66+|p66,66+|p70,66+|p73,66+|p76,66+|p78,66+|p80,66+|p82,66+|p57,65+|p62,65+|p65,65+|p67,65+|p69,65+|p71,65+|p73,65+|p77,65+|p79,65+|p81,65+|p83,65+|p56,64+|p58,64+|p61,64+|p66,64+|p70,64+|p74,64+|p78,64+|p80,64+|p82,64+|p84,64+|p57,63+|p59,63+|p61,63+|p63,63+|p65,63+|p67,63+|p69,63+|p71,63+|p73,63+|p75,63+|p79,63+|p81,63+|p83,63+|p85,63+|p56,62+|p58,62+|p60,62+|p62,62+|p64,62+|p66,62+|p70,62+|p74,62+|p76,62+|p80,62+|p82,62+|p84,62+|p57,61+|p59,61+|p61,61+|p63,61+|p65,61+|p67,61+|p69,61+|p71,61+|p73,61+|p75,61+|p77,61+|p78,61+|p80,61+|p56,60+|p58,60+|p60,60+|p62,60+|p64,60+|p66,60+|p70,60+|p74,60+|p77,60+|p79,60+|p57,59+|p59,59+|p61,59+|p63,59+|p65,59+|p73,59+|p75,59+|p77,59+|p78,59+|p79,59+|p80,59+|p81,59+|p82,59+|p83,59+|p84,59+|p85,59+|p58,58+|p60,58+|p62,58+|p64,58+|p74,58+|p77,58+|p79,58+|p81,58+|p83,58+|p85,58+|p73,57+|p75,57+|p77,57+|p79,57+|p81,57+|p83,57+|p85,57+|p74,56+|p76,56+|p78,56+|p80,56+|p82,56+|p84,56+|p75,55+|p77,55+|p79,55+|p81,55+|p83,55+|p85,55+|p26,24+|p28,24+|p30,24+|p26,22+|p28,22+|p30,22+|p27,21+|p29,21+|p26,20+|p28,20+|p30,20+|p28,17+',\n\t\t},\n\t\tgameruleModifications: gameruleModificationsOfOmegaShowcasings,\n\t\tannotePresets: {\n\t\t\tsquares:\n\t\t\t\t'-42,76|16,86|15,84|27,88|35,80|37,82|33,86|37,90|41,86|41,80|44,80|27,2|53,71',\n\t\t\trays: '23,94>-1,0|23,76>-1,0|17,88>0,1|16,82>0,-1|68,72>0,1|68,71>0,-1|60,64>0,1|72,68>0,-1',\n\t\t},\n\t},\n\tOmega_Cubed: {\n\t\tname: 'Showcase: Omega^3',\n\t\tgenerator: {\n\t\t\talgorithm: omega3generator.genPositionOfOmegaCubed,\n\t\t\t// Additional properties that are normally stored in the position string in the form of '+', but isn't present since it's a generated position.\n\t\t\trules: { pawnDoublePush: false },\n\t\t},\n\t\t// WE HAVE TO EXPLICITLY STATE the royalcapture win condition so that it will go into the ICN!!! It doesn't matter the game will automatically swap from checkmate.\n\t\tgameruleModifications: {\n\t\t\twinConditions: royalCaptureWinConditions,\n\t\t\t...gameruleModificationsOfOmegaShowcasings,\n\t\t},\n\t},\n\tOmega_Fourth: {\n\t\tname: 'Showcase: Omega^4',\n\t\tgenerator: {\n\t\t\talgorithm: omega4generator.genPositionOfOmegaFourth,\n\t\t\t// Additional properties that are normally stored in the position string in the form of '+', but isn't present since it's a generated position.\n\t\t\trules: { pawnDoublePush: false },\n\t\t},\n\t\t// WE HAVE TO EXPLICITLY STATE the royalcapture win condition so that it will go into the ICN!!! It doesn't matter the game will automatically swap from checkmate.\n\t\tgameruleModifications: {\n\t\t\twinConditions: royalCaptureWinConditions,\n\t\t\t...gameruleModificationsOfOmegaShowcasings,\n\t\t},\n\t},\n\t// DISABLED BECAUSE IT has a smothered mate in 2 if one side allows it.\n\t// Trappist_1: { // Also has the huygen featured in it!\n\t// \tpositionString: 'p-6,16+|ha-4,16|p-2,16+|p11,16+|ha13,16|p15,16+|p-5,15+|p-3,15+|p12,15+|p14,15+|p-4,14+|p13,14+|p-3,9+|hu-2,9|n3,9|b4,9|b5,9|n6,9|hu11,9|p12,9+|p-2,8+|r-1,8+|ch0,8|gu1,8|n2,8|b3,8|q4,8|k5,8+|b6,8|n7,8|gu8,8|ch9,8|r10,8+|p11,8+|p-1,7+|p0,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p9,7+|p10,7+|P-1,2+|P0,2+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P9,2+|P10,2+|P-2,1+|R-1,1+|CH0,1|GU1,1|N2,1|B3,1|Q4,1|K5,1+|B6,1|N7,1|GU8,1|CH9,1|R10,1+|P11,1+|P-3,0+|HU-2,0|N3,0|B4,0|B5,0|N6,0|HU11,0|P12,0+|P-4,-5+|P13,-5+|P-5,-6+|P-3,-6+|P12,-6+|P14,-6+|P-6,-7+|HA-4,-7|P-2,-7+|P11,-7+|HA13,-7|P15,-7+',\n\t// \tgameruleModifications: { promotionsAllowed: repeatPromotionsAllowedForEachColor([...coaIPPromotions, r.HUYGEN]) }\n\t// },\n\t// Chess on an Infinite Plane - Huygens Options\n\tCoaIP_HO: {\n\t\tname: 'Chess on an Infinite Plane - Huygens Option',\n\t\tpositionString:\n\t\t\t'p-4,14+|ha-2,14|p0,14+|p9,14+|ha11,14|p13,14+|p-3,13+|p-1,13+|p10,13+|p12,13+|p-2,12+|p11,12+|gu-1,9|hu0,9|ch1,9|ch8,9|hu9,9|gu10,9|p-1,8+|p0,8+|r1,8+|n2,8|b3,8|q4,8|k5,8+|b6,8|n7,8|r8,8+|p9,8+|p10,8+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P-1,1+|P0,1+|R1,1+|N2,1|B3,1|Q4,1|K5,1+|B6,1|N7,1|R8,1+|P9,1+|P10,1+|GU-1,0|HU0,0|CH1,0|CH8,0|HU9,0|GU10,0|P-2,-3+|P11,-3+|P-3,-4+|P-1,-4+|P10,-4+|P12,-4+|P-4,-5+|HA-2,-5|P0,-5+|P9,-5+|HA11,-5|P13,-5+',\n\t\tgameruleModifications: {\n\t\t\tpromotionsAllowed: repeatPromotionsAllowedForEachColor([...coaIPPromotions, r.HUYGEN]),\n\t\t},\n\t},\n\tCoaIP_RO: {\n\t\tname: 'Chess on an Infinite Plane - Roses Option',\n\t\tpositionString:\n\t\t\t'P-2,1+|P-1,2+|P0,2+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P9,2+|P10,2+|P11,1+|P-4,-6+|P-3,-5+|P-2,-4+|P-1,-5+|P0,-6+|P9,-6+|P10,-5+|P11,-4+|P12,-5+|P13,-6+|p-2,8+|p-1,7+|p0,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p9,7+|p10,7+|p11,8+|p-4,15+|p-3,14+|p-2,13+|p-1,14+|p0,15+|p9,15+|p10,14+|p11,13+|p12,14+|p13,15+|R-1,1|R10,1|r-1,8|r10,8|CH0,1|CH9,1|ch0,8|ch9,8|GU1,1+|GU8,1+|gu1,8+|gu8,8+|N2,1|N7,1|n2,8|n7,8|B3,1|B6,1|b3,8|b6,8|Q4,1|q4,8|K5,1+|k5,8+|RO-2,-6|RO11,-6|ro-2,15|ro11,15',\n\t\tgameruleModifications: {\n\t\t\tpromotionsAllowed: repeatPromotionsAllowedForEachColor([\n\t\t\t\t...defaultPromotions,\n\t\t\t\tr.GUARD,\n\t\t\t\tr.CHANCELLOR,\n\t\t\t\tr.ROSE,\n\t\t\t]),\n\t\t},\n\t},\n\tCoaIP_NO: {\n\t\tname: 'Chess on an Infinite Plane - Knightriders Option',\n\t\tpositionString: {\n\t\t\t// 6:43 PM Dec 24, 2025, MST - Knightriders can no longer give a discovered check on move one.\n\t\t\t1766627026138:\n\t\t\t\t'P-2,1+|P-1,2+|P0,2+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P9,2+|P10,2+|P11,1+|P-4,-6+|P-3,-5+|P-2,-4+|P-1,-5+|P0,-6+|P9,-6+|P10,-5+|P11,-4+|P12,-5+|P13,-6+|p-2,8+|p-1,7+|p0,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p9,7+|p10,7+|p11,8+|p-4,15+|p-3,14+|p-2,13+|p-1,14+|p0,15+|p9,15+|p10,14+|p11,13+|p12,14+|p13,15+|R-1,1|R10,1|r-1,8|r10,8|CH0,1|CH9,1|ch0,8|ch9,8|GU1,1+|GU8,1+|gu1,8+|gu8,8+|N2,1|N7,1|n2,8|n7,8|B3,1|B6,1|b3,8|b6,8|Q4,1|q4,8|K5,1+|k5,8+|nr-2,16|nr11,16|NR-2,-7|NR11,-7',\n\t\t\t0: 'P-2,1+|P-1,2+|P0,2+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P9,2+|P10,2+|P11,1+|P-4,-6+|P-3,-5+|P-2,-4+|P-1,-5+|P0,-6+|P9,-6+|P10,-5+|P11,-4+|P12,-5+|P13,-6+|p-2,8+|p-1,7+|p0,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p9,7+|p10,7+|p11,8+|p-4,15+|p-3,14+|p-2,13+|p-1,14+|p0,15+|p9,15+|p10,14+|p11,13+|p12,14+|p13,15+|R-1,1|R10,1|r-1,8|r10,8|CH0,1|CH9,1|ch0,8|ch9,8|GU1,1+|GU8,1+|gu1,8+|gu8,8+|N2,1|N7,1|n2,8|n7,8|B3,1|B6,1|b3,8|b6,8|Q4,1|q4,8|K5,1+|k5,8+|nr-2,15|nr11,15|NR-2,-6|NR11,-6',\n\t\t},\n\t\tgameruleModifications: {\n\t\t\tpromotionsAllowed: repeatPromotionsAllowedForEachColor([\n\t\t\t\t...defaultPromotions,\n\t\t\t\tr.GUARD,\n\t\t\t\tr.CHANCELLOR,\n\t\t\t\tr.KNIGHTRIDER,\n\t\t\t]),\n\t\t},\n\t},\n\t'4x4x4x4_Chess': {\n\t\tname: '4×4×4×4 Chess',\n\t\tgenerator: {\n\t\t\talgorithm: (): Map<CoordsKey, number> => {\n\t\t\t\treturn fourdimensionalgenerator.gen4DPosition(4n, 4n, 5n, {\n\t\t\t\t\t'0,0': 'P1,2|P2,2|P3,2|P4,2|R1,1|N2,1|N3,1|R4,1',\n\t\t\t\t\t'1,0': 'P1,2|P2,2|P3,2|P4,2|P1,1|P2,1|P3,1|P4,1',\n\t\t\t\t\t'2,0': 'P1,2|P2,2|P3,2|P4,2|B1,1|K2,1|Q3,1|B4,1',\n\t\t\t\t\t'3,0': 'P1,2|P2,2|P3,2|P4,2|R1,1|N2,1|N3,1|R4,1',\n\t\t\t\t\t'0,3': 'p1,3|p2,3|p3,3|p4,3|r1,4|n2,4|n3,4|r4,4',\n\t\t\t\t\t'1,3': 'p1,3|p2,3|p3,3|p4,3|b1,4|q2,4|k3,4|b4,4',\n\t\t\t\t\t'2,3': 'p1,3|p2,3|p3,3|p4,3|p1,4|p2,4|p3,4|p4,4',\n\t\t\t\t\t'3,3': 'p1,3|p2,3|p3,3|p4,3|r1,4|n2,4|n3,4|r4,4',\n\t\t\t\t});\n\t\t\t},\n\t\t\trules: { pawnDoublePush: true },\n\t\t},\n\t\tmovesetGenerator: (): Movesets =>\n\t\t\tfourdimensionalgenerator.gen4DMoveset(4n, 4n, 5n, false, true),\n\t\tgameruleModifications: {\n\t\t\tpromotionsAllowed: defaultPromotionsAllowed,\n\t\t\tpromotionRanks: { [p.WHITE]: [19n], [p.BLACK]: [1n] },\n\t\t},\n\t\tspecialMoves: { pawns: fourdimensionalmoves.doFourDimensionalPawnMove },\n\t\tspecialVicinity: {\n\t\t\t[r.PAWN]: fourdimensionalgenerator.getPawnVicinity(5n, true),\n\t\t\t[r.KNIGHT]: fourdimensionalgenerator.getKnightVicinity(5n),\n\t\t\t[r.KING]: fourdimensionalgenerator.getKingVicinity(5n, false),\n\t\t},\n\t\tworldBorderDist: 0n,\n\t},\n\t'5D_Chess': {\n\t\tname: '5D Chess',\n\t\tgenerator: {\n\t\t\talgorithm: (): Map<CoordsKey, number> => {\n\t\t\t\treturn fourdimensionalgenerator.gen4DPosition(\n\t\t\t\t\t8n,\n\t\t\t\t\t8n,\n\t\t\t\t\t9n,\n\t\t\t\t\tpositionStringOfClassical,\n\t\t\t\t);\n\t\t\t},\n\t\t\trules: { pawnDoublePush: true, castleWith: r.ROOK },\n\t\t},\n\t\tmovesetGenerator: (): Movesets =>\n\t\t\tfourdimensionalgenerator.gen4DMoveset(8n, 8n, 9n, true, false),\n\t\t// WE HAVE TO EXPLICITLY STATE the royalcapture win condition so that it will go into the ICN!!! It doesn't matter the game will automatically swap from checkmate.\n\t\tgameruleModifications: {\n\t\t\twinConditions: royalCaptureWinConditions,\n\t\t\tpromotionsAllowed: defaultPromotionsAllowed,\n\t\t\tpromotionRanks: {\n\t\t\t\t[p.WHITE]: [8n, 17n, 26n, 35n, 44n, 53n, 62n, 71n],\n\t\t\t\t[p.BLACK]: [1n, 10n, 19n, 28n, 37n, 46n, 55n, 64n],\n\t\t\t},\n\t\t},\n\t\tspecialMoves: { pawns: fourdimensionalmoves.doFourDimensionalPawnMove },\n\t\tspecialVicinity: {\n\t\t\t[r.PAWN]: fourdimensionalgenerator.getPawnVicinity(9n, false),\n\t\t\t[r.KNIGHT]: fourdimensionalgenerator.getKnightVicinity(9n),\n\t\t\t[r.KING]: fourdimensionalgenerator.getKingVicinity(9n, true),\n\t\t},\n\t\tworldBorderDist: 0n,\n\t},\n\t// DELETED (but still in here to support pasting old game notation)\n\tKnighted_Chess: {\n\t\tname: 'Knighted Chess',\n\t\tpositionString: {\n\t\t\t// UTC Aug 1, 2024, 12:00AM\n\t\t\t1722470400000:\n\t\t\t\t'P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|p1,7+|p2,7+|p3,7+|P0,1+|P1,0+|P2,0+|P3,0+|P6,0+|P7,0+|P8,0+|P9,1+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p0,8+|p1,9+|p2,9+|p3,9+|p6,9+|p7,9+|p8,9+|p9,8+|CH1,1+|CH8,1+|ch1,8+|ch8,8+|NR2,1|NR7,1|nr2,8|nr7,8|AR3,1|AR6,1|ar3,8|ar6,8|AM4,1|am4,8|RC5,1+|rc5,8+',\n\t\t\t0: 'P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|p1,7+|p2,7+|p3,7+|P0,1+|P1,0+|P2,0+|P3,0+|P6,0+|P7,0+|P8,0+|P9,1+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p0,8+|p1,9+|p2,9+|p3,9+|p6,9+|p7,9+|p8,9+|p9,8+|CH1,1+|CH8,1+|ch1,8+|ch8,8+|N2,1|N7,1|n2,8|n7,8|AR3,1|AR6,1|ar3,8|ar6,8|AM4,1|am4,8|RC5,1+|rc5,8+',\n\t\t},\n\t\tgameruleModifications: {\n\t\t\tpromotionsAllowed: repeatPromotionsAllowedForEachColor([\n\t\t\t\tr.CHANCELLOR,\n\t\t\t\tr.KNIGHTRIDER,\n\t\t\t\tr.ARCHBISHOP,\n\t\t\t\tr.AMAZON,\n\t\t\t]),\n\t\t},\n\t},\n});\n\n// Functions ---------------------------------------------------------------------------------\n\n/**\n * Type helper: validates each variant entry against the Variant interface while preserving\n * the literal key names, so that `keyof typeof variantDictionary` remains a union of\n * specific string literals and every lookup returns `Variant` instead of a narrow union.\n */\nfunction buildVariantDictionary<K extends string>(dict: { [key in K]: Variant }): {\n\t[key in K]: Variant;\n} {\n\treturn dict;\n}\n\n/**\n * Takes a single list of possible promotions: `[r.ROOK,r.QUEEN...]`,\n * repeats it for every color to produce the full `promotionsAllowed` gamerule:\n * `{ [p.WHITE]: [r.ROOK,r.QUEEN...], [p.BLACK]: [r.ROOK,r.QUEEN...] }`\n */\nfunction repeatPromotionsAllowedForEachColor(\n\tpromotions: RawType[],\n\tplayers: Player[] = [p.WHITE, p.BLACK],\n): PlayerGroup<RawType[]> {\n\tconst promotionRule: PlayerGroup<RawType[]> = {};\n\tfor (const player of players) {\n\t\tpromotionRule[player] = promotions;\n\t}\n\treturn promotionRule;\n}\n\n// Exports ----------------------------------------------------------------------------------\n\nexport default variantDictionary;\n"
  },
  {
    "path": "src/shared/components/header/pieceThemes.ts",
    "content": "// src/shared/components/header/pieceThemes.ts\n\n/**\n * This script stores the SVG locations and default tint colors for the pieces.\n */\n\nimport type { Color } from '../../util/math/math.js';\nimport type { RawType, Player } from '../../chess/util/typeutil.js';\n\nimport { rawTypes as r, players as p } from '../../chess/util/typeutil.js';\n\ntype PieceColorGroup = {\n\t[_team in Player]: Color;\n};\n\n/** The default tints for a piece, if not provided. */\nconst defaultBaseColors: PieceColorGroup = {\n\t[p.NEUTRAL]: [0.5, 0.5, 0.5, 1],\n\t[p.WHITE]: [1, 1, 1, 1],\n\t[p.BLACK]: [1, 1, 1, 1],\n\t// If these are solid color, they're quite saturated\n\t[p.RED]: [1, 0.17, 0.17, 1],\n\t[p.BLUE]: [0.23, 0.23, 1, 1],\n\t[p.YELLOW]: [1, 1, 0.1, 1],\n\t[p.GREEN]: [0.1, 1, 0.1, 1],\n};\n\n/** Config for the SVGs of the pieces */\nconst SVGConfig: {\n\t[_type in RawType]: {\n\t\t/** null if the raw type doesn't have an svg (VOID) */\n\t\tlocation: string | null;\n\t\tcolors?: PieceColorGroup;\n\t};\n} = {\n\t[r.VOID]: {\n\t\tlocation: null, // VOID has no svg\n\t\tcolors: {\n\t\t\t[p.NEUTRAL]: [0, 0, 0, 1],\n\t\t\t[p.WHITE]: [1, 1, 1, 1],\n\t\t\t[p.BLACK]: [0.3, 0.3, 0.3, 1],\n\t\t\t[p.RED]: [1, 0, 0, 1],\n\t\t\t[p.BLUE]: [0, 0, 1, 1],\n\t\t\t[p.YELLOW]: [1, 1, 0, 1],\n\t\t\t[p.GREEN]: [0, 1, 0, 1],\n\t\t},\n\t},\n\t[r.OBSTACLE]: {\n\t\tlocation: 'fairy/obstacle',\n\t\tcolors: {\n\t\t\t[p.NEUTRAL]: [0.08, 0.08, 0.08, 1],\n\t\t\t[p.WHITE]: [1, 1, 1, 1],\n\t\t\t[p.BLACK]: [0, 0, 0, 1],\n\t\t\t[p.RED]: [1, 0, 0, 1],\n\t\t\t[p.BLUE]: [0, 0, 1, 1],\n\t\t\t[p.YELLOW]: [1, 1, 0, 1],\n\t\t\t[p.GREEN]: [0, 1, 0, 1],\n\t\t},\n\t},\n\t[r.KING]: { location: 'classical' },\n\t[r.GIRAFFE]: { location: 'fairy/giraffe' },\n\t[r.CAMEL]: { location: 'fairy/camel' },\n\t[r.ZEBRA]: { location: 'fairy/zebra' },\n\t[r.KNIGHTRIDER]: { location: 'fairy/knightrider' },\n\t[r.AMAZON]: { location: 'fairy/amazon' },\n\t[r.QUEEN]: { location: 'classical' },\n\t[r.ROYALQUEEN]: { location: 'fairy/royalQueen' },\n\t[r.HAWK]: { location: 'fairy/hawk' },\n\t[r.CHANCELLOR]: { location: 'fairy/chancellor' },\n\t[r.ARCHBISHOP]: { location: 'fairy/archbishop' },\n\t[r.CENTAUR]: { location: 'fairy/centaur' },\n\t[r.ROYALCENTAUR]: { location: 'fairy/royalCentaur' },\n\t[r.ROSE]: { location: 'fairy/rose' },\n\t[r.KNIGHT]: { location: 'classical' },\n\t[r.GUARD]: { location: 'fairy/guard' },\n\t[r.HUYGEN]: { location: 'fairy/huygen' },\n\t[r.ROOK]: { location: 'classical' },\n\t[r.BISHOP]: { location: 'classical' },\n\t[r.PAWN]: { location: 'classical' },\n};\n\nfunction getLocationsForTypes(types: Iterable<RawType>): Set<string> {\n\tconst locations: Set<string> = new Set();\n\tfor (const raw of types) {\n\t\tconst location = getLocationForType(raw);\n\t\tif (location) locations.add(location);\n\t}\n\treturn locations;\n}\n\nfunction getLocationForType(type: RawType): string | null {\n\treturn SVGConfig[type].location;\n}\n\nfunction getBaseColorForType(type: RawType, team: Player): Color {\n\treturn (SVGConfig[type].colors ?? defaultBaseColors)[team];\n}\n\nexport type { PieceColorGroup };\n\nexport default {\n\tgetLocationsForTypes,\n\tgetLocationForType,\n\tgetBaseColorForType,\n};\n"
  },
  {
    "path": "src/shared/components/header/themes.ts",
    "content": "// src/shared/components/header/themes.ts\n\n// This module stores our themes. Straight forward :P\n\nimport type { Color } from '../../util/math/math.js';\nimport type { PieceColorGroup } from './pieceThemes.js';\n\nimport jsutil from '../../util/jsutil.js';\n\n/*\n * Strings for computed property names.\n *\n * By using computed property names, we greatly compact this script,\n * as our bundler changes the symbols to a single letter.\n */\nconst lightTiles = 'lightTiles';\nconst darkTiles = 'darkTiles';\nconst legalMovesHighlightColor_Friendly = 'legalMovesHighlightColor_Friendly';\nconst legalMovesHighlightColor_Opponent = 'legalMovesHighlightColor_Opponent';\nconst legalMovesHighlightColor_Premove = 'legalMovesHighlightColor_Premove';\nconst lastMoveHighlightColor = 'lastMoveHighlightColor';\nconst checkHighlightColor = 'checkHighlightColor';\nconst boxOutlineColor = 'boxOutlineColor';\nconst annoteSquareColor = 'annoteSquareColor';\nconst annoteArrowColor = 'annoteArrowColor';\nconst pieceTheme = 'pieceTheme';\n\ninterface ThemeProperties {\n\t[lightTiles]: Color;\n\t[darkTiles]: Color;\n\t[legalMovesHighlightColor_Friendly]: Color;\n\t[legalMovesHighlightColor_Opponent]: Color;\n\t[legalMovesHighlightColor_Premove]: Color;\n\t[lastMoveHighlightColor]?: Color;\n\t[checkHighlightColor]?: Color;\n\t[boxOutlineColor]?: Color;\n\t[annoteSquareColor]?: Color;\n\t[annoteArrowColor]?: Color;\n\t[pieceTheme]?: Partial<PieceColorGroup>;\n}\n\n/**\n * Fallback properties for a themes properties\n * to use if it doesn't have them present\n */\nconst defaults: Partial<ThemeProperties> = {\n\t[lastMoveHighlightColor]: [0.72, 1, 0, 0.28],\n\t[checkHighlightColor]: /* checkHighlightColor */ [1, 0, 0, 0.7],\n\t[boxOutlineColor]: [1, 1, 1, 0.45],\n\t[annoteSquareColor]: [1, 0, 0, 0.35], // .43 with no .08 offset to squares.   This matches the Ray color exactly, though\n\t[annoteArrowColor]: [1, 0.65, 0.15, 0.8],\n\t[pieceTheme]: {},\n};\n\nconst defaultTheme = 'wood_light';\n\nconst themeDictionary: { [themeName: string]: ThemeProperties } = {\n\t/*\n\t * By using computed property names, we greatly compact this script,\n\t * as our bundler changes the symbols to a single letter.\n\t */\n\n\twood_light: {\n\t\t// 5D Chess\n\t\t[lightTiles]: [1, 0.85, 0.66, 1],\n\t\t[darkTiles]: [0.87, 0.68, 0.46, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.5, 0.14, 0.38],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.37],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.32],\n\t\t[lastMoveHighlightColor]: [0.9, 1, 0, 0.3],\n\t\t// [annoteSquareColor]: [1, 0, 0, 0.35], // .43 with no .08 offset to squares\n\t\t// [annoteArrowColor]: [1, 0.65, 0.15, 0.8],\n\t},\n\tsandstone: {\n\t\t// Sometimes thanksgiving uses this\n\t\t[lightTiles]: [0.94, 0.88, 0.78, 1],\n\t\t[darkTiles]: [0.74, 0.63, 0.53, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [1, 0.2, 0, 0.35], // 0.5 for BIG positions   0.35 for SMALL\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.7, 0, 0.35],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.28],\n\t\t[lastMoveHighlightColor]: [0.3, 1, 0, 0.35], // 0.3 for small, 0.35 for BIG positions\n\t},\n\twood: {\n\t\t[lightTiles]: [0.96, 0.87, 0.75, 1],\n\t\t[darkTiles]: [0.71, 0.54, 0.38, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.42],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.43],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.32],\n\t},\n\tsandstone_dark: {\n\t\t[lightTiles]: [0.86, 0.76, 0.5, 1],\n\t\t[darkTiles]: [0.69, 0.55, 0.35, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.32],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.29],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.28],\n\t},\n\tmaple: {\n\t\t[lightTiles]: [0.96, 0.81, 0.65, 1],\n\t\t[darkTiles]: [0.83, 0.52, 0.32, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.32],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.52, 0, 0.57],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.28],\n\t},\n\tred_wood: {\n\t\t[lightTiles]: [0.96, 0.82, 0.7, 1],\n\t\t[darkTiles]: [0.76, 0.35, 0.24, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.48],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.52, 0, 0.61],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.36],\n\t},\n\tcyan_ocean: {\n\t\t[lightTiles]: [0.06, 1, 1, 1],\n\t\t[darkTiles]: [0.18, 0.76, 0.78, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.46, 0.1, 0.42],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.18, 0.24, 0.46],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.3],\n\t\t[annoteSquareColor]: [0, 0, 1, 0.29],\n\t\t[annoteArrowColor]: [0.66, 0, 1, 0.62],\n\t},\n\tocean: {\n\t\t[lightTiles]: [0.42, 0.75, 0.96, 1],\n\t\t[darkTiles]: [0.25, 0.46, 0.73, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.86, 0.14, 0.5],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0, 0.22, 0.35],\n\t\t[legalMovesHighlightColor_Premove]: [0.12, 0, 0.24, 0.48],\n\t},\n\tblue_hard: {\n\t\t[lightTiles]: [0.84, 0.91, 0.94, 1],\n\t\t[darkTiles]: [0.26, 0.55, 0.78, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.6, 0.1, 0.46],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0, 0.22, 0.37],\n\t\t[legalMovesHighlightColor_Premove]: [0.12, 0, 0.24, 0.42],\n\t},\n\tblue: {\n\t\t[lightTiles]: [0.87, 0.89, 0.91, 1],\n\t\t[darkTiles]: [0.55, 0.64, 0.68, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.6, 0.1, 0.34],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.46, 0, 0.35],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.34],\n\t\t[lastMoveHighlightColor]: [0, 1, 1, 0.3],\n\t},\n\tblue_soft: {\n\t\t[lightTiles]: [0.59, 0.7, 0.78, 1],\n\t\t[darkTiles]: [0.45, 0.55, 0.62, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.6, 0.1, 0.36],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.46, 0, 0.37],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.36],\n\t},\n\tgreen_plastic: {\n\t\t[lightTiles]: [0.95, 0.98, 0.73, 1],\n\t\t[darkTiles]: [0.35, 0.58, 0.36, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.26, 0.64, 0.56],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.43],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.4],\n\t},\n\tgreen: {\n\t\t[lightTiles]: [0.92, 0.93, 0.82, 1],\n\t\t[darkTiles]: [0.45, 0.58, 0.32, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [1, 1, 0, 0.48],\n\t\t[legalMovesHighlightColor_Opponent]: [0.28, 0, 1, 0.31],\n\t\t[legalMovesHighlightColor_Premove]: [1, 0.12, 0.12, 0.38],\n\t\t[lastMoveHighlightColor]: [1, 1, 0, 0.4],\n\t},\n\tlime: {\n\t\t[lightTiles]: [0.8, 0.94, 0.39, 1],\n\t\t[darkTiles]: [0.39, 0.71, 0.06, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.26, 0.48, 0.52],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0, 0, 0.35],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.3, 0.34],\n\t\t[lastMoveHighlightColor]: [0, 0.26, 1, 0.24],\n\t},\n\tavocado: {\n\t\t[lightTiles]: [0.84, 0.98, 0.5, 1],\n\t\t[darkTiles]: [0.62, 0.77, 0.35, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.26, 0.48, 0.4],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0, 0, 0.31],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.3, 0.3],\n\t\t[lastMoveHighlightColor]: [0, 0.28, 1, 0.24],\n\t},\n\twhite: {\n\t\t[lightTiles]: [1, 1, 1, 1],\n\t\t[darkTiles]: [0.78, 0.78, 0.78, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0, 1, 0.28],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.72, 0, 0.37],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.26, 0.36],\n\t\t[lastMoveHighlightColor]: [0.28, 1, 0, 0.28],\n\t\t[boxOutlineColor]: [0, 0, 0, 0.25],\n\t},\n\tpoison: {\n\t\t[lightTiles]: [0.93, 0.93, 0.93, 1],\n\t\t[darkTiles]: [0.76, 0.76, 0.56, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.32],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.29],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.28],\n\t},\n\tgrey: {\n\t\t[lightTiles]: [0.72, 0.72, 0.72, 1],\n\t\t[darkTiles]: [0.55, 0.55, 0.55, 1], // tad darker than lichess\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.32],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.27],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.26],\n\t},\n\tolive: {\n\t\t[lightTiles]: [0.71, 0.68, 0.62, 1],\n\t\t[darkTiles]: [0.55, 0.51, 0.45, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.34],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.29],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.28],\n\t},\n\tdark_grey: {\n\t\t[lightTiles]: [0.45, 0.45, 0.45, 1],\n\t\t[darkTiles]: [0.3, 0.3, 0.3, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.58, 0.1, 0.34],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.31],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.4, 0.26],\n\t},\n\tseabed: {\n\t\t[lightTiles]: [0.56, 0.66, 0.57, 1],\n\t\t[darkTiles]: [0.42, 0.51, 0.42, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.2, 0.78, 0.32],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.29],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.28, 0.28],\n\t},\n\tmarble: {\n\t\t[lightTiles]: [0.78, 0.78, 0.7, 1],\n\t\t[darkTiles]: [0.44, 0.42, 0.4, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.44],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.37],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.34],\n\t},\n\tpurple: {\n\t\t[lightTiles]: [0.93, 0.89, 0.96, 1],\n\t\t[darkTiles]: [0.59, 0.49, 0.7, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.44],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.39],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.3, 0.42],\n\t},\n\tpink: {\n\t\t[lightTiles]: [0.98, 0.93, 0.93, 1],\n\t\t[darkTiles]: [0.95, 0.76, 0.76, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.32],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.29],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.28],\n\t},\n\tbeehive: {\n\t\t[lightTiles]: [1, 0.86, 0.35, 1],\n\t\t[darkTiles]: [0.88, 0.52, 0.05, 1],\n\t\t[legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.44],\n\t\t[legalMovesHighlightColor_Opponent]: [1, 0.14, 0, 0.49],\n\t\t[legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.32],\n\t\t[lastMoveHighlightColor]: [0, 1, 0, 0.28],\n\t},\n\n\t// purple_hard: {\n\t// \t[a]: [0.95, 0.95, 0.95, 1],\n\t// \t[b]: [0.49, 0.42, 0.68, 1],\n\t// },\n\n\t// Holiday themes\n\n\t// halloween: {\n\t// \t[lightTiles]: [1, 0.65, 0.4, 1],\n\t// \t[darkTiles]: [1, 0.4, 0, 1],\n\t// \t[legalMovesHighlightColor_Friendly]: [0.6, 0, 1, 0.55],\n\t// \t[legalMovesHighlightColor_Opponent]: [0, 0.5, 0, 0.35],\n\t// \t[legalMovesHighlightColor_Premove]: [1, 0.15, 0, 0.65],\n\t// \t[lastMoveHighlightColor]: [0.5, 0.2, 0, 0.75],\n\t// \t[checkHighlightColor]: /* checkHighlightColor */ [1, 0, 0.5, 0.76],\n\t// \t[pieceTheme]: {\n\t// \t\t[players.WHITE]: [0.6, 0.5, 0.45, 1],\n\t// \t\t[players.BLACK]: [0.8, 0, 1, 1],\n\t// \t},\n\t// },\n\t// christmas: {\n\t// \t[lightTiles]: [0.60, 0.93, 1, 1],\n\t// \t[darkTiles]: [0 / 255, 199 / 255, 238 / 255, 1],\n\t// \t[legalMovesHighlightColor_Friendly]: [0, 0, 1, 0.35],\n\t// \t[legalMovesHighlightColor_Opponent]: [1, 0.7, 0, 0.35],\n\t// \t[legalMovesHighlightColor_Premove]: [0.25, 0, 0.7, 0.3],\n\t// \t[lastMoveHighlightColor]: [0, 0, 0.3, 0.35],\n\t// \t[checkHighlightColor]: /* checkHighlightColor */ [1, 0, 0, 0.7],\n\t// \t[pieceTheme]: {\n\t// \t\t[players.WHITE]: [0.4, 1, 0.4, 1],\n\t// \t\t[players.BLACK]: [1, 0.2, 0.2, 1],\n\t// \t},\n\t// }\n};\n\n/**\n * Returns the specified property of the provided theme.\n * @param {string} themeName - The name of the theme, e.g., \"sandstone\".\n * @param {string} property - The property to retrieve, e.g., \"legalMovesHighlightColor_Friendly\".\n * @returns - The property of the theme or the default value.\n */\nfunction getPropertyOfTheme(themeName: string, property: keyof ThemeProperties): any {\n\tconst value = themeDictionary[themeName]?.[property] ?? defaults[property]!;\n\treturn jsutil.deepCopyObject(value); // Return a deep copy to avoid modifying the original.\n}\n\n/**\n * Checks if a theme name is valid.\n * @param themeName - The name of the theme to check.\n * @returns - True if the theme exists, false otherwise.\n */\nfunction isThemeValid(themeName: string): boolean {\n\treturn themeDictionary[themeName] !== undefined;\n}\n\nexport default {\n\tdefaultTheme,\n\tthemes: themeDictionary,\n\tgetPropertyOfTheme,\n\tisThemeValid,\n};\n"
  },
  {
    "path": "src/shared/game_version.ts",
    "content": "// src/shared/game_version.ts\n\n/**\n * The latest version of the game the website is running.\n * If the client is ever using an old version, we will tell them to hard-refresh.\n */\nexport const GAME_VERSION = '1.10.1';\n"
  },
  {
    "path": "src/shared/types.ts",
    "content": "// src/shared/types.ts\n\n/**\n * Miscellaneous shared type definitions and schemas between server and client.\n *\n * Centralized here to avoid circular dependency issues.\n */\n\nimport * as z from 'zod';\n\nimport typeutil from './chess/util/typeutil.js';\nimport winconutil from './chess/util/winconutil.js';\n\n// Common Helper Schemas ---------------------------------------------------------------\n\n/** A player's rating value and whether we are confident about it. */\nexport type Rating = z.infer<typeof RatingSchema>;\nexport const RatingSchema = z.strictObject({\n\tvalue: z.number(),\n\tconfident: z.boolean(),\n});\n\n/**\n * The clock value for the game, `s+s`, where the left side is\n * start time in seconds, and the right is increment in seconds.\n * Untimed = `-`\n */\nexport type TimeControl = z.infer<typeof TimeControlSchema>;\nexport const TimeControlSchema = z.union([\n\tz.templateLiteral([z.number(), '+', z.number()]),\n\tz.literal('-'),\n]);\n\n// Invite Helper Schemas ---------------------------------------------------------------\n\n/** The username container of an invite sent by the server. DIFFERENT FROM UsernameContainerProperties!!!! */\nexport type ServerUsernameContainer = z.infer<typeof ServerUsernameContainerSchema>;\nexport const ServerUsernameContainerSchema = z.strictObject({\n\ttype: z.enum(['player', 'guest']),\n\tusername: z.string(),\n\t/** The rating of the user. Falls back to the INFINITY leaderboard. */\n\trating: RatingSchema.optional(),\n});\n\n// Game Helper Schemas ---------------------------------------------------------------\n\n/** The values of each color's clock, and which one is currently counting down, if any. */\nexport type ClockValues = z.infer<typeof ClockValuesSchema>;\nexport const ClockValuesSchema = z.strictObject({\n\t/** Each color's remaining time in milliseconds, keyed by player number. */\n\tclocks: typeutil.GenPlayerGroupSchema(z.number()),\n\t/**\n\t * If a player's timer is currently counting down, this should be specified.\n\t * No clock is ticking if less than 2 moves are played, or if the game is over.\n\t * The color specified should have their time immediately accommodated for ping.\n\t */\n\tcolorTicking: typeutil.PlayerSchema.optional(),\n\t/**\n\t * The timestamp the color ticking (if there is one) will lose by timeout.\n\t * This should be calculated AFTER we adjust the clock values for ping.\n\t * The server should NOT specify this when sending the clock information\n\t * to the client, because the server and client's clocks are not always in sync.\n\t */\n\ttimeColorTickingLosesAt: z.number().optional(),\n});\n\n/** A move as transmitted over the wire: the serialized move token (e.g. `\"1,2>3,4=N\"`) and an optional clock stamp. */\nexport type MovePacket = z.infer<typeof MovePacketSchema>;\nexport const MovePacketSchema = z.strictObject({\n\ttoken: z.string(),\n\tclockStamp: z.number().optional(),\n});\n\n/** Info storing draw offers of the game. */\nexport type DrawOfferInfo = z.infer<typeof DrawOfferInfoSchema>;\nexport const DrawOfferInfoSchema = z.strictObject({\n\t/** True if our opponent has extended a draw offer we haven't yet confirmed/denied. */\n\tunconfirmed: z.boolean(),\n\t/** The move ply WE HAVE last offered a draw, if we have, otherwise undefined. */\n\tlastOfferPly: z.number().int().optional(),\n});\n\n/** Contains information about an opponent's disconnection. */\nexport const DisconnectInfoSchema = z.strictObject({\n\t/**\n\t * How many milliseconds left until our opponent will be auto-resigned from disconnection,\n\t * at the time the server sent the message. Subtract half our ping to get the correct estimated value!\n\t */\n\tmillisUntilAutoDisconnectResign: z.number(),\n\t/** Whether the opponent disconnected by choice, or if it was non-intentional (lost network). */\n\twasByChoice: z.boolean(),\n});\n\n/** The state of the game unique to participants, while the game is ongoing — not for spectators, and not when the game is over. */\nexport type ParticipantState = z.infer<typeof ParticipantStateSchema>;\nexport const ParticipantStateSchema = z.strictObject({\n\tdrawOffer: DrawOfferInfoSchema,\n\t/** If our opponent has disconnected, this will be present. */\n\tdisconnect: DisconnectInfoSchema.optional(),\n\t/**\n\t * If our opponent is AFK, this is how many milliseconds left until they will be auto-resigned,\n\t * at the time the server sent the message. Subtract half our ping to get the correct estimated value!\n\t */\n\tmillisUntilAutoAFKResign: z.number().optional(),\n});\n\n/** The message contents of a server websocket `'gameupdate'` message. */\nexport type GameUpdateMessage = z.infer<typeof GameUpdateMessageSchema>;\nexport const GameUpdateMessageSchema = z.strictObject({\n\tgameConclusion: winconutil.gameConclusionSchema.optional(),\n\t/** Existing moves, if any, to forward to the front of the game. */\n\tmoves: z.array(MovePacketSchema),\n\tparticipantState: ParticipantStateSchema,\n\tclockValues: ClockValuesSchema.optional(),\n\t/**\n\t * When true, the client's resync logic should force its move list to exactly match\n\t * the server's, even if the client has one extra move at the end that is \"ours\".\n\t * The client must revert it rather than re-submitting it.\n\t */\n\tforceSync: z.boolean(),\n});\n\n/** The message contents of a server websocket `'move'` message — our opponent's move. */\nexport type OpponentsMoveMessage = z.infer<typeof OpponentsMoveMessageSchema>;\nexport const OpponentsMoveMessageSchema = z.strictObject({\n\t/** The move our opponent played. In the most compact notation: `\"5,2>5,4\"`. */\n\tmove: MovePacketSchema,\n\tgameConclusion: winconutil.gameConclusionSchema.optional(),\n\t/** Our opponent's move number, 1-based. */\n\tmoveNumber: z.number().int().positive(),\n\t/** If the game is timed, this will be the current clock values. */\n\tclockValues: ClockValuesSchema.optional(),\n});\n\n/** ICN (Infinite Chess Notation) metadata for a game, inspired by PGN notation. */\nexport type MetaData = z.infer<typeof MetaDataSchema>;\nexport const MetaDataSchema = z.strictObject({\n\t/** What kind of game (rated/casual), and variant, in spoken language. E.g. \"Casual local Classical infinite chess game\". */\n\tEvent: z.string().optional(),\n\t/** What website the game was played on. */\n\tSite: z.literal('https://www.infinitechess.org/').optional(),\n\tTimeControl: TimeControlSchema.optional(),\n\t/** The round number. A pgn-required metadata with no current application to infinitechess.org. */\n\tRound: z.literal('-').optional(),\n\t/** The UTC date of the game, in the format `\"YYYY.MM.DD\"`. */\n\tUTCDate: z.string().optional(),\n\t/** The UTC time the game started, in the format `\"HH:MM:SS\"`. */\n\tUTCTime: z.string().optional(),\n\t/** If it's not a custom position, this must be one of the valid variants. */\n\tVariant: z.string().optional(),\n\tWhite: z.string().optional(),\n\tBlack: z.string().optional(),\n\t/** The ID of the white player, if they are signed in, converted to base 62. */\n\tWhiteID: z.string().optional(),\n\t/** The ID of the black player, if they are signed in, converted to base 62. */\n\tBlackID: z.string().optional(),\n\t/** The display elo of the white player, which may include a \"?\" if we're uncertain about their rating. */\n\tWhiteElo: z.string().optional(),\n\t/** The display elo of the black player, which may include a \"?\" if we're uncertain about their rating. */\n\tBlackElo: z.string().optional(),\n\t/** How much elo white gained/lost from the match. */\n\tWhiteRatingDiff: z.string().optional(),\n\t/** How much elo black gained/lost from the match. */\n\tBlackRatingDiff: z.string().optional(),\n\t/** How many points each side received from the game (e.g. `\"1-0\"` means white won, `\"1/2-1/2\"` means a draw). */\n\tResult: z.string().optional(),\n\t/** What caused the game to end, in spoken language. E.g. \"Time forfeit\". */\n\tTermination: z.string().optional(),\n});\n\n/** A single player's rating change from a completed rated game. */\nexport type PlayerRatingChangeInfo = z.infer<typeof PlayerRatingChangeInfoSchema>;\nexport const PlayerRatingChangeInfoSchema = z.strictObject({\n\tnewRating: RatingSchema,\n\tchange: z.number(),\n});\n"
  },
  {
    "path": "src/shared/util/EventBus.ts",
    "content": "// src/shared/util/EventBus.ts\n\n/**\n * Typed Event Bus\n * T is the mapping of event names to detail types.\n */\nexport class EventBus<T extends Record<string, any>> {\n\tprivate target = new EventTarget();\n\n\taddEventListener<K extends keyof T & string>(\n\t\ttype: K,\n\t\tlistener: (event: CustomEvent<T[K]>) => void,\n\t\toptions?: boolean | AddEventListenerOptions,\n\t): void {\n\t\t// We cast 'as any' here because the internal EventTarget expects a\n\t\t// generic EventListener, but we are enforcing a stricter one.\n\t\tthis.target.addEventListener(type, listener as any, options);\n\t}\n\n\tremoveEventListener<K extends keyof T & string>(\n\t\ttype: K,\n\t\tlistener: (event: CustomEvent<T[K]>) => void,\n\t\toptions?: boolean | EventListenerOptions,\n\t): void {\n\t\tthis.target.removeEventListener(type, listener as any, options);\n\t}\n\n\tdispatch<K extends keyof T & string>(\n\t\ttype: K,\n\t\t...args: undefined extends T[K] ? [detail?: T[K]] : [detail: T[K]]\n\t): boolean {\n\t\tconst [detail] = args;\n\t\tconst event = new CustomEvent(type, { detail, cancelable: true });\n\t\treturn this.target.dispatchEvent(event);\n\t}\n}\n"
  },
  {
    "path": "src/shared/util/editorutil.ts",
    "content": "// src/shared/util/editorutil.ts\n\n/**\n * Board Editor shared constants between client and server.\n */\n\n// Constants ------------------------------------------\n\n/** Maximum length for a position name */\nconst MAX_POSITION_NAME_LENGTH = 70;\n\n/** Maximum byte length for ICN notation of a saved position */\nconst MAX_ICN_LENGTH = 1_000_000;\n\n// Exports --------------------------------------------\n\nexport default { MAX_POSITION_NAME_LENGTH, MAX_ICN_LENGTH };\n"
  },
  {
    "path": "src/shared/util/isprime.ts",
    "content": "// src/shared/util/isprime.ts\n\n/*\nSource: https://github.com/latonv/MillerRabinPrimality\nAdapted by Andreas Tsevas\nSee attached license below:\n\nMIT License\n\nCopyright (c) Laton Vermette (https://latonv.com)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software\nand associated documentation files (the \"Software\"), to deal in the Software without restriction,\nincluding without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,\nand/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial\nportions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT\nLIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\nIN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\nWHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE\nSOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n*/\n\n// Note to myself, Naviary: ----------------------------------------------------------------------\n// Anything above 341550071728321 has an extremely low probability of returning false positives.\n// As long as both players use the same seeded RNG, then this will never break games if one\n// player's Huygen has different legal moves than the others.\n// The chance of false positives can further be reduced by modifying getAdaptiveNumRounds() to do more checks.\n// -----------------------------------------------------------------------------------------------\n\nimport bimath from './math/bimath.js';\n\n/**\n * A type containing precalculated values needed to efficiently reduce numbers to/from their Montgomery forms\n * and perform Montgomery-reduced arithmetic, modulo a given `base`.\n */\ninterface MontgomeryReductionContext {\n\t/** The modulus of the reduction context */\n\tbase: bigint;\n\t/** The exponent of the power of 2 used for `r` (i.e., `r = 2^shift`) */\n\tshift: bigint;\n\t/** The auxiliary modulus for Montgomery reduction, defined as the smallest power of two greater than `base` */\n\tr: bigint;\n\t/** The modular inverse of `r` (mod `base`) */\n\trInv: bigint;\n\t/** The modular inverse of `base` (mod `r`) */\n\tbaseInv: bigint;\n}\n\n/** A union of types that can be resolved to a primitive bigint: `number`, `string`, or `bigint` itself. */\ntype BigIntResolvable = number | string | bigint;\n\n/** The available options to the primalityTest function. */\ninterface PrimalityTestOptions {\n\t/**\n\t * A positive integer specifying the number of random bases to test against.\n\t * If none is provided, a reasonable number of rounds will be chosen automatically to balance speed and accuracy.\n\t */\n\tnumRounds?: number;\n\t/**\n\t * An array of integers (or string representations thereof) to use as the\n\t * bases for Miller-Rabin testing. If this option is specified, the `numRounds` option will be ignored,\n\t * and the maximum number of testing rounds will equal `bases.length` (one round for each given base).\n\t *\n\t * Every base provided must lie within the range [2, n-2] (inclusive) or a RangeError will be thrown.\n\t * If `bases` is specified but is not an array, a TypeError will be thrown.\n\t */\n\tbases?: BigIntResolvable[];\n\t/**\n\t * Whether to calculate and return a divisor of `n` in certain cases where this is possible (not guaranteed).\n\t * Set this to false to avoid extra calculations if a divisor is not needed. Defaults to `true`.\n\t */\n\tfindDivisor?: boolean;\n}\n\n// Some useful BigInt constants\nconst ZERO = 0n;\nconst ONE = 1n;\nconst TWO = 2n;\nconst FOUR = 4n;\nconst LIMIT_DETERMINISM = 2n ** 64n;\nconst LOWER_LIMIT_MONTGOMMERY = 10n ** 30n;\nconst MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);\n\n// Useful int constants\n// See https://en.wikipedia.org/wiki/Miller%E2%80%93Rabin_primality_test#Testing_against_small_sets_of_bases\n// and: https://oeis.org/A014233\n// and longer base lists: https://en.wikipedia.org/wiki/Miller%E2%80%93Rabin_primality_test#Deterministic_variants_of_the_test\nconst LIMIT_2 = 2047;\nconst LIMIT_2_3 = 1373653;\nconst LIMIT_2_3_5 = 25326001;\nconst LIMIT_2_3_5_7 = 3215031751;\nconst LIMIT_2_3_5_7_11 = 2152302898747;\nconst LIMIT_2_3_5_7_11_13 = 3474749660383;\nconst LIMIT_2_3_5_7_11_13_17 = 341550071728321;\nconst SAFE_SQRT = Math.sqrt(Number.MAX_SAFE_INTEGER);\n\n// Bases for deterministic Miller-Rabin\n// See https://en.wikipedia.org/wiki/Miller%E2%80%93Rabin_primality_test#Testing_against_small_sets_of_bases\n// and: https://oeis.org/A014233\n// and: https://miller-rabin.appspot.com/\nconst INT_BASES = [2, 3, 5, 7, 11, 13, 17, 19, 23] as const;\nconst BIGINT_BASES = [2n, 325n, 9375n, 28178n, 450775n, 9780504n, 1795265022n] as const;\n\n/**\n * Calculates the inverse of `2^exp` modulo the given odd `base`.\n * @param exp The exponent of the power of 2 that should be inverted (_not_ the power of 2 itself!)\n * @param base The modulus to invert with respect to\n * @returns The modular inverse of `2^exp` modulo `base`\n */\nfunction invertPowerOfTwo(exp: number, base: bigint): bigint {\n\t// Penk's rshift inversion method, but restricted to powers of 2 and odd bases (which is all we require for Miller-Rabin)\n\t// Just start from 1 and repeatedly halve, adding the base whenever necessary to remain even.\n\tlet inv = ONE;\n\tfor (let i = 0; i < exp; i++) {\n\t\tif (inv & ONE) inv += base;\n\t\tinv >>= ONE;\n\t}\n\n\treturn inv;\n}\n\n/**\n * Calculates the multiplicity of 2 in the prime factorization of `n` -- i.e., how many factors of 2 `n` contains.\n * So if `n = 2^k * d` and `d` is odd, the returned value would be `k`.\n * @param n Any number\n * @returns The multiplicity of 2 in the prime factorization of `n`\n */\nfunction twoMultiplicity(n: bigint): bigint {\n\tif (n === ZERO) return ZERO;\n\n\tlet m = ZERO;\n\twhile (true) {\n\t\t// Since n is not 0, it must have a leading 1 bit, so this is safe\n\t\tif (n & (ONE << m)) return m; // Bail out when we reach the least significant 1 bit\n\t\tm++;\n\t}\n}\n\n/**\n * Produces a string of random bits with the specified length.\n * Mainly useful as input to BigInt constructors that take digit strings of arbitrary length.\n * @param numBits How many random bits to return.\n * @returns A string of `numBits` random bits.\n */\nfunction getRandomBitString(numBits: number): string {\n\tlet bits = '';\n\twhile (bits.length < numBits) {\n\t\tbits += Math.random().toString(2).substring(2, 50);\n\t}\n\treturn bits.substring(0, numBits);\n}\n\n/**\n * Produces a Montgomery reduction context that can be used to define and operate on numbers in Montgomery form\n * for the given base.\n * @param base The modulus of the reduction context. Must be an odd number.\n * @returns A Montgomery reduction context for the given base.\n */\nfunction getReductionContext(base: bigint): MontgomeryReductionContext {\n\tif (!(base & ONE)) throw new Error(`base must be odd`);\n\n\t// Select the auxiliary modulus r to be the smallest power of two greater than the base modulus\n\tconst numBits = bimath.bitLength_bisection(base);\n\tconst littleShift = numBits;\n\tconst shift = BigInt(littleShift);\n\tconst r = ONE << shift;\n\n\t// Calculate the modular inverses of r (mod base) and base (mod r)\n\tconst rInv = invertPowerOfTwo(littleShift, base);\n\tconst baseInv = r - (((rInv * r - ONE) / base) % r); // From base*baseInv + r*rInv = 1  (mod r)\n\n\treturn { base, shift, r, rInv, baseInv };\n}\n\n/**\n * Convert the given number into its Montgomery form, according to the given Montgomery reduction context.\n * @param n Any number\n * @param ctx The Montgomery reduction context to reduce into\n * @returns The Montgomery form of `n`\n */\nfunction montgomeryReduce(n: bigint, ctx: MontgomeryReductionContext): bigint {\n\treturn (n << ctx.shift) % ctx.base;\n}\n\n// /**\n//  * Converts the given number _out_ of Montgomery form, according to the given Montgomery reduction context.\n//  *\n//  * @param {bigint} n A number in Montgomery form\n//  * @param {MontgomeryReductionContext} ctx The Montgomery reduction context to reduce out of\n//  * @returns {bigint} The (no longer Montgomery-reduced) number whose Montgomery form was `n`\n//  */\n// function invMontgomeryReduce(n, ctx) {\n//   return (n * ctx.rInv) % ctx.base\n// }\n\n/**\n * Squares a number in Montgomery form.\n * @param n A number in Montgomery form\n * @param ctx The Montgomery reduction context to square within\n * @returns The Montgomery-reduced square of `n`\n */\nfunction montgomerySqr(n: bigint, ctx: MontgomeryReductionContext): bigint {\n\treturn montgomeryMul(n, n, ctx);\n}\n\n/**\n * Multiplies two numbers in Montgomery form.\n * @param a A number in Montgomery form\n * @param b A number in Montgomery form\n * @param ctx The Montgomery reduction context to multiply within\n * @returns The Montgomery-reduced product of `a` and `b`\n */\nfunction montgomeryMul(a: bigint, b: bigint, ctx: MontgomeryReductionContext): bigint {\n\tif (a === ZERO || b === ZERO) return ZERO;\n\n\tconst rm1 = ctx.r - ONE;\n\tconst unredProduct = a * b;\n\n\tconst t = (((unredProduct & rm1) * ctx.baseInv) & rm1) * ctx.base;\n\tlet product = (unredProduct - t) >> ctx.shift;\n\n\tif (product >= ctx.base) product -= ctx.base;\n\telse if (product < ZERO) product += ctx.base;\n\n\treturn product;\n}\n\n/**\n * Calculates `n` to the power of `exp` in Montgomery form.\n * While `n` must be in Montgomery form, `exp` should not.\n * @param n A number in Montgomery form; the base of the exponentiation\n * @param exp Any number (_not_ in Montgomery form)\n * @param ctx The Montgomery reduction context to exponentiate within\n * @returns The Montgomery-reduced result of taking `n` to exponent `exp`\n */\nfunction montgomeryPow(n: bigint, exp: bigint, ctx: MontgomeryReductionContext): bigint {\n\t// Exponentiation by squaring\n\tconst expLen = BigInt(bimath.bitLength_bisection(exp));\n\tlet result = montgomeryReduce(ONE, ctx);\n\tfor (let i = ZERO, x = n; i < expLen; ++i, x = montgomerySqr(x, ctx)) {\n\t\tif (exp & (ONE << i)) result = montgomeryMul(result, x, ctx);\n\t}\n\n\treturn result;\n}\n\n/** A record class to hold the result of primality testing. */\n// class PrimalityResult {\n//   /**\n//    * Constructs a result object from the given options\n//    * @param {PrimalityResultOptions} options\n//    */\n//   constructor({ probablePrime }) {\n//     this.probablePrime = probablePrime\n//   }\n// }\n\n/**\n * Ensures that all bases in the given array are valid for use in Miller-Rabin tests on the number `n = nSub + 1`.\n * A base is valid if it is an integer in the range [2, n-2].\n *\n * If `bases` is null or undefined, it is ignored and null is returned.\n * If `bases` is an array of valid bases, they will be returned as a new array, all coerced to BigInts.\n * Otherwise, a RangeError will be thrown if any of the bases are outside the valid range, or a TypeError will\n * be thrown if `bases` is neither an array nor null/undefined.\n *\n * @param bases The array of bases to validate\n * @param nSub One less than the number being primality tested\n * @returns An array of BigInts provided all bases were valid, or null if the input was null\n */\nfunction validateBases(\n\tbases: BigIntResolvable[] | null | undefined,\n\tnSub: bigint,\n): bigint[] | null {\n\tif (!bases) return null;\n\tif (!Array.isArray(bases)) throw new TypeError(`invalid bases option (must be an array)`);\n\t// Ensure all bases are valid BigInts within [2, n-2]\n\treturn bases.map((b) => {\n\t\tif (typeof b !== 'bigint') b = BigInt(b);\n\t\tif (!(b >= TWO) || !(b < nSub))\n\t\t\tthrow new RangeError(`invalid base (must be in the range [2, n-2]): ${b}`);\n\t\treturn b;\n\t});\n}\n\n/**\n * Computes (p1 * p2) mod modulus for numbers\n * @param p1 - base\n * @param p2 - base\n * @param  modulus - modulus\n * @returns (p1 * p2) % modulus\n */\nfunction modProductNumber(p1: number, p2: number, modulus: number): number {\n\tif (p1 > SAFE_SQRT || p2 > SAFE_SQRT)\n\t\treturn Number((BigInt(p1) * BigInt(p2)) % BigInt(modulus));\n\telse return (p1 * p2) % modulus;\n}\n\n/**\n * Computes (base ^ 2) mod modulus for numbers\n * @param base - base\n * @param modulus - modulus\n * @returns (base ** 2) % modulus\n */\nfunction modSquaredNumber(base: number, modulus: number): number {\n\tif (base > SAFE_SQRT) return Number(BigInt(base) ** TWO % BigInt(modulus));\n\telse return base ** 2 % modulus;\n}\n\n/**\n * Computes (base ^ exponent) mod modulus for numbers, avoiding recursion because of large exponent\n * @param base - base\n * @param exponent - exponent\n * @param modulus - modulus\n * @returns (base ** exponent) % modulus\n */\nfunction modPowNumber(base: number, exponent: number, modulus: number): number {\n\tlet accumulator = 1;\n\twhile (exponent !== 0) {\n\t\tif (exponent % 2 === 0) {\n\t\t\texponent = exponent / 2;\n\t\t\tbase = modSquaredNumber(base, modulus);\n\t\t} else {\n\t\t\texponent = exponent - 1;\n\t\t\taccumulator = modProductNumber(base, accumulator, modulus);\n\t\t}\n\t}\n\treturn accumulator;\n}\n\n/**\n * Computes (base ^ exponent) mod modulus for BigInts, avoiding recursion because of large exponent\n * @param base - base\n * @param exponent - exponent\n * @param modulus - modulus\n * @returns (base ** exponent) % modulus\n */\nfunction modPowBigint(base: bigint, exponent: bigint, modulus: bigint): bigint {\n\tlet accumulator = ONE;\n\twhile (exponent !== ZERO) {\n\t\tif (exponent % TWO === ZERO) {\n\t\t\texponent = exponent / TWO;\n\t\t\tbase = base ** TWO % modulus;\n\t\t} else {\n\t\t\texponent = exponent - ONE;\n\t\t\taccumulator = (base * accumulator) % modulus;\n\t\t}\n\t}\n\treturn accumulator;\n}\n\n/**\n * Runs Miller-Rabin primality tests on `n` which can be a number, string, or a bigint.\n * If `n` is a number/string smaller than Number.MAX_SAFE_INTEGER, then primalityTestNumber() is called.\n * If `n` is a bigint/string larger than Number.MAX_SAFE_INTEGER, then primalityTestBigint() is called.\n * @param n - A number or bigint integer to be tested for primality.\n * @param options - optional arguments passed along to primalityTestBigint() if necessary\n * @returns true if all the primality tests passed, false otherwise\n */\nexport function primalityTest(n: BigIntResolvable, options?: PrimalityTestOptions): boolean {\n\tif (typeof n === 'number') return primalityTestNumber(n);\n\telse if (typeof n === 'string') n = BigInt(n);\n\n\tif (n < MAX_SAFE_INTEGER_BIGINT) return primalityTestNumber(Number(n));\n\treturn primalityTestBigint(n, options);\n}\n\n/**\n * Runs deterministic Miller-Rabin primality test on number `n`\n * @param n - A number be tested for primality.\n * @returns true if all the primality tests passed, false otherwise\n */\nfunction primalityTestNumber(n: number): boolean {\n\tlet bases: number[];\n\t// Handle some small special cases\n\tif (n < 2)\n\t\treturn false; // n = 0 or 1\n\telse if (n < 4)\n\t\treturn true; // n = 2 or 3\n\telse if (n % 2 === 0)\n\t\treturn false; // Quick short-circuit for other even n\n\telse if (n < LIMIT_2) bases = INT_BASES.slice(0, 1);\n\telse if (n < LIMIT_2_3) bases = INT_BASES.slice(0, 2);\n\telse if (n < LIMIT_2_3_5) bases = INT_BASES.slice(0, 3);\n\telse if (n < LIMIT_2_3_5_7) bases = INT_BASES.slice(0, 4);\n\telse if (n < LIMIT_2_3_5_7_11) bases = INT_BASES.slice(0, 5);\n\telse if (n < LIMIT_2_3_5_7_11_13) bases = INT_BASES.slice(0, 6);\n\telse if (n < LIMIT_2_3_5_7_11_13_17) bases = INT_BASES.slice(0, 7);\n\telse bases = INT_BASES.slice(0, 9);\n\n\tconst nSub = n - 1;\n\tlet r = 0;\n\tlet d = nSub;\n\twhile (d % 2 === 0) {\n\t\td = d / 2;\n\t\tr += 1;\n\t}\n\n\tfor (let round = 0; round < bases.length; round++) {\n\t\tconst base = bases[round]!;\n\n\t\t// Normal Miller-Rabin method => FAST for smaller numbers!\n\t\tconst modularpower = modPowNumber(base, d, n);\n\t\tif (modularpower !== 1) {\n\t\t\tfor (let i = 0, x = modularpower; x !== nSub; i += 1, x = modSquaredNumber(x, n)) {\n\t\t\t\tif (i === r - 1) return false;\n\t\t\t}\n\t\t}\n\t}\n\n\treturn true;\n}\n\n/**\n * Runs probabilistic Miller-Rabin primality tests on bigint `n` using randomly-chosen bases, to determine with high probability whether `n` is a prime number.\n *\n * @param n A Bigint integer to be tested for primality.\n * @param options An object specifying the `numRounds` and/or `findDivisor` options.\n *   - `numRounds` is a positive integer specifying the number of random bases to test against.\n *    If none is provided, a reasonable number of rounds will be chosen automatically to balance speed and accuracy.\n *   - `bases` is an array of integers to use as the bases for Miller-Rabin testing. If this option\n *    is specified, the `numRounds` option will be ignored, and the maximum number of testing rounds will equal `bases.length` (one round\n *    for each given base). Every base provided must lie within the range [2, n-2] (inclusive) or a RangeError will be thrown.\n *    If `bases` is specified but is not an array, a TypeError will be thrown.\n *   - `findDivisor` is a boolean specifying whether to calculate and return a divisor of `n` in certain cases where this is\n *    easily possible (not guaranteed). Set this to false to avoid extra calculations if a divisor is not needed. Defaults to `true`.\n *   - `useMontgomery` specifies whether the Montgomery reduction context for faster modular exponentiation should be used.\n *     If left undefined, it is set automatically (recommended).\n * @returns true if all the primality tests passed, false otherwise\n */\nfunction primalityTestBigint(\n\tn: bigint,\n\toptions?: PrimalityTestOptions & { useMontgomery?: boolean },\n): boolean {\n\t// eslint-disable-next-line prefer-const\n\tlet { numRounds, bases, findDivisor = true, useMontgomery } = options || {};\n\n\t// Handle some small special cases\n\tif (n < TWO)\n\t\treturn false; // n = 0 or 1\n\telse if (n < FOUR)\n\t\treturn true; // n = 2 or 3\n\telse if (!(n & ONE))\n\t\treturn false; // Quick short-circuit for other even n\n\telse if (n < LIMIT_DETERMINISM) bases = BIGINT_BASES.slice(0, BIGINT_BASES.length);\n\n\tconst nBits = bimath.bitLength_bisection(n);\n\tconst nSub = n - ONE;\n\n\t// Represent n-1 as d * 2^r, with d odd\n\tconst r = twoMultiplicity(nSub); // Multiplicity of prime factor 2 in the prime factorization of n-1\n\tconst d = nSub >> r; // The result of factoring out all powers of 2 from n-1\n\n\t// Either use the user-provided list of bases to test against, or determine how many random bases to test\n\tconst validBases = validateBases(bases, nSub);\n\tif (validBases !== null) numRounds = validBases.length;\n\telse if (!numRounds || numRounds < 1) {\n\t\t// If the number of testing rounds was not provided, pick a reasonable one based on the size of n\n\t\t// Larger n have a vanishingly small chance to be falsely labelled probable primes, so we can balance speed and accuracy accordingly\n\t\tnumRounds = getAdaptiveNumRounds(nBits);\n\t}\n\n\tlet baseIndex = 0; // Only relevant if the user specified a list of bases to use\n\n\t// if useMontgomery is not specified, it will be set according to the cutoff at LOWER_LIMIT_MONTGOMMERY\n\tif (useMontgomery === undefined) {\n\t\tif (n < LOWER_LIMIT_MONTGOMMERY) useMontgomery = false;\n\t\telse useMontgomery = true;\n\t}\n\n\tif (useMontgomery) {\n\t\t// Faster for larger numbers (like above 1e30)\n\t\t// Convert into a Montgomery reduction context for faster modular exponentiation\n\t\tconst reductionContext = getReductionContext(n);\n\t\tconst oneReduced = montgomeryReduce(ONE, reductionContext); // The number 1 in the reduction context\n\t\tconst nSubReduced = montgomeryReduce(nSub, reductionContext); // The number n-1 in the reduction context\n\n\t\tfor (let round = 0; round < numRounds; round++) {\n\t\t\tlet base: bigint;\n\t\t\tif (validBases !== null) {\n\t\t\t\t// Use the next user-specified base\n\t\t\t\tbase = validBases[baseIndex]!;\n\t\t\t\tbaseIndex++;\n\t\t\t} else {\n\t\t\t\t// Select a random base to test\n\t\t\t\tdo {\n\t\t\t\t\tbase = BigInt('0b' + getRandomBitString(nBits));\n\t\t\t\t} while (!(base >= TWO) || !(base < nSub)); // The base must lie within [2, n-2]\n\t\t\t}\n\n\t\t\t// Check whether the chosen base has any factors in common with n (if so, we can end early)\n\t\t\tif (findDivisor) {\n\t\t\t\tconst gcd = bimath.GCD(n, base);\n\t\t\t\tif (gcd !== ONE) return false; // Found a factor of n, so no need for further primality tests\n\t\t\t}\n\n\t\t\tconst baseReduced = montgomeryReduce(base, reductionContext);\n\t\t\tlet x = montgomeryPow(baseReduced, d, reductionContext);\n\t\t\tif (x === oneReduced || x === nSubReduced) continue; // The test passed: base^d = +/-1 (mod n)\n\n\t\t\t// Perform the actual Miller-Rabin loop\n\t\t\tlet i: bigint, y: bigint;\n\t\t\tfor (i = ZERO; i < r; i++) {\n\t\t\t\ty = montgomerySqr(x, reductionContext);\n\n\t\t\t\tif (y === oneReduced)\n\t\t\t\t\treturn false; // The test failed: base^(d*2^i) = 1 (mod n) and thus cannot be -1 for any i\n\t\t\t\telse if (y === nSubReduced) {\n\t\t\t\t\t// The test passed: base^(d*2^i) = -1 (mod n) for the current i\n\t\t\t\t\t// So n is a strong probable prime to this base (though n may still be composite)\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tx = y;\n\t\t\t}\n\n\t\t\t// No value of i satisfied base^(d*2^i) = +/-1 (mod n)\n\t\t\t// So this base is a witness to the guaranteed compositeness of n\n\t\t\tif (i === r) return false;\n\t\t}\n\t\treturn true;\n\t} else {\n\t\t// Use Miller-Robin method (faster for smaller numbers, like below 1e30)\n\t\tfor (let round = 0; round < numRounds; round++) {\n\t\t\tlet base: bigint;\n\t\t\tif (validBases !== null) {\n\t\t\t\t// Use the next user-specified base\n\t\t\t\tbase = validBases[baseIndex]!;\n\t\t\t\tbaseIndex++;\n\t\t\t} else {\n\t\t\t\t// Select a random base to test\n\t\t\t\tdo {\n\t\t\t\t\tbase = BigInt('0b' + getRandomBitString(nBits));\n\t\t\t\t} while (!(base >= TWO) || !(base < nSub)); // The base must lie within [2, n-2]\n\t\t\t}\n\n\t\t\t// Check whether the chosen base has any factors in common with n (if so, we can end early)\n\t\t\tif (findDivisor) {\n\t\t\t\tconst gcd = bimath.GCD(n, base);\n\t\t\t\tif (gcd !== ONE) return false; // Found a factor of n, so no need for further primality tests\n\t\t\t}\n\n\t\t\t// normal Miller-Rabin\n\t\t\tconst modularpower = modPowBigint(base, d, n);\n\t\t\tif (modularpower !== ONE) {\n\t\t\t\tfor (let i = ZERO, x = modularpower; x !== nSub; i += ONE, x = x ** TWO % n) {\n\t\t\t\t\tif (i === r - ONE) return false;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn true;\n\t}\n}\n\n/**\n * Determines an appropriate number of Miller-Rabin testing rounds to perform based on the size of the\n * input number being tested. Larger numbers generally require fewer rounds to maintain a given level\n * of accuracy.\n * @param inputBits The number of bits in the input number.\n * @returns How many rounds of testing to perform.\n */\nfunction getAdaptiveNumRounds(inputBits: number): number {\n\tif (inputBits > 1000) return 2;\n\telse if (inputBits > 500) return 3;\n\telse if (inputBits > 250) return 4;\n\telse if (inputBits > 150) return 5;\n\telse return 6;\n}\n\n////////////////////////////////////////////////////////////////////////////////////////////////////////\n// Everything below this line is only for testing purposes\n////////////////////////////////////////////////////////////////////////////////////////////////////////\n\n// Get the mathjs module via \"npm install mathjs\"\n/*\nlet mathjs = require('mathjs');\n\nlet prime1 = 11;\nlet prime2 = BigInt(\"34260522533194312141699016768017376046579370858274371908475849\");\nlet prime3 = BigInt(\"24609615439855545007865829059894825853255339682863740988001\");\nlet prime4 = BigInt(\"10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000267\");\nlet largecomposite = prime2*prime3;\n\nfunction run_basic_tests(){\n  console.log(\"prime1 is prime? \" + primalityTest(prime1));\n  console.log(\"prime2 is prime? \" + primalityTest(prime2));\n  console.log(\"prime3 is prime? \" + primalityTest(prime3));\n  console.log(\"prime4 is prime? \" + primalityTest(prime4));\n  console.log(\"Product of prime2 and prime3 is prime? \" + primalityTest(largecomposite));\n  console.log(\"Stupidly large composite is prime? \" + primalityTest(BigInt(\"10\") ** BigInt(\"1000\") + BigInt(\"13\")));\n}\nrun_basic_tests();\n\nfunction test_for_errors(){\n  let erroramount = 0;\n  let N_TESTS = 1000;\n  for (let i = 0; i< N_TESTS; i++){\n    if (primalityTest(largecomposite)) erroramount++;\n  }\n  console.log(\"Consistency test. Number of false positives after \" + N_TESTS + \" tests: \" + erroramount);\n}\ntest_for_errors(largecomposite);\n\nfunction speed_test(){\n  let N_START = 10n**10n;\n  let N_STEPS = 10n**5n;\n  let timer = Date.now();\n  for (let i = N_START; i< N_START + N_STEPS; i+= 1n){\n    primalityTest(i, {useMontgomery: false});\n  }\n  console.log(`Speed test. Time ellapsed: ${(Date.now()-timer)/1000}s`);\n}\nspeed_test();\n\nfunction mathjs_speed_test(){\n  let N_START = 10**10;\n  let N_STEPS = 10**5;\n  let timer = Date.now();\n  for (let i = N_START; i< N_START + N_STEPS; i+= 1){\n    mathjs.isPrime(i);\n  }\n  console.log(`Mathjs speed test. Time ellapsed: ${(Date.now()-timer)/1000}s`);\n}\nmathjs_speed_test();\n\n// Test this program for correctness with mathjs library\nfunction test_program(){\n  let i = 10**12 + 1;\n  while(true) {\n    let isprime_thisprogram = primalityTest(i);\n    let isprime_mathjs = mathjs.isPrime(i);\n    if (isprime_thisprogram != isprime_mathjs){\n      console.log(`Program fails for number ${i}`);\n      console.log(isprime_thisprogram);\n      console.log(isprime_mathjs);\n      break;\n    } else if(i % 10000 == 0){\n      console.log(`Numbers up to ${i} tested`);\n    }\n    i += 1;\n  }\n}\ntest_program();\n*/\n"
  },
  {
    "path": "src/shared/util/jsutil.ts",
    "content": "// src/shared/util/jsutil.ts\n\n/**\n * This scripts contains utility methods for working with javascript objects.\n */\n\nimport bimath from './math/bimath.js';\n\n/**\n * Deep copies an entire object, no matter how deep its nested.\n * No properties will contain references to the source object.\n * Use this instead of structuredClone() because of browser support,\n * or when that throws an error due to functions contained within the src.\n *\n * SLOW. Avoid using for very massive objects.\n */\nfunction deepCopyObject<T extends unknown>(src: T): T {\n\tif (typeof src !== 'object' || src === null) return src;\n\n\t// Check for Maps\n\tif (src instanceof Map) {\n\t\t// Create a new Map instance\n\t\tconst copy = new Map();\n\t\t// Iterate over the original map's entries\n\t\tfor (const [key, value] of src.entries()) {\n\t\t\t// Deep copy both the key and the value before setting them in the new map\n\t\t\tcopy.set(deepCopyObject(key), deepCopyObject(value));\n\t\t}\n\t\treturn copy as T; // Return the new Map with deep copied entries\n\t}\n\n\t// Check for Sets\n\tif (src instanceof Set) {\n\t\t// Create a new Set instance\n\t\tconst copy = new Set();\n\t\t// Iterate over the original set's values\n\t\tfor (const value of src) {\n\t\t\t// Deep copy the value before adding it to the new set\n\t\t\tcopy.add(deepCopyObject(value));\n\t\t}\n\t\treturn copy as T; // Return the new Set with deep copied values\n\t}\n\n\t// Check for TypedArrays (which are ArrayBuffer views and have slice)\n\tif (ArrayBuffer.isView(src) && typeof (src as any).slice === 'function') {\n\t\treturn (src as any).slice() as T; // Use slice for TypedArray copy\n\t}\n\n\t// Handle remaining arrays and objects\n\tconst copy: any = Array.isArray(src) ? [] : {}; // Create an empty array or object\n\tfor (const key in src) {\n\t\tconst value = src[key];\n\t\tcopy[key] = deepCopyObject(value); // Recursively copy each property\n\t}\n\n\treturn copy as T; // Return the copied object\n}\n\n/**\n * Deep copies a Float32Array.\n */\nfunction copyFloat32Array(src: Float32Array): Float32Array {\n\tif (!src || !(src instanceof Float32Array)) {\n\t\tthrow new Error('Invalid input: must be a Float32Array');\n\t}\n\n\tconst copy = new Float32Array(src.length);\n\n\tfor (let i = 0; i < src.length; i++) {\n\t\tcopy[i]! = src[i]!;\n\t}\n\n\treturn copy;\n}\n\n/**\n * Searches an organized array and returns an object telling\n * you the index the element could be added at for the array to remain\n * organized, and whether the element was already found in the array.\n * @param sortedArray - The array sorted in ascending order\n * @param value - The value to find in the array.\n * @returns An object telling you whether the value was found, and the index of that value, or where it can be inserted to remain organized.\n */\nfunction binarySearch(sortedArray: number[], value: number): { found: boolean; index: number } {\n\tlet left = 0;\n\tlet right = sortedArray.length - 1;\n\n\twhile (left <= right) {\n\t\tconst mid = Math.floor((left + right) / 2);\n\t\tconst midValue = sortedArray[mid]!;\n\n\t\tif (value < midValue) right = mid - 1;\n\t\telse if (value > midValue) left = mid + 1;\n\t\telse return { found: true, index: mid };\n\t}\n\n\t// The left is the correct index to insert at, while retaining order!\n\treturn { found: false, index: left };\n}\n\n/**\n * Uses binary search to quickly find and insert the given number in the\n * organized array.\n *\n * MUST NOT ALREADY CONTAIN THE VALUE!!\n * @param sortedArray - The array to search, which must be sorted in ascending order.\n * @param value - The value add in the correct place, retaining order.\n * @returns The new array with the sorted element.\n */\nfunction addElementToOrganizedArray(sortedArray: number[], value: number): number[] {\n\tconst { found, index } = binarySearch(sortedArray, value);\n\tif (found)\n\t\tthrow Error(\n\t\t\t`Cannot add element to sorted array when it already contains the value! ${value}. List: ${JSON.stringify(sortedArray)}`,\n\t\t);\n\tsortedArray.splice(index, 0, value);\n\treturn sortedArray;\n}\n\n/**\n * Calculates the index in the given organized array at which you could insert\n * the point and the array would still be organized.\n * @param sortedArray - An array of numbers organized in ascending order.\n * @param point - The point in the array to find the index for.\n * @returns The index\n */\nfunction findIndexOfPointInOrganizedArray(sortedArray: number[], point: number): number {\n\treturn binarySearch(sortedArray, point).index;\n}\n\n/**\n * Copies the properties from one object to another,\n * without overwriting the existing properties on the destination object,\n * UNLESS the destination object has a matching property name.\n * @param objSrc - The source object\n * @param objDest - The destination object\n */\nfunction copyPropertiesToObject(objSrc: Record<string, any>, objDest: Record<string, any>): void {\n\tfor (const [key, value] of Object.entries(objSrc)) {\n\t\tobjDest[key] = value;\n\t}\n}\n\n/**\n * O(1) method of checking if an object/dict is empty\n * I think??? I may be wrong. I think before the first iteration of\n * a for-in loop the program still has to calculate the keys...\n */\nfunction isEmpty(obj: object): boolean {\n\tfor (const prop in obj) {\n\t\tif (Object.prototype.hasOwnProperty.call(obj, prop)) return false;\n\t}\n\n\treturn true;\n}\n\n/**\n * Returns a new object with the keys being the values of the provided object, and the values being the keys.\n * THE VALUES WILL ALWAYS BE STRINGS. This is because the keys of an object are always strings.\n */\nfunction invertObj(obj: Record<string, string>): Record<string, string> {\n\tconst inv: Record<string, string> = {};\n\tfor (const key in obj) {\n\t\tinv[obj[key]!] = key;\n\t}\n\treturn inv;\n}\n\n/**\n * Estimates the size, in memory, of ANY object, no matter how deep it's nested,\n * and returns that number in a human-readable string.\n *\n * This takes into account added overhead from each object/array created,\n * as those have extra prototype methods, etc, adding more memory. It also\n * attempts to correctly estimate the size of TypedArrays, ArrayBuffers, Maps, and Sets.\n *\n * @author Gemini 2.5 Pro\n */\nfunction estimateMemorySizeOf(obj: any): string {\n\tconst visited = new Set<any>(); // Use a Set to track visited objects to handle cycles and prevent double-counting.\n\n\t// --- Helper Functions ---\n\n\tfunction roughSizeOfObject(value: any): number {\n\t\tlet bytes = 0;\n\n\t\t// --- Primitive types ---\n\t\tif (typeof value === 'boolean') bytes = 4;\n\t\telse if (typeof value === 'string')\n\t\t\tbytes = value.length * 2; // Each char is 2 bytes in JS strings (UTF-16)\n\t\telse if (typeof value === 'number')\n\t\t\tbytes = 8; // 64-bit float\n\t\telse if (typeof value === 'symbol')\n\t\t\tbytes = (value.description?.length ?? 0) * 2 + 8; // Description + internal overhead\n\t\telse if (typeof value === 'bigint')\n\t\t\tbytes = bimath.estimateBigIntSize(value); // Precise BigInt estimator\n\t\telse if (value === null || typeof value === 'undefined')\n\t\t\tbytes = 0; // Very small\n\t\telse if (typeof value === 'function')\n\t\t\tbytes = value.toString().length * 2 + 100; // Very rough guess\n\t\t// --- Object types ---\n\t\telse if (typeof value === 'object') {\n\t\t\t// --- Handle circular references and already visited objects ---\n\t\t\tif (visited.has(value)) return 0;\n\t\t\tvisited.add(value);\n\n\t\t\t// --- Specific object types ---\n\n\t\t\t// ArrayBuffer: The raw data store\n\t\t\tif (value instanceof ArrayBuffer) {\n\t\t\t\tbytes = value.byteLength + 64; // byteLength + object overhead\n\t\t\t}\n\t\t\t// TypedArray views (Int8Array, Float32Array, etc.)\n\t\t\telse if (ArrayBuffer.isView(value)) {\n\t\t\t\tbytes = value.byteLength + 64; // Data size + view object overhead\n\t\t\t\t// Ensure the underlying buffer is also marked as visited if not already\n\t\t\t\tif (value.buffer && !visited.has(value.buffer)) {\n\t\t\t\t\tvisited.add(value.buffer);\n\t\t\t\t\t// Optionally add buffer overhead ONCE if buffer itself wasn't visited\n\t\t\t\t\t// bytes += 64; // Depends on desired accuracy for shared buffer overhead.\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Date objects\n\t\t\telse if (value instanceof Date)\n\t\t\t\tbytes = 8 + 40; // Internal number + object overhead\n\t\t\t// RegExp objects\n\t\t\telse if (value instanceof RegExp)\n\t\t\t\tbytes = value.source.length * 2 + 40; // Source string + object overhead\n\t\t\t// Map objects\n\t\t\telse if (value instanceof Map) {\n\t\t\t\tbytes = 64; // Overhead for the Map object itself\n\t\t\t\tfor (const [key, val] of value.entries()) {\n\t\t\t\t\tbytes += roughSizeOfObject(key);\n\t\t\t\t\tbytes += roughSizeOfObject(val);\n\t\t\t\t\tbytes += 16; // Overhead per entry (approx)\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Set objects\n\t\t\telse if (value instanceof Set) {\n\t\t\t\tbytes = 64; // Overhead for the Set object itself\n\t\t\t\tfor (const val of value.values()) {\n\t\t\t\t\tbytes += roughSizeOfObject(val);\n\t\t\t\t\tbytes += 16; // Overhead per entry (approx)\n\t\t\t\t}\n\t\t\t}\n\t\t\t// --- Generic objects and arrays ---\n\t\t\telse {\n\t\t\t\tconst isArray = Array.isArray(value);\n\t\t\t\t// Overhead for object/array itself (pointers, length, prototype)\n\t\t\t\tbytes = isArray ? 40 : 40;\n\n\t\t\t\tfor (const key in value) {\n\t\t\t\t\t// Only count own properties\n\t\t\t\t\tif (!Object.hasOwnProperty.call(value, key)) continue;\n\n\t\t\t\t\t// Size of the key (property name or array index)\n\t\t\t\t\tif (!isArray || isNaN(parseInt(key, 10))) {\n\t\t\t\t\t\tbytes += key.length * 2; // Key string size\n\t\t\t\t\t}\n\n\t\t\t\t\t// Reference pointer size (approx)\n\t\t\t\t\tbytes += 8; // Assumed pointer/reference overhead\n\n\t\t\t\t\t// Size of the value (recursive call)\n\t\t\t\t\tbytes += roughSizeOfObject(value[key]);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn bytes;\n\t}\n\n\t// Turns the number into a human-readable string\n\tfunction formatByteSize(bytes: number): string {\n\t\tif (bytes < 1024) return bytes + ' bytes';\n\t\telse if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';\n\t\telse if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';\n\t\telse return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';\n\t}\n\n\t// --- Main execution ---\n\tconst totalBytes = roughSizeOfObject(obj);\n\tvisited.clear(); // Clean up the visited set\n\treturn formatByteSize(totalBytes);\n}\n\n/**\n * A \"replacer\" for JSON.stringify()'ing with custom behavior,\n * allowing us to stringify special objects like BigInts, Maps and TypedArrays.\n * Use {@link parseReviver} to parse back.\n */\nfunction stringifyReplacer(key: string, value: any): any {\n\t// Stringify BigInts\n\tif (typeof value === 'bigint')\n\t\treturn {\n\t\t\t$$type: 'BigInt',\n\t\t\tvalue: value.toString(), // Convert BigInt to a string\n\t\t};\n\t// Stringify Maps\n\tif (value instanceof Map)\n\t\treturn {\n\t\t\t$$type: 'Map',\n\t\t\tvalue: [...value],\n\t\t};\n\t// Stringify Sets\n\tif (value instanceof Set)\n\t\treturn {\n\t\t\t$$type: 'Set',\n\t\t\tvalue: [...value], // Convert Set elements to an array\n\t\t};\n\t// Stringify TypedArrays\n\tfor (const [name, type] of Object.entries(FixedArrayInfo)) {\n\t\tif (value instanceof type)\n\t\t\treturn {\n\t\t\t\t$$type: name,\n\t\t\t\tvalue: [...value],\n\t\t\t};\n\t}\n\n\treturn value;\n}\n\n/** TypedArray constructors and their names. */\nconst FixedArrayInfo = {\n\tFloat32Array: Float32Array,\n\tFloat64Array: Float64Array,\n\n\tInt8Array: Int8Array,\n\tInt16Array: Int16Array,\n\tInt32Array: Int32Array,\n\n\tUint8Array: Uint8Array,\n\tUint16Array: Uint16Array,\n\tUint32Array: Uint32Array,\n} as const;\n\n/** Type representing any of the TypedArray constructor types listed in FixedArrayInfo. */\ntype FixedArrayConstructor = (typeof FixedArrayInfo)[keyof typeof FixedArrayInfo];\n\n/**\n * A \"reviver\" for JSON.parse()'ing that will convert back from the custom stringified format to the original objects.\n * This allows us to parse back the special objects like Maps and TypedArrays that were stringified using {@link stringifyReplacer}.\n */\nfunction parseReviver(key: string, value: any): any {\n\tif (typeof value === 'object' && value !== null) {\n\t\tif (value.$$type === 'BigInt') return BigInt(value.value); // Convert string back to BigInt\n\t\tif (value.$$type === 'Map') return new Map(value.value); // value.value should be an array of [key, value] pairs\n\t\tif (value.$$type === 'Set') return new Set(value.value); // value.value should be an array of elements\n\t\tif (value.$$type in FixedArrayInfo) {\n\t\t\tconst constructor: FixedArrayConstructor =\n\t\t\t\tFixedArrayInfo[value.$$type as keyof typeof FixedArrayInfo]; // Get the constructor\n\t\t\treturn new constructor(value.value); // value.value should be an array of numbers\n\t\t}\n\t}\n\treturn value;\n}\n\n/**\n * Ensures any type of object is JSON stringified. Strings are left unchanged.\n * If there's a provided error message, it will log any ocurred error.\n * @param input - The input to stringify.\n * @param [errorMessage] - If specified, then this message will be printed if an error occurs.\n * @returns - The JSON stringified input or the original string if input was a string. Or, if an error ocurred, 'Error: Input could not be JSON stringified'.\n */\nfunction ensureJSONString(input: any, errorMessage?: string): string {\n\tif (typeof input === 'string') return input;\n\ttry {\n\t\treturn JSON.stringify(input, stringifyReplacer);\n\t} catch (error) {\n\t\t// Handle cases where input cannot be stringified\n\t\tif (errorMessage) {\n\t\t\t// Print the error...\n\t\t\tconst errText = `${errorMessage}\\n${(error as Error).stack}`;\n\t\t\tconsole.log(errText);\n\t\t}\n\t\treturn 'Error: Input could not be JSON stringified';\n\t}\n}\n\nexport default {\n\tbinarySearch,\n\tdeepCopyObject,\n\tcopyFloat32Array,\n\taddElementToOrganizedArray,\n\tfindIndexOfPointInOrganizedArray,\n\tcopyPropertiesToObject,\n\tisEmpty,\n\tinvertObj,\n\testimateMemorySizeOf,\n\tstringifyReplacer,\n\tparseReviver,\n\tensureJSONString,\n};\n"
  },
  {
    "path": "src/shared/util/math/bimath.ts",
    "content": "// src/shared/util/math/bimath.ts\n\n/**\n * This module contains complex math functions\n * for working with bigints.\n */\n\n// Constants =========================================================\n\nconst ZERO: bigint = 0n;\nconst ONE: bigint = 1n;\n\n// Mathematical Operations ===========================================\n\n/**\n * Calculates the absolute value of a bigint\n * @param bigint - The BigInt\n * @returns The absolute value\n */\nfunction abs(bigint: bigint): bigint {\n\treturn bigint < ZERO ? -bigint : bigint;\n}\n\n/**\n * Estimates the number of base-10 digits in a bigint, excluding the sign.\n * Accurate most of the time. 100% of the time within 1 digit.\n * @param bigint - The BigInt to count digits for\n * @returns The number of base-10 digits (excluding sign)\n */\nfunction countDigits(bigint: bigint): number {\n\t// Make it positive for digit counting\n\tconst abs_bigint = abs(bigint);\n\t// Use bitLength for efficiency\n\tconst bitLen = bitLength_bisection(abs_bigint);\n\t// Convert bit length to decimal digits: log10(2^bitLen) = bitLen * log10(2)\n\t// Use Math.floor and add 1 for high accuracy, sacrificing exactness.\n\treturn Math.floor(bitLen * Math.log10(2)) + 1;\n}\n\n// Big Length Algorithms =============================================================\n\n// Global state for the bisection algorithm so it's not re-computed every call\nconst testersCoeff: number[] = [];\nconst testersBigCoeff: bigint[] = [];\nconst testers: bigint[] = [];\nlet testersN = 0;\n\n/**\n * Calculates the bit length of a bigint using a highly optimized dynamic bisection algorithm.\n * Complexity O(log n), where n is the number of bits.\n * Algorithm pulled from https://stackoverflow.com/a/76616288\n */\nfunction bitLength_bisection(x: bigint): number {\n\tif (x === ZERO) return 0;\n\tif (x < ZERO) x = -x;\n\n\tlet k = 0;\n\twhile (true) {\n\t\tif (testersN === k) {\n\t\t\ttestersCoeff.push(32 << testersN);\n\t\t\ttestersBigCoeff.push(BigInt(testersCoeff[testersN]!));\n\t\t\ttesters.push(1n << testersBigCoeff[testersN]!);\n\t\t\ttestersN++;\n\t\t}\n\t\tif (x < testers[k]!) break;\n\t\tk++;\n\t}\n\n\tif (!k) return 32 - Math.clz32(Number(x));\n\n\t// Determine length by bisection\n\tk--;\n\tlet i = testersCoeff[k]!;\n\tlet a = x >> testersBigCoeff[k]!;\n\twhile (k--) {\n\t\tconst b = a >> testersBigCoeff[k]!;\n\t\tif (b) {\n\t\t\ti += testersCoeff[k]!;\n\t\t\ta = b;\n\t\t}\n\t}\n\n\treturn i + 32 - Math.clz32(Number(a));\n}\n\n/**\n * Estimate the memory footprint of a BigInt in bytes, assuming a 64‑bit JavaScript engine\n * (e.g. V8 in Chrome/Node.js or JavaScriptCore in Safari).\n *\n * On a 64‑bit build, each BigInt is represented as a small heap object:\n * - Two pointer‑sized fields (object header)\n * - A sequence of 64‑bit “words” holding the integer’s bits, rounded up\n *\n * Total size = headerBytes + dataBytes\n * @param bi - The BigInt to measure.\n * @returns The estimated number of bytes occupied by the bigint in memory.\n */\nfunction estimateBigIntSize(bi: bigint): number {\n\t// Compute bit length (number of binary digits)\n\tconst bitLen = bitLength_bisection(bi);\n\n\t// In a 64‑bit engine, pointerSize = 8 bytes\n\tconst pointerSize = 8;\n\t// Two pointers for the BigInt object header\n\tconst headerBytes = pointerSize * 2;\n\n\t// Number of 64‑bit chunks needed to store the bits\n\tconst chunkCount = Math.ceil(bitLen / (pointerSize * 8));\n\tconst dataBytes = pointerSize * chunkCount;\n\n\treturn headerBytes + dataBytes;\n}\n\n// /**\n//  * Performs integer division of two BigInts, rounding up towards positive infinity.\n//  * @param a - The dividend.\n//  * @param b - The divisor (must be a positive BigInt).\n//  * @returns The result of the division, rounded up.\n//  */\n// function roundUpDiv(a: bigint, b: bigint): bigint {\n// \treturn a / b + ((a % b) * b > ZERO ? ONE : ZERO);\n// }\n\n/**\n * Computes the positive modulus of two BigInts.\n * @param a - The dividend.\n * @param b - The divisor (must be a positive BigInt).\n * @returns The positive remainder of the division as a BigInt.\n */\nfunction posMod(a: bigint, b: bigint): bigint {\n\treturn ((a % b) + b) % b;\n}\n\n/** Finds the smaller of two BigInts. */\nfunction min(a: bigint, b: bigint): bigint {\n\treturn a < b ? a : b;\n}\n\n/** Finds the larger of two BigInts. */\nfunction max(a: bigint, b: bigint): bigint {\n\treturn a > b ? a : b;\n}\n\n/**\n * Compares two BigInts.\n * @param a The first BigInt.\n * @param b The second BigInt.\n * @returns -1 if a < b, 0 if a === b, and 1 if a > b.\n */\nfunction compare(a: bigint, b: bigint): -1 | 0 | 1 {\n\treturn a < b ? -1 : a > b ? 1 : 0;\n}\n\n/** Clamps a BigInt value between an inclusive minimum and maximum. */\nfunction clamp(value: bigint, min: bigint, max: bigint): bigint {\n\treturn value < min ? min : value > max ? max : value;\n}\n\n// Number-Theoretic Algorithms -----------------------------------------------------------------------------------------------\n\n/**\n * Calculates the gcd of two bigints using the binary GCD (or Stein's) algorithm.\n * This is faster than the Euclidean algorithm, especially for very large numbers.\n */\nfunction GCD(a: bigint, b: bigint): bigint {\n\t// We must work with positive numbers\n\ta = abs(a);\n\tb = abs(b);\n\n\tif (a === b) return a;\n\tif (a === ZERO) return b;\n\tif (b === ZERO) return a;\n\n\t// Strip out any shared factors of two beforehand (to be re-added at the end)\n\tlet sharedTwoFactors = ZERO;\n\twhile (!((a & ONE) | (b & ONE))) {\n\t\tsharedTwoFactors++;\n\t\ta >>= ONE;\n\t\tb >>= ONE;\n\t}\n\n\twhile (a !== b && b > ONE) {\n\t\t// Any remaining factors of two in either number are not important to the gcd and can be shifted away\n\t\twhile (!(a & ONE)) a >>= ONE;\n\t\twhile (!(b & ONE)) b >>= ONE;\n\n\t\t// Standard Euclidean algorithm, maintaining a > b and avoiding division\n\t\tif (b > a) [a, b] = [b, a];\n\t\telse if (a === b) break;\n\n\t\ta -= b;\n\t}\n\n\t// b is the gcd, after re-applying the shared factors of 2 removed earlier\n\treturn b << sharedTwoFactors;\n}\n\n// /**\n//  * Calculates the least common multiple (LCM) between all BigInts in an array.\n//  * @param array An array of BigInts.\n//  * @returns The LCM of the numbers in the array.\n//  */\n// function LCM(array: bigint[]): bigint {\n// \tif (array.length === 0)\n// \t\tthrow new Error('Array must contain at least one number to calculate the LCM.');\n\n// \tlet answer: bigint = array[0]!;\n// \tfor (let i = 1; i < array.length; i++) {\n// \t\tconst currentNumber = array[i]!;\n\n// \t\tif (currentNumber === ZERO || answer === ZERO) answer = ZERO;\n// \t\telse answer = abs(currentNumber * answer) / GCD(currentNumber, answer);\n// \t}\n\n// \treturn answer;\n// }\n\n// Displat Formatting -------------------------------------------------------------------------\n\n/**\n * Formats a bigint in scientific notation with the given number of significant figures.\n * e.g., formatBigIntExponential(123456789n, 3) => \"1.23e8\"\n */\nfunction formatBigIntExponential(bigint: bigint, precision: number): string {\n\tconst isNegative = bigint < 0n;\n\tconst absString: string = abs(bigint).toString();\n\n\tconst exponent: number = absString.length - 1;\n\tconst mantissaDigits: string = absString.substring(0, precision);\n\n\tlet mantissa: string;\n\tif (mantissaDigits.length > 1) {\n\t\tmantissa = mantissaDigits[0] + '.' + mantissaDigits.substring(1);\n\t} else {\n\t\tmantissa = mantissaDigits;\n\t}\n\n\treturn `${isNegative ? '-' : ''}${mantissa}e${exponent}`;\n}\n\n// Exports ============================================================\n\nexport default {\n\t// Mathematical Operations\n\tabs,\n\tcountDigits,\n\tbitLength_bisection,\n\t// Big Length Algorithms\n\testimateBigIntSize,\n\t// roundUpDiv,\n\tposMod,\n\tmin,\n\tmax,\n\tcompare,\n\tclamp,\n\t// Number-Theoretic Algorithms\n\tGCD,\n\t// Display Formatting\n\tformatBigIntExponential,\n};\n"
  },
  {
    "path": "src/shared/util/math/bounds.ts",
    "content": "// src/shared/util/math/bounds.ts\n\n/**\n * This script contains methods for constructing and operating on bounding boxes.\n */\n\nimport type { BDCoords, Coords, DoubleCoords } from '../../chess/util/coordutil.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport bimath from './bimath.js';\n\n// Types -------------------------------------------------------------------------\n\n/** A arbitrarily large rectangle object with properties for the coordinates of its sides. */\ninterface BoundingBox {\n\t/** The x-coordinate of the left side of the box. */\n\tleft: bigint;\n\t/** The x-coordinate of the right side of the box. */\n\tright: bigint;\n\t/** The y-coordinate of the bottom side of the box. */\n\tbottom: bigint;\n\t/** The y-coordinate of the top side of the box. */\n\ttop: bigint;\n}\n\n/**\n * A {@link BoundingBox} that may be unbounded in one or more directions.\n * `null` is used as a placeholder for -infinity or infinity.\n */\ninterface UnboundedRectangle {\n\t/** The x-coordinate of the left side of the box. */\n\tleft: bigint | null;\n\t/** The x-coordinate of the right side of the box. */\n\tright: bigint | null;\n\t/** The y-coordinate of the bottom side of the box. */\n\tbottom: bigint | null;\n\t/** The y-coordinate of the top side of the box. */\n\ttop: bigint | null;\n}\n\n/** A rectangle object with properties for the coordinates of its sides, but using BigDecimal\n * instead of bigints for arbitrary deciaml precision. */\ninterface BoundingBoxBD {\n\t/** The x-coordinate of the left side of the box. */\n\tleft: BigDecimal;\n\t/** The x-coordinate of the right side of the box. */\n\tright: BigDecimal;\n\t/** The y-coordinate of the bottom side of the box. */\n\tbottom: BigDecimal;\n\t/** The y-coordinate of the top side of the box. */\n\ttop: BigDecimal;\n}\n\n/** A rectangle object with properties for the coordinates of its sides, but using numbers instead of bigints. */\ninterface DoubleBoundingBox {\n\t/** The x-coordinate of the left side of the box. */\n\tleft: number;\n\t/** The x-coordinate of the right side of the box. */\n\tright: number;\n\t/** The y-coordinate of the bottom side of the box. */\n\tbottom: number;\n\t/** The y-coordinate of the top side of the box. */\n\ttop: number;\n}\n\n// Constants -----------------------------------------\n\nconst TWO = bd.fromNumber(2.0);\n\n// Construction --------------------------------------------------------\n\n/**\n * Calculates the minimum bounding box that contains all the provided coordinates.\n */\nfunction getBoxFromCoordsList(coordsList: Coords[]): BoundingBox {\n\t// Initialize the bounding box using the first coordinate\n\tconst firstPiece = coordsList[0]!;\n\tconst box: BoundingBox = {\n\t\tleft: firstPiece[0],\n\t\tright: firstPiece[0],\n\t\tbottom: firstPiece[1],\n\t\ttop: firstPiece[1],\n\t};\n\n\t// Expands the bounding box to include every coordinate\n\tfor (const coord of coordsList) {\n\t\texpandBoxToContainSquare(box, coord);\n\t}\n\n\treturn box;\n}\n\nfunction castDoubleBoundingBoxToBigDecimal(box: DoubleBoundingBox): BoundingBoxBD {\n\treturn {\n\t\tleft: bd.fromNumber(box.left),\n\t\tright: bd.fromNumber(box.right),\n\t\tbottom: bd.fromNumber(box.bottom),\n\t\ttop: bd.fromNumber(box.top),\n\t};\n}\n\nfunction castBoundingBoxToBigDecimal(box: BoundingBox): BoundingBoxBD {\n\treturn {\n\t\tleft: bd.fromBigInt(box.left),\n\t\tright: bd.fromBigInt(box.right),\n\t\tbottom: bd.fromBigInt(box.bottom),\n\t\ttop: bd.fromBigInt(box.top),\n\t};\n}\n\n// function castBDBoundingBoxToBigint(box: BoundingBoxBD): BoundingBox {\n// \treturn {\n// \t\tleft: bd.toBigInt(box.left),\n// \t\tright: bd.toBigInt(box.right),\n// \t\tbottom: bd.toBigInt(box.bottom),\n// \t\ttop: bd.toBigInt(box.top)\n// \t};\n// }\n\n/**\n * Expands the bounding box to include the provided coordinates, if it doesn't already.\n * DESTRUCTIVE. Modifies the original box.\n */\nfunction expandBoxToContainSquare(box: BoundingBox, coord: Coords): void {\n\tif (coord[0] < box.left) box.left = coord[0];\n\telse if (coord[0] > box.right) box.right = coord[0];\n\tif (coord[1] < box.bottom) box.bottom = coord[1];\n\telse if (coord[1] > box.top) box.top = coord[1];\n}\n\nfunction expandBDBoxToContainSquare(box: BoundingBoxBD, coord: BDCoords): void {\n\tif (bd.compare(coord[0], box.left) < 0) box.left = coord[0];\n\telse if (bd.compare(coord[0], box.right) > 0) box.right = coord[0];\n\tif (bd.compare(coord[1], box.bottom) < 0) box.bottom = coord[1];\n\telse if (bd.compare(coord[1], box.top) > 0) box.top = coord[1];\n}\n\n/**\n * Returns the mimimum bounding box that contains both of the provided boxes.\n */\nfunction mergeBoundingBoxBDs(box1: BoundingBoxBD, box2: BoundingBoxBD): BoundingBoxBD {\n\treturn {\n\t\tleft: bd.min(box1.left, box2.left),\n\t\tright: bd.max(box1.right, box2.right),\n\t\tbottom: bd.min(box1.bottom, box2.bottom),\n\t\ttop: bd.max(box1.top, box2.top),\n\t};\n}\n\n/**\n * Returns the mimimum bounding box that contains both of the provided boxes.\n */\nfunction mergeBoundingBoxDoubles(box1: BoundingBox, box2: BoundingBox): BoundingBox {\n\treturn {\n\t\tleft: bimath.min(box1.left, box2.left),\n\t\tright: bimath.max(box1.right, box2.right),\n\t\tbottom: bimath.min(box1.bottom, box2.bottom),\n\t\ttop: bimath.max(box1.top, box2.top),\n\t};\n}\n\n/**\n * Translates a bounding box by the given coordinates.\n * Non-mutating.\n */\nfunction translateBoundingBox(box: BoundingBox, translation: Coords): BoundingBox {\n\treturn {\n\t\tleft: box.left + translation[0],\n\t\tright: box.right + translation[0],\n\t\tbottom: box.bottom + translation[1],\n\t\ttop: box.top + translation[1],\n\t};\n}\n\n/**\n * Returns a new {@link DoubleBoundingBox} with each edge clamped so it\n * does not extend beyond the corresponding edge of `clampTo`.\n * Non-mutating.\n */\nfunction clampDoubleBoundingBox(\n\tbox: DoubleBoundingBox,\n\tclampTo: DoubleBoundingBox,\n): DoubleBoundingBox {\n\treturn {\n\t\tleft: Math.max(box.left, clampTo.left),\n\t\tright: Math.min(box.right, clampTo.right),\n\t\tbottom: Math.max(box.bottom, clampTo.bottom),\n\t\ttop: Math.min(box.top, clampTo.top),\n\t};\n}\n\n// Operations -----------------------------------------------------------------------\n\n/**\n * Determines if one bounding box (`innerBox`) is entirely contained within another bounding box (`outerBox`).\n * No overlaps allowed, but edges can touch.\n */\nfunction boxContainsBox(\n\touterBox: BoundingBox | UnboundedRectangle,\n\tinnerBox: BoundingBox,\n): boolean {\n\tif (outerBox.left !== null && innerBox.left < outerBox.left) return false;\n\tif (outerBox.right !== null && innerBox.right > outerBox.right) return false;\n\tif (outerBox.bottom !== null && innerBox.bottom < outerBox.bottom) return false;\n\tif (outerBox.top !== null && innerBox.top > outerBox.top) return false;\n\n\treturn true;\n}\n\n/**\n * Determines if one bounding box (`innerBox`) is entirely contained within another bounding box (`outerBox`).\n * No overlaps allowed, but edges can touch.\n */\nfunction boxContainsBoxBD(outerBox: BoundingBoxBD, innerBox: BoundingBoxBD): boolean {\n\tif (bd.compare(innerBox.left, outerBox.left) < 0) return false;\n\tif (bd.compare(innerBox.right, outerBox.right) > 0) return false;\n\tif (bd.compare(innerBox.bottom, outerBox.bottom) < 0) return false;\n\tif (bd.compare(innerBox.top, outerBox.top) > 0) return false;\n\n\treturn true;\n}\n\n/**\n * Determines if two bounding boxes have zero overlap.\n * They are allowed to touch sides without overlapping.\n */\nfunction areBoxesDisjoint(box1: DoubleBoundingBox, box2: DoubleBoundingBox): boolean {\n\tif (box1.right <= box2.left) return true;\n\tif (box1.left >= box2.right) return true;\n\tif (box1.top <= box2.bottom) return true;\n\tif (box1.bottom >= box2.top) return true;\n\n\treturn false;\n}\n\n/**\n * Returns true if the provided box contains the square coordinate.\n */\nfunction boxContainsSquare(box: BoundingBox | UnboundedRectangle, square: Coords): boolean {\n\tif (box.left !== null && square[0] < box.left) return false;\n\tif (box.right !== null && square[0] > box.right) return false;\n\tif (box.bottom !== null && square[1] < box.bottom) return false;\n\tif (box.top !== null && square[1] > box.top) return false;\n\n\treturn true;\n}\n\n/**\n * Returns true if the provided bigdecimal box contains the square coordinate.\n */\nfunction boxContainsSquareBD(box: BoundingBoxBD, square: BDCoords): boolean {\n\tif (bd.compare(square[0], box.left) < 0) return false;\n\tif (bd.compare(square[0], box.right) > 0) return false;\n\tif (bd.compare(square[1], box.bottom) < 0) return false;\n\tif (bd.compare(square[1], box.top) > 0) return false;\n\n\treturn true;\n}\n\n/**\n * Returns true if the provided double box contains the square coordinate.\n */\nfunction boxContainsSquareDouble(box: DoubleBoundingBox, square: DoubleCoords): boolean {\n\tif (square[0] < box.left) return false;\n\tif (square[0] > box.right) return false;\n\tif (square[1] < box.bottom) return false;\n\tif (square[1] > box.top) return false;\n\n\treturn true;\n}\n\n/**\n * Calculates the center of a bounding box.\n */\nfunction calcCenterOfBoundingBox(box: BoundingBoxBD): BDCoords {\n\tconst xSum = bd.add(box.left, box.right);\n\tconst ySum = bd.add(box.bottom, box.top);\n\treturn [bd.divide(xSum, TWO), bd.divide(ySum, TWO)];\n}\n\n// Debugging --------------------------------------------------------\n\n/** [DEBUG] Prints a box of BigDecimal floating point edges, with their exact representations. SLOW. */\nfunction printBDBox(box: BoundingBoxBD): void {\n\t// console.log(`Box: left=${bd.toNumber(box.left)}, right=${bd.toNumber(box.right)}, bottom=${bd.toNumber(box.bottom)}, top=${bd.toNumber(box.top)}`);\n\tconsole.log(\n\t\t`Box: left=${bd.toExactString(box.left)}, right=${bd.toExactString(box.right)}, bottom=${bd.toExactString(box.bottom)}, top=${bd.toExactString(box.top)}`,\n\t);\n}\n\n// Exports ----------------------------------------------------------\n\nexport default {\n\t// Construction\n\tgetBoxFromCoordsList,\n\tcastDoubleBoundingBoxToBigDecimal,\n\tcastBoundingBoxToBigDecimal,\n\t// castBDBoundingBoxToBigint,\n\texpandBoxToContainSquare,\n\texpandBDBoxToContainSquare,\n\tmergeBoundingBoxBDs,\n\tmergeBoundingBoxDoubles,\n\ttranslateBoundingBox,\n\tclampDoubleBoundingBox,\n\n\t// Operations\n\tboxContainsBox,\n\tboxContainsBoxBD,\n\tareBoxesDisjoint,\n\tboxContainsSquare,\n\tboxContainsSquareBD,\n\tboxContainsSquareDouble,\n\tcalcCenterOfBoundingBox,\n\n\t// Debugging\n\tprintBDBox,\n};\n\nexport type { BoundingBox, UnboundedRectangle, BoundingBoxBD, DoubleBoundingBox };\n"
  },
  {
    "path": "src/shared/util/math/geometry.ts",
    "content": "// src/shared/util/math/geometry.ts\n\n/**\n * This script contains methods for performing geometric calculations,\n * such as calculating intersections, and distances.\n */\n\nimport type { BoundingBox, BoundingBoxBD } from './bounds.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport bounds from './bounds.js';\nimport bimath from './bimath.js';\nimport bdcoords from '../../chess/util/bdcoords.js';\nimport coordutil, { BDCoords, Coords } from '../../chess/util/coordutil.js';\nimport vectors, { LineCoefficients, LineCoefficientsBD, Ray, Vec2 } from './vectors.js';\n\n// ================================ Type Definitions =================================\n\n/** The form of the intersection points returned by {@link findLineBoxIntersectionsBD}. */\ntype IntersectionPoint = {\n\t/** The actual intersection point */\n\tcoords: BDCoords;\n\t/**\n\t * True if the dot product of the direction vector and the vector to the intersection point is positive.\n\t * This tells us if the intersection is in the direction of the vector, or the opposite way.\n\t */\n\tpositiveDotProduct: boolean;\n};\n\n/** The simplest form of a ray. */\ntype BaseRay = { start: Coords; vector: Vec2 };\n\n// ======================================= Constants =======================================\n\nconst ZERO = bd.fromBigInt(0n);\n\n// ============================== Fundamental Intersection Functions ==============================\n\n/**\n * Finds the intersection of two lines in general form.\n * [x, y] or undefined if there is no intersection (or infinite intersections).\n *\n * PERFECT INTEGER PRECISION. If the intersection lies on a perfect integer point,\n * there will be no floating point innaccuracies.\n * If however the intersection lies on a non-integer point, and the BigDecimal\n * can't represent it perfectly in binary, there will be floating point innaccuracy.\n */\nfunction calcIntersectionPointOfLines(\n\tA1: bigint,\n\tB1: bigint,\n\tC1: bigint,\n\tA2: bigint,\n\tB2: bigint,\n\tC2: bigint,\n): BDCoords | undefined {\n\tconst determinant = A1 * B2 - A2 * B1;\n\tif (determinant === 0n) return undefined; // Lines are parallel or identical\n\n\tconst determinantBD = bd.fromBigInt(determinant);\n\n\tfunction determineAxis(dividend: bigint): BigDecimal {\n\t\tconst dividendBD = bd.fromBigInt(dividend);\n\t\treturn bd.divide(dividendBD, determinantBD);\n\t}\n\n\t// Calculate the intersection point\n\tconst x = determineAxis(C2 * B1 - C1 * B2);\n\tconst y = determineAxis(A2 * C1 - A1 * C2);\n\n\treturn [x, y];\n}\n\n/**\n * {@link calcIntersectionPointOfLines}, but for BigDecimal lines (requiring decimal precision).\n */\nfunction calcIntersectionPointOfLinesBD(\n\tA1: BigDecimal,\n\tB1: BigDecimal,\n\tC1: BigDecimal,\n\tA2: BigDecimal,\n\tB2: BigDecimal,\n\tC2: BigDecimal,\n): BDCoords | undefined {\n\tconst determinant = bd.subtract(bd.multiply(A1, B2), bd.multiply(A2, B1));\n\tif (bd.areEqual(determinant, ZERO)) return undefined; // Lines are parallel or identical\n\n\tfunction determineAxis(dividend: BigDecimal): BigDecimal {\n\t\treturn bd.divide(dividend, determinant);\n\t}\n\n\t// Calculate the intersection point\n\tconst x = determineAxis(bd.subtract(bd.multiply(C2, B1), bd.multiply(C1, B2)));\n\tconst y = determineAxis(bd.subtract(bd.multiply(A2, C1), bd.multiply(A1, C2)));\n\n\treturn [x, y];\n}\n\n/**\n * Calculates the intersection point of a NON-VERTICAL line with a vertical one!\n */\nfunction intersectLineAndVerticalLine(A1: bigint, B1: bigint, C1: bigint, x: bigint): BDCoords {\n\t// The known coordinate is x, its coefficient is A1.\n\t// We are solving for y, its coefficient is B1.\n\tconst intersectionY = solveForUnknownAxis(A1, B1, C1, x);\n\tconst intersectionX = bd.fromBigInt(x);\n\n\treturn [intersectionX, intersectionY];\n}\n\n/**\n * {@link intersectLineAndVerticalLine}, but for BigDecimal coefficients and known value.\n */\nfunction intersectLineAndVerticalLineBD(\n\tA1: BigDecimal,\n\tB1: BigDecimal,\n\tC1: BigDecimal,\n\tx: BigDecimal,\n): BDCoords {\n\t// The known coordinate is x, its coefficient is A1.\n\t// We are solving for y, its coefficient is B1.\n\tconst intersectionY = solveForUnknownAxisBD(A1, B1, C1, x);\n\tconst intersectionX = x;\n\n\treturn [intersectionX, intersectionY];\n}\n\n/**\n * Calculates the intersection point of a NON-HORIZONTAL line with a horizontal one!\n *\n */\nfunction intersectLineAndHorizontalLine(A1: bigint, B1: bigint, C1: bigint, y: bigint): BDCoords {\n\t// The known coordinate is y, its coefficient is B1.\n\t// We are solving for x, its coefficient is A1.\n\tconst intersectionX = solveForUnknownAxis(B1, A1, C1, y);\n\tconst intersectionY = bd.fromBigInt(y);\n\n\treturn [intersectionX, intersectionY];\n}\n\n/**\n * {@link intersectLineAndHorizontalLine}, but for BigDecimal coefficients and known value.\n */\nfunction intersectLineAndHorizontalLineBD(\n\tA1: BigDecimal,\n\tB1: BigDecimal,\n\tC1: BigDecimal,\n\ty: BigDecimal,\n): BDCoords {\n\t// The known coordinate is y, its coefficient is B1.\n\t// We are solving for x, its coefficient is A1.\n\tconst intersectionX = solveForUnknownAxisBD(B1, A1, C1, y);\n\tconst intersectionY = y;\n\n\treturn [intersectionX, intersectionY];\n}\n\n/**\n * [Helper] Solves for one coordinate of a line (Ax + By + C = 0) when the other is known.\n * Generalizes the formula: unknown = -(knownCoeff * knownVal + C) / unknownCoeff\n * @param knownAxisCoeff - The coefficient (A or B) corresponding to the known coordinate.\n * @param unknownAxisCoeff - The coefficient (A or B) for the coordinate we are solving for.\n * @param C - The C coefficient of the line.\n * @param knownValue - The value of the known coordinate (e.g., the 'x' of a vertical line).\n * @returns The calculated value of the unknown coordinate as a BigDecimal.\n */\nfunction solveForUnknownAxis(\n\tknownAxisCoeff: bigint,\n\tunknownAxisCoeff: bigint,\n\tC: bigint,\n\tknownValue: bigint,\n): BigDecimal {\n\t// This should not happen if the \"non-vertical\" or \"non-horizontal\" constraints are met.\n\tif (unknownAxisCoeff === 0n)\n\t\tthrow new Error('Cannot solve for axis, as the divisor (unknownAxisCoeff) is zero.');\n\n\t// Calculate the numerator using perfect BigInt arithmetic.\n\tconst numerator = -(knownAxisCoeff * knownValue + C);\n\n\t// Convert to BigDecimal and perform the single, final division.\n\treturn bd.divide(bd.fromBigInt(numerator), bd.fromBigInt(unknownAxisCoeff));\n}\n\n/**\n * {@link solveForUnknownAxis}, but for BigDecimal coefficients and known value.\n */\nfunction solveForUnknownAxisBD(\n\tknownAxisCoeff: BigDecimal,\n\tunknownAxisCoeff: BigDecimal,\n\tC: BigDecimal,\n\tknownValue: BigDecimal,\n): BigDecimal {\n\t// This should not happen if the \"non-vertical\" or \"non-horizontal\" constraints are met.\n\tif (bd.areEqual(unknownAxisCoeff, ZERO))\n\t\tthrow new Error('Cannot solve for axis, as the divisor (unknownAxisCoeff) is zero.');\n\n\t// Calculate the numerator\n\tconst numerator = bd.negate(bd.add(bd.multiply(knownAxisCoeff, knownValue), C));\n\n\t// Perform the single, final division.\n\treturn bd.divide(numerator, unknownAxisCoeff);\n}\n\n// ================================= Composite Geometric Operations =================================\n\n/**\n * Calculates the intersection point of two line SEGMENTS (not rays or infinite lines).\n * Returns undefined if there is none, or there's infinite (colinear).\n *\n * THE REASON WE TAKE THE COEFFICIENTS as arguments instead of calculating them\n * on the fly, is because the start and end segment points MAY HAVE FLOATING POINT IMPRECISION,\n * which would bleed into coefficient imprecision, thus imprecise intersection points.\n * By accepting the coefficients as arguments, they retain maximum precision.\n * @param line1Coefficients Coefficients [A,B,C] of segment 1's infinite line\n * @param s1p1 Start point of segment 1\n * @param s1p2 End point of segment 1\n * @param line2Coefficients Coefficients [A,B,C] of segment 2's infinite line\n * @param s2p1 Start point of segment 2\n * @returns The intersection Coords if they intersect, otherwise undefined.\n */\nfunction intersectLineSegments(\n\tline1Coefficients: LineCoefficients,\n\ts1p1: BDCoords,\n\ts1p2: BDCoords,\n\tline2Coefficients: LineCoefficients,\n\ts2p1: BDCoords,\n\ts2p2: BDCoords,\n): BDCoords | undefined {\n\t// 1. Calculate intersection of the infinite lines\n\tconst intersectionPoint: BDCoords | undefined = calcIntersectionPointOfLines(\n\t\t...line1Coefficients,\n\t\t...line2Coefficients,\n\t);\n\n\tif (!intersectionPoint) return undefined; // Lines are parallel or collinear.\n\n\t// 2. Check if the intersection point lies on both segments\n\tif (\n\t\tisPointOnSegment(intersectionPoint, s1p1, s1p2) &&\n\t\tisPointOnSegment(intersectionPoint, s2p1, s2p2)\n\t)\n\t\treturn intersectionPoint;\n\n\treturn undefined; // Intersection point is not on one or both segments\n}\n\n/**\n * Calculates the intersection point of an infinite line (in general form) and a line segment.\n * Returns undefined if there is no intersection, the intersection point lies\n * outside the segment, or if the line and segment are collinear/parallel.\n * @param lineCoefficients The coefficients [A,B,C] of the infinite line.\n * @param segmentCoefficients The coefficients [A,B,C] of the line containing the segment.\n * @param segP1 Start point of the segment\n * @param segP2 End point of the segment\n * @returns The intersection Coords if they intersect ON the segment, otherwise undefined.\n */\nfunction intersectLineAndSegment(\n\tlineCoefficients: LineCoefficientsBD,\n\tsegmentCoefficients: LineCoefficients,\n\tsegP1: BDCoords,\n\tsegP2: BDCoords,\n): BDCoords | undefined {\n\t// 1. Convert the segment coefficients to BigDecimal\n\tconst segmentCoefficientsBD = vectors.convertCoeficcientsToBD(segmentCoefficients);\n\n\t// 2. Calculate intersection of the two infinite lines\n\t// Uses the provided function calcIntersectionPointOfLines\n\tconst intersectionPoint = calcIntersectionPointOfLinesBD(\n\t\t...lineCoefficients,\n\t\t...segmentCoefficientsBD,\n\t);\n\n\t// 3. Handle no intersection (parallel) or collinear lines.\n\t// calcIntersectionPointOfLines returns undefined if determinant is 0.\n\tif (intersectionPoint === undefined) return undefined;\n\n\t// 4. Check if the calculated intersection point lies on the actual segment\n\t// The point is guaranteed to be collinear with the segment if an intersection was found.\n\tif (isPointOnSegment(intersectionPoint, segP1, segP2)) return intersectionPoint; // Intersection point is within the segment bounds\n\n\t// 5. The intersection point exists but is outside the segment bounds\n\treturn undefined;\n}\n\n/**\n * Calculates the intersection point of an infinite ray and a line segment.\n * Returns undefined if there is no intersection, the intersection point lies\n * outside the segment, the intersection point lies \"behind\" the ray's start,\n * or if the ray's line and segment's line are collinear/parallel without a\n * valid single intersection point on both.\n * @param ray The ray, defined by a starting point and a direction vector.\n * @param segP1 Start point of the segment. PERFECT integer.\n * @param segP2 End point of the segment. PERFECT integer.\n * @returns The intersection Coords if they intersect ON the segment and ON the ray, otherwise undefined.\n */\nfunction intersectRayAndSegment(ray: Ray, segP1: Coords, segP2: Coords): BDCoords | undefined {\n\t// 1. Get general form for the infinite line containing the segment.\n\t// PERFECT integers => No floating point imprecision.\n\tconst segmentCoeffs = vectors.getLineGeneralFormFrom2Coords(segP1, segP2);\n\n\t// 2. Calculate intersection of the two infinite lines.\n\tconst intersectionPoint = calcIntersectionPointOfLines(...ray.line, ...segmentCoeffs);\n\n\t// 3. Handle no unique intersection (parallel or collinear lines).\n\t// Be sure to capture the case if the ray starts at one of the segment's endpoints.\n\tif (!intersectionPoint) {\n\t\t// First check if the ray's start lies on the start/end poit of the segment.\n\t\tconst rayStartIsP1 = coordutil.areCoordsEqual(ray.start, segP1);\n\t\tconst rayStartIsP2 = coordutil.areCoordsEqual(ray.start, segP2);\n\t\tif (rayStartIsP1 || rayStartIsP2) {\n\t\t\t// Collinear, and ray starts at one of the segment's endpoints\n\t\t\t// This means the lines must be collinear, so we need to check if\n\t\t\t// the ray's direction vector points away from the segment's opposite end (1 intersection),\n\t\t\t// because if it pointed towards the segment's opposite end, it would have infinite intersections.\n\t\t\tif (rayStartIsP1) return getCollinearIntersection(segP2);\n\t\t\telse if (rayStartIsP2) return getCollinearIntersection(segP1);\n\t\t}\n\t\treturn undefined; // Parallel, not collinear, zero intersections.\n\t}\n\n\tfunction getCollinearIntersection(oppositePoint: Coords): BDCoords | undefined {\n\t\tconst vectorToOppositePoint = vectors.calculateVectorFromPoints(ray.start, oppositePoint);\n\t\tconst dotProd = vectors.dotProduct(ray.vector, vectorToOppositePoint);\n\t\tif (dotProd > 0)\n\t\t\treturn undefined; // The ray points towards the opposite end of the segment, so no unique intersection.\n\t\telse return bdcoords.FromCoords(ray.start); // The intersection point is the ray's start.\n\t}\n\n\t// 4. Check if the calculated intersection point lies on the actual segment.\n\tif (\n\t\t!isPointOnSegment(intersectionPoint, bdcoords.FromCoords(segP1), bdcoords.FromCoords(segP2))\n\t)\n\t\treturn undefined; // Intersection point is not within the segment bounds.\n\n\t// 5. Check if the intersection point lies on the ray (not \"behind\" its start).\n\t// Calculate vector from ray start to intersection.\n\tconst rayStartBD = bdcoords.FromCoords(ray.start);\n\tconst vectorToIntersection = vectors.calculateVectorFromBDPoints(rayStartBD, intersectionPoint);\n\n\t// Calculate dot product of ray's direction vector and the vector to the intersection.\n\tconst rayVecBD = bdcoords.FromCoords(ray.vector);\n\tconst dotProd = vectors.dotProductBD(rayVecBD, vectorToIntersection);\n\n\tif (bd.compare(dotProd, ZERO) < 0) return undefined; // Dot product is negative, meaning the intersection point is behind the ray's start.\n\n\t// 6. If all checks pass, the intersection point is valid for both ray and segment.\n\treturn intersectionPoint;\n}\n\n/**\n * Calculates the intersection point of two rays.\n * Returns the intersection coordinates if the rays intersect at a single point\n * that lies on both rays (i.e., not \"behind\" the starting point of either ray).\n * Returns undefined if they are parallel, collinear (resulting in no unique\n * intersection or infinite intersections), or if the intersection point of\n * their containing lines falls outside of one or both rays.\n *\n * @param ray1 The first ray.\n * @param ray2 The second ray.\n * @returns The intersection Coords if they intersect on both rays, otherwise undefined.\n */\nfunction intersectRays(ray1: Ray, ray2: Ray): BDCoords | undefined {\n\t// 1. Calculate the intersection point of the infinite lines containing the rays.\n\tconst intersectionPoint = calcIntersectionPointOfLines(...ray1.line, ...ray2.line);\n\n\t// 2. If the lines are parallel or collinear, they don't have a unique intersection point.\n\t// calcIntersectionPointOfLines returns undefined in this case.\n\tif (!intersectionPoint) return undefined; // This covers parallel lines and collinear lines (infinite intersections or no intersection).\n\n\t// 3. Check if the intersection point lies on the first ray.\n\t// This is done by checking if the vector from the ray's start to the intersection point\n\t// points in the same general direction as the ray's own direction vector.\n\t// The dot product will be non-negative (>= 0) if this is true.\n\n\t// Vector from ray1's start to the intersection point\n\tconst vectorToIntersection1 = vectors.calculateVectorFromBDPoints(\n\t\tbdcoords.FromCoords(ray1.start),\n\t\tintersectionPoint,\n\t);\n\t// Dot product of ray1's direction vector and vectorToIntersection1\n\tconst dotProd1 = vectors.dotProductBD(bdcoords.FromCoords(ray1.vector), vectorToIntersection1);\n\n\tif (bd.compare(dotProd1, ZERO) < 0) return undefined; // The intersection point is \"behind\" the start of ray1.\n\n\t// 4. Check if the intersection point lies on the second ray (similarly).\n\tconst vectorToIntersection2 = vectors.calculateVectorFromBDPoints(\n\t\tbdcoords.FromCoords(ray2.start),\n\t\tintersectionPoint,\n\t);\n\tconst dotProd2 = vectors.dotProductBD(bdcoords.FromCoords(ray2.vector), vectorToIntersection2);\n\n\tif (bd.compare(dotProd2, ZERO) < 0) return undefined; // The intersection point is \"behind\" the start of ray2.\n\n\t// 5. If both checks pass, the intersection point is on both rays.\n\treturn intersectionPoint;\n}\n\n/**\n * Checks if a point lies on a given line segment.\n * ASSUMES THE POINT IS COLINEAR with the segment's endpoints if checking after finding an intersection of their lines.\n * @param point The point to check.\n * @param segStart The starting point of the segment.\n * @param segEnd The ending point of the segment.\n * @returns True if the point is on the segment, false otherwise.\n */\nfunction isPointOnSegment(point: BDCoords, segStart: BDCoords, segEnd: BDCoords): boolean {\n\tconst minSegX = bd.min(segStart[0], segEnd[0]);\n\tconst maxSegX = bd.max(segStart[0], segEnd[0]);\n\tconst minSegY = bd.min(segStart[1], segEnd[1]);\n\tconst maxSegY = bd.max(segStart[1], segEnd[1]);\n\n\t// Check if point is within the bounding box of the segment\n\tconst withinX = bd.compare(point[0], minSegX) >= 0 && bd.compare(point[0], maxSegX) <= 0;\n\tconst withinY = bd.compare(point[1], minSegY) >= 0 && bd.compare(point[1], maxSegY) <= 0;\n\n\treturn withinX && withinY;\n}\n\n// ============================== High-Level Algorithms ==============================\n\n/**\n * Returns the point on the line SEGMENT that is nearest to the given point.\n *\n * @param segP1 - The starting point of the line segment.\n * @param segP2 - The ending point of the line segment.\n * @param point - The point to find the nearest point on the line segment to.\n * @returns An object containing the properties `coords`, which is the closest point on the segment,\n *          and `distance` to that point.\n */\nfunction closestPointOnLineSegment(\n\tsegmentCoeffs: LineCoefficients,\n\tsegP1: BDCoords,\n\tsegP2: BDCoords,\n\tpoint: BDCoords,\n): { coords: BDCoords; distance: BigDecimal } {\n\tconst perpendicularCoeffs = vectors.getPerpendicularLine(segmentCoeffs, point);\n\n\t// Find the intersection of the perpendicular line with the line containing the segment.\n\tlet closestPoint: BDCoords | undefined = intersectLineAndSegment(\n\t\tperpendicularCoeffs,\n\t\tsegmentCoeffs,\n\t\tsegP1,\n\t\tsegP2,\n\t);\n\n\t// If the intersection is undefined, it means it lies outside the segment.\n\t// So we need to figure out which segment point its CLOSEST to.\n\tif (closestPoint === undefined) {\n\t\tconst distToP1 = vectors.chebyshevDistanceBD(point, segP1);\n\t\tconst distToP2 = vectors.chebyshevDistanceBD(point, segP2);\n\t\tif (bd.compare(distToP1, distToP2) < 0)\n\t\t\tclosestPoint = segP1; // p1 is closer\n\t\telse closestPoint = segP2; // p2 is closer\n\t}\n\n\t// Calculate the distance from the original point to the closest point on the segment.\n\tconst distance = vectors.euclideanDistanceBD(closestPoint, point);\n\n\treturn {\n\t\tcoords: closestPoint,\n\t\tdistance,\n\t};\n}\n\n/**\n * Finds the two corners of a bounding box that define its cross-sectional width\n * when viewed from the direction of a given vector.\n *\n * If the vector is vertical, then as if we were looking at the box from below,\n * we would return its left/right-most points.\n */\nfunction findCrossSectionalWidthPoints(vector: Vec2, boundingBox: BoundingBox): [Coords, Coords] {\n\tconst { left, right, bottom, top } = boundingBox;\n\n\t// The normal vector is perpendicular to the viewing vector.\n\t// We can use this to find the points that are furthest apart on this line.\n\tconst normal: Vec2 = vectors.getPerpendicularVector(vector);\n\n\tconst corners: Coords[] = [\n\t\t[left, top], // Top-left\n\t\t[right, top], // Top-right\n\t\t[left, bottom], // Bottom-left\n\t\t[right, bottom], // Bottom-right\n\t];\n\n\t// Initialize min/max with the projection of the first corner\n\tlet minCorner: Coords = corners[0]!;\n\tlet maxCorner: Coords = corners[0]!;\n\n\tlet minProjection: bigint = vectors.dotProduct(minCorner, normal);\n\tlet maxProjection: bigint = minProjection;\n\n\t// Iterate through the rest of the corners (from the second one)\n\tfor (const corner of corners) {\n\t\t// Project the corner onto the NORMAL vector using the dot product\n\t\tconst projection = vectors.dotProduct(corner, normal);\n\n\t\tif (bimath.compare(projection, minProjection) < 0) {\n\t\t\tminProjection = projection;\n\t\t\tminCorner = corner;\n\t\t}\n\t\tif (bimath.compare(projection, maxProjection) > 0) {\n\t\t\tmaxProjection = projection;\n\t\t\tmaxCorner = corner;\n\t\t}\n\t}\n\n\treturn [minCorner, maxCorner];\n}\n\n/**\n * Finds the intersection points of a line with a bounding box.\n * @param startCoords - The starting point of the line.\n * @param vector - The direction vector [dx, dy] of the line.\n * @param box - The bounding box to test if the line intersects.\n * @returns An array of intersection points as BDCoords, sorted by distance along the direction vector,\n * \t\t\talong with whether whether their dot product is positive (in the direction of the vector).\n */\nfunction findLineBoxIntersections(\n\tstartCoords: Coords,\n\tvector: Vec2,\n\tbox: BoundingBox,\n\tlog = false,\n): IntersectionPoint[] {\n\tif (log) {\n\t\tconsole.log('\\nFinding line box intersections for:');\n\t\tconsole.log('Coords:', startCoords);\n\t\tconsole.log('Vector:', vector);\n\t\tconsole.log('Box:', box);\n\t\tconsole.log('\\n');\n\t}\n\n\t// Cast the box to BigDecimals\n\tconst boxBD = bounds.castBoundingBoxToBigDecimal(box);\n\n\t// Determine the coefficients of the line in general form\n\tconst coeffs = vectors.getLineGeneralFormFromCoordsAndVec(startCoords, vector);\n\n\t// Normalize the start coords as if the vector is normalized to the first graph quadrant.\n\tconst startCoordsNorm = coordutil.copyCoords(startCoords);\n\tif (vector[0] < 0n) startCoordsNorm[0] = -startCoordsNorm[0];\n\tif (vector[1] < 0n) startCoordsNorm[1] = -startCoordsNorm[1];\n\tconst startCoordsSum = bd.fromBigInt(startCoordsNorm[0] + startCoordsNorm[1]);\n\n\treturn findLineBoxIntersectionsBDHelper(\n\t\tcoeffs,\n\t\tvector,\n\t\tstartCoordsSum,\n\t\tbox,\n\t\tboxBD,\n\t\tintersectLineAndVerticalLine,\n\t\tintersectLineAndHorizontalLine,\n\t\tlog,\n\t);\n}\n\n// Test cases\n\n// const testBox: BoundingBox = { left: -10n, right: 10n, bottom: -5n, top: 5n };\n// const testCoords: Coords = [0n, 0n];\n// const textVector: Vec2 = [1n, 0n];\n\n// findLineBoxIntersections(testCoords, textVector, testBox, true);\n\n/**\n * Finds the intersection points of a line with BigDecimal precision with a bounding box of BigDecimal precision.\n * Slightly slower than {@link findLineBoxIntersections}.\n * @param startCoords - The starting point of the line.\n * @param vector - The direction vector [dx, dy] of the line.\n * @param boxBD - The bounding box to test if the line intersects.\n * @returns An array of intersection points as BDCoords, sorted by distance along the direction vector,\n * \t\t\talong with whether whether their dot product is positive (in the direction of the vector).\n */\nfunction findLineBoxIntersectionsBD(\n\tstartCoords: BDCoords,\n\tvector: Vec2,\n\tboxBD: BoundingBoxBD,\n): IntersectionPoint[] {\n\t// Determine the coefficients of the line in general form\n\tconst coeffs = vectors.getLineGeneralFormFromCoordsAndVecBD(startCoords, vector);\n\n\t// Normalize the start coords as if the vector is normalized to the first graph quadrant.\n\tconst startCoordsNorm = normalizeIntersection(startCoords, vector);\n\tconst startCoordsSum = bd.add(startCoordsNorm[0], startCoordsNorm[1]);\n\n\treturn findLineBoxIntersectionsBDHelper(\n\t\tcoeffs,\n\t\tvector,\n\t\tstartCoordsSum,\n\t\tboxBD,\n\t\tboxBD,\n\t\tintersectLineAndVerticalLineBD,\n\t\tintersectLineAndHorizontalLineBD,\n\t);\n}\n\n/**\n * Helper for findLineBoxIntersections to normalize an intersection point,\n * as if the vector were normalized to the first graph quadrant.\n */\nfunction normalizeIntersection(intersection: BDCoords, vector: Vec2): BDCoords {\n\tconst normalizedIntersection = coordutil.copyBDCoords(intersection);\n\tif (vector[0] < 0n) normalizedIntersection[0] = bd.negate(normalizedIntersection[0]);\n\tif (vector[1] < 0n) normalizedIntersection[1] = bd.negate(normalizedIntersection[1]);\n\treturn normalizedIntersection;\n}\n\n/** [Helper] Shared logic for finding line-box intersections, whether the inputs are integers or BigDecimals. */\nfunction findLineBoxIntersectionsBDHelper<T extends bigint | BigDecimal>(\n\tcoeffs: [T, T, T],\n\tvector: Vec2,\n\tstartCoordsSum: BigDecimal,\n\tbox: { left: T; right: T; bottom: T; top: T },\n\tboxBD: BoundingBoxBD,\n\tvertIntectFunc: (_A1: T, _B1: T, _C1: T, _x: T) => BDCoords,\n\thorzIntsectFunc: (_A1: T, _B1: T, _C1: T, _y: T) => BDCoords,\n\tlog = false,\n): { coords: BDCoords; positiveDotProduct: boolean }[] {\n\t// Check for intersections with each of the four box edges\n\n\tconst intersections: BDCoords[] = [];\n\n\t// Check vertical edges (where x is constant: x = left or x = right)\n\tif (vector[0] !== 0n) {\n\t\t// A non-zero dx means the line is not vertical and can intersect vertical edges.\n\t\tconst intersectionLeft = vertIntectFunc(...coeffs, box.left);\n\t\tconst intersectionRight = vertIntectFunc(...coeffs, box.right);\n\n\t\t// Now check if the intersection points actually lie ON the segments of the edges.\n\t\tif (\n\t\t\tbd.compare(intersectionLeft[1], boxBD.bottom) >= 0 &&\n\t\t\tbd.compare(intersectionLeft[1], boxBD.top) <= 0\n\t\t)\n\t\t\tintersections.push(intersectionLeft); // Valid intersection on left edge\n\t\tif (\n\t\t\tbd.compare(intersectionRight[1], boxBD.bottom) >= 0 &&\n\t\t\tbd.compare(intersectionRight[1], boxBD.top) <= 0\n\t\t)\n\t\t\tintersections.push(intersectionRight); // Valid intersection on right edge\n\t}\n\n\t// Check horizontal edges (where y is constant: y = bottom or y = top)\n\tif (vector[1] !== 0n) {\n\t\t// A non-zero dy means the line is not horizontal and can intersect horizontal edges.\n\t\tconst intersectionBottom = horzIntsectFunc(...coeffs, box.bottom);\n\t\tconst intersectionTop = horzIntsectFunc(...coeffs, box.top);\n\n\t\t// Now check if the intersection points actually lie ON the segments of the edges.\n\t\tif (\n\t\t\tbd.compare(intersectionBottom[0], boxBD.left) >= 0 &&\n\t\t\tbd.compare(intersectionBottom[0], boxBD.right) <= 0\n\t\t)\n\t\t\tintersections.push(intersectionBottom); // Valid intersection on bottom edge\n\t\tif (\n\t\t\tbd.compare(intersectionTop[0], boxBD.left) >= 0 &&\n\t\t\tbd.compare(intersectionTop[0], boxBD.right) <= 0\n\t\t)\n\t\t\tintersections.push(intersectionTop); // Valid intersection on top edge\n\t}\n\n\t// 4. De-duplicate and Sort the valid intersection points\n\n\t// De-duplicate points\n\tconst unique_intersections = intersections.filter(\n\t\t(v, i, a) => a.findIndex((t) => coordutil.areBDCoordsEqual(v, t)) === i,\n\t);\n\n\tconst intersectionsWithPositiveDotProduct = unique_intersections.map((intersection) => {\n\t\t// Normalize the intersection as if the vector is normalized.\n\t\tconst norm = normalizeIntersection(intersection, vector);\n\n\t\tconst sum = bd.add(norm[0], norm[1]);\n\n\t\t// If the sum is greater than the startCoords sum, the dot product is positive.\n\t\tconst positiveDotProduct = bd.compare(sum, startCoordsSum) >= 0;\n\n\t\treturn {\n\t\t\tcoords: intersection,\n\t\t\tpositiveDotProduct,\n\t\t};\n\t});\n\n\t// Sort by distance along the direction vector\n\tintersectionsWithPositiveDotProduct.sort((a, b) => {\n\t\t// Normalize the intersection as if the vector is normalized.\n\t\tconst normA = normalizeIntersection(a.coords, vector);\n\t\tconst normB = normalizeIntersection(b.coords, vector);\n\n\t\tconst ASum = bd.add(normA[0], normA[1]);\n\t\tconst BSum = bd.add(normB[0], normB[1]);\n\n\t\t// Whichever is greater is further along the direction vector.\n\t\treturn bd.compare(ASum, BSum);\n\t});\n\n\tif (log) {\n\t\tfor (const i of intersectionsWithPositiveDotProduct) {\n\t\t\tconsole.log('Coordinates of intersection:', coordutil.stringifyBDCoords(i.coords));\n\t\t\tconsole.log('Positive dot product?', i.positiveDotProduct);\n\t\t}\n\t}\n\n\treturn intersectionsWithPositiveDotProduct;\n}\n\n// ============================== Miscellaneous Utilities ==============================\n\n/**\n * Rounds the given point to the nearest grid point multiple of the provided gridSize.\n *\n * For example, a point of [5200,1100] and gridSize of 10000 would yield [10000,0]\n */\nfunction roundPointToNearestGridpoint(point: BDCoords, gridSize: bigint): Coords {\n\t// point: [x,y]  gridSize is width of cells, typically 10,000\n\t// Incurs rounding, but honestly this doesn't need to be exact because it's for graphics.\n\tconst pointBigInt: Coords = bdcoords.coordsToBigInt(point);\n\n\t// To round bigints, we add half the gridSize before dividing by it.\n\tfunction roundBigintNearestMultiple(value: bigint, multiple: bigint): bigint {\n\t\tconst halfMultiple = multiple / 2n; // Assumes multiple is positive and divisible by 2.\n\n\t\t// For positives, add half and truncate.\n\t\tif (value >= 0n) return ((value + halfMultiple) / multiple) * multiple;\n\t\t// For negatives, subtract half and truncate.\n\t\telse return ((value - halfMultiple) / multiple) * multiple;\n\t}\n\n\tconst nearestX = roundBigintNearestMultiple(pointBigInt[0], gridSize);\n\tconst nearestY = roundBigintNearestMultiple(pointBigInt[1], gridSize);\n\n\treturn [nearestX, nearestY];\n}\n\n// ================================= Exports =================================\n\nexport default {\n\t// Fundamental Intersection Functions\n\tcalcIntersectionPointOfLines,\n\tcalcIntersectionPointOfLinesBD,\n\n\t// Composite Intersection Functions\n\tintersectLineSegments,\n\tintersectLineAndSegment,\n\tintersectRayAndSegment,\n\tintersectRays,\n\n\t// High-Level Algorithms\n\tclosestPointOnLineSegment,\n\tfindCrossSectionalWidthPoints,\n\tfindLineBoxIntersections,\n\tfindLineBoxIntersectionsBD,\n\n\t// Miscellaneous Utilities\n\troundPointToNearestGridpoint,\n};\n\nexport type { IntersectionPoint, BaseRay };\n"
  },
  {
    "path": "src/shared/util/math/math.ts",
    "content": "// src/shared/util/math/math.ts\n\n/**\n * This script contains extra general math operations.\n *\n * Most of the stuff in here were moved to either bounds.ts, vectors.ts, or geometry.ts.\n */\n\n// Types ------------------------------------------------------\n\n/** A color in a length-4 array: `[r,g,b,a]` */\ntype Color = [number, number, number, number];\n\n// Operations -----------------------------------------------------------\n\n/**\n * Clamps a value between a minimum and a maximum value.\n */\nfunction clamp(value: number, min: number, max: number): number {\n\treturn value < min ? min : value > max ? max : value;\n}\n\n/**\n * Computes the positive modulus of two numbers.\n * @param a - The dividend.\n * @param b - The divisor.\n * @returns The positive remainder of the division.\n */\nfunction posMod(a: number, b: number): number {\n\treturn a - Math.floor(a / b) * b;\n}\n\n// Easing Functions ---------------------------------------------------\n\n/**\n * Applies an ease-in-out interpolation.\n * @param t - The interpolation factor (0 to 1).\n */\nfunction easeInOut(t: number): number {\n\treturn -0.5 * Math.cos(Math.PI * t) + 0.5;\n}\n\n/**\n * Applies an ease-in interpolation.\n * @param t - The interpolation factor (0 to 1).\n */\nfunction easeIn(t: number): number {\n\treturn t * t;\n}\n\n/**\n * Applies an ease-out interpolation.\n * @param t - The interpolation factor (0 to 1).\n */\nfunction easeOut(t: number): number {\n\treturn t * (2 - t);\n}\n\n// Other -------------------------------------------------------------\n\n/** Returns a value smoothly oscillating between a min and max. */\nfunction getSineWaveVariation(time: number, min: number, max: number): number {\n\treturn min + (Math.sin(time) * 0.5 + 0.5) * (max - min);\n}\n\n// Exports -----------------------------------------------------\n\nexport default {\n\t// Operations\n\tclamp,\n\tposMod,\n\t// Easing Functions\n\teaseInOut,\n\teaseIn,\n\teaseOut,\n\t// Other\n\tgetSineWaveVariation,\n};\n\nexport type { Color };\n"
  },
  {
    "path": "src/shared/util/math/vectors.ts",
    "content": "// src/shared/util/math/vectors.ts\n\n/**\n * This script contains methods for performing vector calculations,\n * such as calculating angles, distances, and other operations.\n */\n\nimport type { BDCoords, Coords, DoubleCoords } from '../../chess/util/coordutil.js';\n\nimport bd, { BigDecimal } from '@naviary/bigdecimal';\n\nimport bimath from './bimath.js';\nimport bdcoords from '../../chess/util/bdcoords.js';\n\n// Types ----------------------------------------------------------------------\n\n/** A length-2 number array. Commonly used for storing directions. */\ntype Vec2 = [bigint, bigint];\n\n/**\n * A pair of x & y vectors, represented in a string, separated by a `,`.\n *\n * This is often used as the key for a slide direction in an object.\n */\ntype Vec2Key = `${bigint},${bigint}`;\n\n/** A length-3 number array. Commonly used for storing positional and scale transformations. */\ntype Vec3 = [number, number, number];\n\n/**\n * A maethematical ray, starting from a single point\n * and going out to infinity in one direction.\n */\ntype Ray = {\n\tstart: Coords;\n\tvector: Vec2;\n\n\t/** The line in general form (A, B, C coefficients) */\n\tline: LineCoefficients;\n};\n\n/**\n * Coefficients A, B, C, of a line in general form.\n * These can be bigints because all lines, rays, and segment\n * points inside the game are integers.\n */\ntype LineCoefficients = [bigint, bigint, bigint];\n\n/**\n * {@link LineCoefficients} but for BigDecimal lines (requiring decimal precision).\n */\ntype LineCoefficientsBD = [BigDecimal, BigDecimal, BigDecimal];\n\n// Constants ----------------------------------------------------------------------\n\n// prettier-ignore\n/** All positive/absolute orthogonal vectors. */\nconst VECTORS_ORTHOGONAL: Coords[] = [[1n,0n],[0n,1n]];\n// prettier-ignore\n/** All positive/absolute diagonal vectors. */\nconst VECTORS_DIAGONAL: Coords[] = [[1n,1n],[1n,-1n]];\n// prettier-ignore\n/** The positive/absolute knightrider hippogonals. */\nconst VECTORS_HIPPOGONAL: Coords[] = [[1n,2n],[1n,-2n],[2n,1n],[2n,-1n]];\n\n// Construction ----------------------------------------------------------------------\n\n/**\n * Returns the key string of the coordinates: [dx,dy] => 'dx,dy'\n */\nfunction getKeyFromVec2(vec2: Vec2): Vec2Key {\n\treturn `${vec2[0]},${vec2[1]}`;\n}\n\n/**\n * Returns the vector from the Vec2Key: 'dx,dy' => [dx,dy]\n */\nfunction getVec2FromKey(vec2Key: Vec2Key): Vec2 {\n\treturn vec2Key.split(',').map(BigInt) as Vec2;\n}\n\n/**\n * Converts a bigint vector to javascript doubles.\n */\nfunction convertVectorToDoubles(vec2: Vec2): DoubleCoords {\n\treturn [Number(vec2[0]), Number(vec2[1])];\n}\n\n/**\n * Calculates the general form coefficients (A, B, C) of a line given a point and a direction vector.\n */\nfunction getLineGeneralFormFromCoordsAndVec(coords: Coords, vector: Vec2): LineCoefficients {\n\t// General form: Ax + By + C = 0\n\tconst A = vector[1];\n\tconst B = -vector[0];\n\tconst C = vector[0] * coords[1] - vector[1] * coords[0];\n\n\treturn [A, B, C];\n}\n\n/**\n * {@link getLineGeneralFormFromCoordsAndVec} but for BigDecimal coordinates.\n */\nfunction getLineGeneralFormFromCoordsAndVecBD(coords: BDCoords, vector: Vec2): LineCoefficientsBD {\n\tconst vectorBD = bdcoords.FromCoords(vector);\n\n\t// General form: Ax + By + C = 0\n\tconst A: BigDecimal = bd.clone(vectorBD[1]);\n\tconst B: BigDecimal = bd.negate(vectorBD[0]);\n\t// vector[0] * coords[1] - vector[1] * coords[0]\n\tconst C: BigDecimal = bd.subtract(\n\t\tbd.multiply(vectorBD[0], coords[1]),\n\t\tbd.multiply(vectorBD[1], coords[0]),\n\t);\n\n\treturn [A, B, C];\n}\n\n/**\n * Calculates the general form of a line (Ax + By + C = 0) given two points on the line.\n * Handles both regular and vertical lines.\n */\nfunction getLineGeneralFormFrom2Coords(coords1: Coords, coords2: Coords): LineCoefficients {\n\t// Handle the case of a vertical line (infinite slope)\n\t// The line equation is x = x1, which in general form is: 1*x + 0*y - x1 = 0\n\tif (coords1[0] === coords2[0]) return [1n, 0n, -coords1[0]];\n\n\t// // Calculate the slope (m)\n\t// const m = (coords2[1] - coords1[1]) / (coords2[0] - coords1[0]);\n\n\t// // General form coefficients: A = m, B = -1, and C = y1 - m * x1\n\t// const A = m;\n\t// const B = -1n;\n\t// const C = coords1[1] - m * coords1[0];\n\n\t// To avoid division and floating-point/truncation issues, we use the cross-multiplication method.\n\t// The equation (y - y1)(x2 - x1) = (x - x1)(y2 - y1) is rearranged to Ax + By + C = 0.\n\tconst A = coords2[1] - coords1[1]; // y2 - y1\n\tconst B = coords1[0] - coords2[0]; // x1 - x2\n\tconst C = coords2[0] * coords1[1] - coords1[0] * coords2[1]; // x2*y1 - x1*y2\n\n\treturn [A, B, C];\n}\n\n// /**\n//  * {@link getLineGeneralFormFrom2Coords} but for BigDecimal coordinates.\n//  */\n// function getLineGeneralFormFrom2CoordsBD(coords1: BDCoords, coords2: BDCoords): LineCoefficientsBD {\n// \t// Handle the case of a vertical line (infinite slope)\n// \t// The line equation is x = x1, which in general form is: 1*x + 0*y - x1 = 0\n// \tif (bd.areEqual(coords1[0], coords2[0])) return [ONE, ZERO, bd.negate(coords1[0])];\n//\n// \t// To avoid division and floating-point/truncation issues, we use the cross-multiplication method.\n// \t// The equation (y - y1)(x2 - x1) = (x - x1)(y2 - y1) is rearranged to Ax + By + C = 0.\n// \tconst A = bd.subtract(coords2[1], coords1[1]); // y2 - y1\n// \tconst B = bd.subtract(coords1[0], coords2[0]); // x1 - x2\n// \tconst C = bd.subtract(bd.multiply(coords2[0], coords1[1]), bd.multiply(coords1[0], coords2[1])); // x2*y1 - x1*y2\n//\n// \treturn [A, B, C];\n// }\n\n/**\n * Upgrades bigint line coefficients [A, B, C] to BigDecimals.\n */\nfunction convertCoeficcientsToBD(line: LineCoefficients): LineCoefficientsBD {\n\treturn [bd.fromBigInt(line[0]), bd.fromBigInt(line[1]), bd.fromBigInt(line[2])];\n}\n\n/**\n * Calculates the vector between 2 points.\n */\nfunction calculateVectorFromPoints(start: Coords, end: Coords): Vec2 {\n\treturn [end[0] - start[0], end[1] - start[1]];\n}\n\n/**\n * Calculates the vector between 2 points.\n */\nfunction calculateVectorFromBDPoints(start: BDCoords, end: BDCoords): BDCoords {\n\treturn [bd.subtract(end[0], start[0]), bd.subtract(end[1], start[1])];\n}\n\n/**\n * Calculates the C coefficient of a line in general form (Ax + By + C = 0)\n * given a point (coords) and a direction vector (vector).\n *\n * Step size here is unimportant, but the slope **is**.\n * This value will be unique for every line that *has the same slope*, but different positions.\n */\nfunction getLineCFromCoordsAndVec(coords: Coords, vector: Vec2): bigint {\n\treturn vector[0] * coords[1] - vector[1] * coords[0];\n}\n\n// /**\n//  * {@link getLineCFromCoordsAndVec} but for BigDecimal coordinates.\n//  */\n// function getLineCFromCoordsAndVecBD(coords: BDCoords, vector: Vec2): BigDecimal {\n// \tconst vectorBD = bdcoords.FromCoords(vector);\n// \t// Coords first since they are likely higher precision.\n// \treturn bd.subtract(bd.multiply(coords[1], vectorBD[0]), bd.multiply(coords[0], vectorBD[1]));\n// }\n\n// Operations -----------------------------------------------------------------------------\n\n/**\n * Compares two lines in general form to see if they are equal/coincident.\n * Two lines are considered equal if their coefficients are proportional.\n * @param line1 - The first line in general form [A1, B1, C1]\n * @param line2 - The second line in general form [A2, B2, C2]\n * @returns true if the lines are equal, false otherwise\n */\nfunction areLinesInGeneralFormEqual(line1: LineCoefficients, line2: LineCoefficients): boolean {\n\tconst [A1, B1, C1] = line1;\n\tconst [A2, B2, C2] = line2;\n\n\t// Check if the ratios of the coefficients are equal (proportional)\n\t// Avoid division by zero by checking the relationship with multiplication\n\treturn A1 * B2 === A2 * B1 && A1 * C2 === A2 * C1 && B1 * C2 === B2 * C1;\n}\n\n/**\n * Calculates the X and Y components of a unit vector given an angle in radians.\n * @param theta - The angle in radians.\n * @returns A tuple containing the X and Y components, both between -1 and 1.\n */\nfunction getXYComponents_FromAngle(theta: number): DoubleCoords {\n\treturn [Math.cos(theta), Math.sin(theta)]; // When hypotenuse is 1.0\n}\n\n/**\n * Computes the dot product of two 2D vectors.\n * WILL BE POSITIVE if they roughly point in the same direction.\n */\nfunction dotProduct(v1: Vec2, v2: Vec2): bigint {\n\treturn v1[0] * v2[0] + v1[1] * v2[1];\n}\n\n/**\n * Computes the dot product of two 2D vectors.\n * WILL BE POSITIVE if they roughly point in the same direction.\n */\nfunction dotProductBD(v1: BDCoords, v2: BDCoords): BigDecimal {\n\treturn bd.add(bd.multiply(v1[0], v2[0]), bd.multiply(v1[1], v2[1]));\n}\n\n/**\n * Computes the dot product of two 2D vectors represented as doubles.\n * WILL BE POSITIVE if they roughly point in the same direction.\n */\nfunction dotProductDoubles(v1: DoubleCoords, v2: DoubleCoords): number {\n\treturn v1[0] * v2[0] + v1[1] * v2[1];\n}\n\n/**\n * Negates the provided length-2 vector so it points in the opposite direction\n *\n * Non-mutating. Returns a new vector.\n */\nfunction negateVector(vec2: Vec2): Vec2 {\n\treturn [-vec2[0], -vec2[1]];\n}\n\n/**\n * Negates the provided length-2 BigDecimal vector so it points in the opposite direction\n *\n * Non-mutating. Returns a new vector.\n */\nfunction negateBDVector(vec2: BDCoords): BDCoords {\n\treturn [bd.negate(vec2[0]), bd.negate(vec2[1])];\n}\n\n/**\n * Negates the provided length-2 double vector so it points in the opposite direction\n *\n * Non-mutating. Returns a new vector.\n */\nfunction negateDoubleVector(vec2: DoubleCoords): DoubleCoords {\n\treturn [-vec2[0], -vec2[1]];\n}\n\n/**\n * Returns the absolute value of the provided vector.\n * In the context of our game, positive vectors always point to the right,\n * and if they are vertical then they always point up.\n */\nfunction absVector(vec2: Vec2): Vec2 {\n\tif (vec2[0] < 0n || (vec2[0] === 0n && vec2[1] < 0n)) return negateVector(vec2);\n\telse return vec2;\n}\n\n/**\n * Normalizes a vector to its smallest possible integer components while preserving its direction.\n */\nfunction normalizeVector(vec2: Vec2): Vec2 {\n\t// Calculate the GCD of all the components in the vector.\n\tconst gcd = bimath.GCD(vec2[0], vec2[1]);\n\n\t// If the GCD is 0, it means all elements were 0\n\tif (gcd === 0n) return [0n, 0n];\n\n\t// Divide each component by the GCD to get the smallest integer representation.\n\treturn [vec2[0] / gcd, vec2[1] / gcd];\n}\n\n/**\n * Normalizes a floating point arbitrarily large vector into a range\n * near 0-1, small enough so it can be represented with javascript numbers.\n * PRESERVES the ratio between the x and y components.\n */\nfunction normalizeVectorBD(vec2: BDCoords): DoubleCoords {\n\t// Normalize it NEAR the range 0-1 (don't matter if it's not exact).\n\t// const targetLength = vectors.chebyshevDistanceBD(ZERO_COORDS, targetVector);\n\tconst targetLength = bd.max(bd.abs(vec2[0]), bd.abs(vec2[1]));\n\treturn [\n\t\tbd.toNumber(bd.divideFloating(vec2[0], targetLength)),\n\t\tbd.toNumber(bd.divideFloating(vec2[1], targetLength)),\n\t];\n}\n\n/**\n * Calculates the normal (perpendicular) vector of a given 2D vector.\n */\nfunction getPerpendicularVector(vec2: Vec2): Vec2 {\n\treturn [-vec2[1], vec2[0]];\n}\n\n/**\n * Calculates the line that is perpendicular to a given line and passes through a specific point.\n * @param lineCoeffs - The coefficients [A,B,C] of the original line.\n * @param point - The coordinates that the new perpendicular line must pass through.\n * @returns New BigDecimal coefficients for the perpendicular line.\n */\nfunction getPerpendicularLine(lineCoeffs: LineCoefficients, point: BDCoords): LineCoefficientsBD {\n\tconst lineCoeffsBD = convertCoeficcientsToBD(lineCoeffs);\n\tconst [A1, B1] = lineCoeffsBD;\n\n\t// Step 1: Determine the A and B coefficients for the new line (L2).\n\t// The normal vector for L2 is (-B1, A1).\n\tconst A2 = bd.negate(B1);\n\tconst B2 = A1;\n\n\t// Step 2: Solve for the C coefficient of the new line (L2).\n\t// The equation is A2*x + B2*y + C2 = 0.\n\t// We know it must pass through point (xp, yp), so we can solve for C2:\n\t// A2*xp + B2*yp + C2 = 0\n\t// C2 = -(A2*xp + B2*yp)\n\t// C2 = -((-B1)*xp + A1*yp)\n\t// C2 = B1*xp - A1*yp\n\tconst term1 = bd.multiply(B1, point[0]);\n\tconst term2 = bd.multiply(A1, point[1]);\n\tconst C2 = bd.subtract(term1, term2);\n\n\treturn [A2, B2, C2];\n}\n\n/**\n * Converts an angle in degrees to radians\n */\nfunction degreesToRadians(angleDegrees: number): number {\n\treturn angleDegrees * (Math.PI / 180);\n}\n\n// Distance Calculation ----------------------------------------------------------------------------\n\n/**\n * Returns the euclidean (hypotenuse) distance between 2 bigint points.\n */\nfunction euclideanDistance(point1: Coords, point2: Coords): BigDecimal {\n\tconst point1BD: BDCoords = bdcoords.FromCoords(point1);\n\tconst point2BD: BDCoords = bdcoords.FromCoords(point2);\n\treturn euclideanDistanceBD(point1BD, point2BD);\n}\n\n/**\n * Returns the euclidean (hypotenuse) distance between 2 BigDecimal points.\n */\nfunction euclideanDistanceBD(point1: BDCoords, point2: BDCoords): BigDecimal {\n\treturn bd.hypot(bd.subtract(point2[0], point1[0]), bd.subtract(point2[1], point1[1]));\n}\n\n/**\n * Returns the euclidean (hypotenuse) distance between 2 javascript double coordinates.\n */\nfunction euclideanDistanceDoubles(point1: DoubleCoords, point2: DoubleCoords): number {\n\treturn Math.hypot(point2[0] - point1[0], point2[1] - point1[1]);\n}\n\n/**\n * Returns the manhatten distance between 2 points.\n * This is the sum of the distances between the points' x distance and y distance.\n * This is often the distance of roads, because you can't move diagonally.\n */\nfunction manhattanDistance(point1: Coords, point2: Coords): bigint {\n\treturn bimath.abs(point2[0] - point1[0]) + bimath.abs(point2[1] - point1[1]);\n}\n\n// function manhattanDistanceBD(point1: BDCoords, point2: BDCoords): BigDecimal {\n// \treturn bd.add(bd.abs(bd.subtract(point2[0], point1[0])), bd.abs(bd.subtract(point2[1], point1[1])));\n// }\n\n/**\n * Returns the chebyshev distance between 2 points.\n * This is the maximum between the points' x distance and y distance.\n * This is often used for chess pieces, because moving\n * diagonally 1 is the same distance as moving orthogonally one.\n */\nfunction chebyshevDistance(point1: Coords, point2: Coords): bigint {\n\treturn bimath.max(bimath.abs(point2[0] - point1[0]), bimath.abs(point2[1] - point1[1]));\n}\n\n/**\n * {@link chebyshevDistance} but for BigDecimal coordinates.\n */\nfunction chebyshevDistanceBD(point1: BDCoords, point2: BDCoords): BigDecimal {\n\treturn bd.max(\n\t\tbd.abs(bd.subtract(point2[0], point1[0])),\n\t\tbd.abs(bd.subtract(point2[1], point1[1])),\n\t);\n}\n\n/**\n * {@link chebyshevDistance} but for javascript numbers (doubles).\n */\nfunction chebyshevDistanceDoubles(point1: DoubleCoords, point2: DoubleCoords): number {\n\treturn Math.max(Math.abs(point2[0] - point1[0]), Math.abs(point2[1] - point1[1]));\n}\n\n// Exports -------------------------------------------------------------\n\nexport default {\n\t// Constants\n\tVECTORS_ORTHOGONAL,\n\tVECTORS_DIAGONAL,\n\tVECTORS_HIPPOGONAL,\n\n\t// Construction\n\tgetKeyFromVec2,\n\tgetVec2FromKey,\n\tconvertVectorToDoubles,\n\tgetLineGeneralFormFromCoordsAndVec,\n\tgetLineGeneralFormFromCoordsAndVecBD,\n\tgetLineGeneralFormFrom2Coords,\n\t// getLineGeneralFormFrom2CoordsBD,\n\tconvertCoeficcientsToBD,\n\tcalculateVectorFromPoints,\n\tcalculateVectorFromBDPoints,\n\tgetLineCFromCoordsAndVec,\n\t// getLineCFromCoordsAndVecBD,\n\n\t// Operations\n\tareLinesInGeneralFormEqual,\n\tgetXYComponents_FromAngle,\n\tdotProduct,\n\tdotProductBD,\n\tdotProductDoubles,\n\tnegateVector,\n\tnegateBDVector,\n\tnegateDoubleVector,\n\tabsVector,\n\tnormalizeVector,\n\tnormalizeVectorBD,\n\tgetPerpendicularVector,\n\tgetPerpendicularLine,\n\tdegreesToRadians,\n\n\t// Distance Calculation\n\teuclideanDistance,\n\teuclideanDistanceBD,\n\teuclideanDistanceDoubles,\n\tmanhattanDistance,\n\t// manhattanDistanceBD,\n\tchebyshevDistance,\n\tchebyshevDistanceBD,\n\tchebyshevDistanceDoubles,\n};\n\nexport type { Vec2, Vec2Key, Vec3, Ray, LineCoefficients, LineCoefficientsBD };\n"
  },
  {
    "path": "src/shared/util/timeutil.ts",
    "content": "// src/shared/util/timeutil.ts\n\n/**\n * This script contains utility methods for working with dates and timestamps.\n *\n * ZERO dependencies.\n */\n\n/**\n * Converts minutes to milliseconds.\n */\nfunction minutesToMillis(minutes: number): number {\n\treturn minutes * 60 * 1000;\n}\n\n/**\n * Converts seconds to milliseconds.\n */\nfunction secondsToMillis(seconds: number): number {\n\treturn seconds * 1000;\n}\n\n/**\n * Converts a timestamp to an object with UTCDate and UTCTime.\n * This time format is used for ICN metadata notation.\n * @param timestamp - The timestamp in milliseconds since the Unix Epoch.\n * @returns An object with the properties `UTCDate` and `UTCTime`.\n */\nfunction convertTimestampToUTCDateUTCTime(timestamp: number): { UTCDate: string; UTCTime: string } {\n\tconst date = new Date(timestamp);\n\n\tconst year = date.getUTCFullYear();\n\tconst month = String(date.getUTCMonth() + 1).padStart(2, '0');\n\tconst day = String(date.getUTCDate()).padStart(2, '0');\n\n\tconst hours = String(date.getUTCHours()).padStart(2, '0');\n\tconst minutes = String(date.getUTCMinutes()).padStart(2, '0');\n\tconst seconds = String(date.getUTCSeconds()).padStart(2, '0');\n\n\tconst UTCDate = `${year}.${month}.${day}`;\n\tconst UTCTime = `${hours}:${minutes}:${seconds}`;\n\n\treturn { UTCDate, UTCTime };\n}\n\n/**\n * Converts a UTCDate and optional UTCTime to a UTC timestamp in milliseconds since the Unix Epoch.\n * @param UTCDate - The date in the format \"YYYY.MM.DD\".\n * @param [UTCTime] The time in the format \"HH:MM:SS\". Defaults to \"00:00:00\".\n * @returns The UTC timestamp in milliseconds since the Unix Epoch.\n */\nfunction convertUTCDateUTCTimeToTimeStamp(UTCDate: string, UTCTime: string = '00:00:00'): number {\n\tconst [year, month, day] = UTCDate.split('.').map(Number) as [number, number, number];\n\tconst [hours, minutes, seconds] = UTCTime.split(':').map(Number) as [number, number, number];\n\n\tconst date = new Date(Date.UTC(year, month - 1, day, hours, minutes, seconds));\n\treturn date.getTime();\n}\n\n/**\n * Calculates the total milliseconds based on the provided options.\n * @param options - An object containing time units and their values.\n * @returns The total milliseconds calculated from the provided options.\n */\nfunction getTotalMilliseconds(options: {\n\tmilliseconds?: number;\n\tseconds?: number;\n\tminutes?: number;\n\thours?: number;\n\tdays?: number;\n\tweeks?: number;\n\tmonths?: number;\n\tyears?: number;\n}): number {\n\tconst millisecondsIn = {\n\t\tmilliseconds: 1,\n\t\tseconds: 1000,\n\t\tminutes: 1000 * 60,\n\t\thours: 1000 * 60 * 60,\n\t\tdays: 1000 * 60 * 60 * 24,\n\t\tweeks: 1000 * 60 * 60 * 24 * 7,\n\t\tmonths: 1000 * 60 * 60 * 24 * 30, // Approximation, not precise\n\t\tyears: 1000 * 60 * 60 * 24 * 365, // Approximation, not precise\n\t};\n\n\tlet totalMilliseconds = 0;\n\n\tfor (const option in options) {\n\t\tif (millisecondsIn[option as keyof typeof millisecondsIn]) {\n\t\t\ttotalMilliseconds +=\n\t\t\t\t(options[option as keyof typeof options] || 0) *\n\t\t\t\tmillisecondsIn[option as keyof typeof millisecondsIn];\n\t\t}\n\t}\n\n\treturn totalMilliseconds;\n}\n\n/**\n * Gets the current month in 'yyyy-mm' format.\n */\nfunction getCurrentMonth(): string {\n\tconst date = new Date();\n\tconst year = date.getFullYear();\n\tconst month = (date.getMonth() + 1).toString().padStart(2, '0'); // Add 1 because getMonth() returns 0-11\n\treturn `${year}-${month}`;\n}\n\n/**\n * Gets the current day in 'yyyy-mm-dd' format.\n */\nfunction getCurrentDay(): string {\n\tconst date = new Date();\n\tconst year = date.getFullYear();\n\tconst month = (date.getMonth() + 1).toString().padStart(2, '0');\n\tconst day = date.getDate().toString().padStart(2, '0');\n\treturn `${year}-${month}-${day}`;\n}\n\n/**\n * Checks if the current date is within a specified date range.\n * @param startMonth - The starting month of the range (1-12).\n * @param startDay - The starting day of the range (1-31).\n * @param endMonth - The ending month of the range (1-12).\n * @param endDay - The ending day of the range (1-31).\n * @returns True if the current date is within the specified range; otherwise, false.\n */\nfunction isCurrentDateWithinRange(\n\tstartMonth: number,\n\tstartDay: number,\n\tendMonth: number,\n\tendDay: number,\n): boolean {\n\tconst currentDate = new Date();\n\tconst today = new Date(\n\t\tcurrentDate.getFullYear(),\n\t\tcurrentDate.getMonth(),\n\t\tcurrentDate.getDate(),\n\t); // Normalized current date\n\tconst startDate = new Date(currentDate.getFullYear(), startMonth - 1, startDay);\n\tconst endDate = new Date(currentDate.getFullYear(), endMonth - 1, endDay);\n\treturn today >= startDate && today <= endDate;\n}\n\n/**\n * Converts a timestamp (milliseconds since the UNIX epoch) to an ISO 8601 string.\n */\nfunction timestampToISO(timestamp: number): string {\n\treturn new Date(timestamp).toISOString();\n}\n\n/**\n * Converts an ISO 8601 string to a timestamp in milliseconds since the UNIX epoch.\n */\nfunction isoToTimestamp(isoString: string): number {\n\treturn new Date(isoString).getTime();\n}\n\n/**\n * Converts a SQLite DATETIME string (in \"YYYY-MM-DD HH:MM:SS\" format) to a UTC timestamp in milliseconds.\n * Assumes the SQLite timestamp is in UTC.\n * @param sqliteString - The DATETIME string from SQLite in the format \"YYYY-MM-DD HH:MM:SS\".\n * @returns The corresponding UTC timestamp in milliseconds since the UNIX epoch.\n */\nfunction sqliteToTimestamp(sqliteString: string): number {\n\tconst isoString = sqliteToISO(sqliteString);\n\treturn Date.parse(isoString);\n}\n\n/**\n * Converts a SQLite DATETIME string (in \"YYYY-MM-DD HH:MM:SS\" format) to an ISO 8601 string.\n * Assumes the SQLite timestamp is in UTC.\n * @param sqliteString - The DATETIME string from SQLite in the format \"YYYY-MM-DD HH:MM:SS\".\n * @returns The corresponding ISO 8601 formatted string (e.g., \"YYYY-MM-DDTHH:MM:SSZ\").\n */\nfunction sqliteToISO(sqliteString: string): string {\n\treturn sqliteString.replace(' ', 'T') + 'Z';\n}\n\n/**\n * Converts an ISO 8601 string to SQLite's DATETIME format (\"YYYY-MM-DD HH:MM:SS\").\n * @param isoString - The ISO 8601 formatted string (e.g., \"YYYY-MM-DDTHH:MM:SSZ\").\n * @returns The corresponding SQLite DATETIME string (e.g., \"YYYY-MM-DD HH:MM:SS\").\n */\nfunction isoToSQLite(isoString: string): string {\n\tconst date = new Date(isoString);\n\tif (isNaN(date.getTime())) throw new Error('Invalid ISO 8601 string provided.');\n\n\treturn date.toISOString().replace('T', ' ').split('.')[0]!;\n}\n\n/**\n * Converts a timestamp (milliseconds since the UNIX epoch) to SQLite's DATETIME format (\"YYYY-MM-DD HH:MM:SS\").\n * The output string represents the timestamp in UTC.\n * @param timestamp - The timestamp in milliseconds since the UNIX epoch.\n * @returns The corresponding SQLite DATETIME string (e.g., \"YYYY-MM-DD HH:MM:SS\").\n */\nfunction timestampToSqlite(timestamp: number): string {\n\tconst date = new Date(timestamp);\n\n\t// Check if the timestamp resulted in a valid date\n\tif (isNaN(date.getTime())) throw new Error('Invalid timestamp provided.');\n\n\t// toISOString() returns in UTC format \"YYYY-MM-DDTHH:MM:SS.sssZ\"\n\t// We need to format it to \"YYYY-MM-DD HH:MM:SS\"\n\tconst isoString = date.toISOString();\n\n\t// Extract the date and time part, replace 'T' with space\n\treturn isoString.slice(0, 19).replace('T', ' ');\n}\n\nexport default {\n\tminutesToMillis,\n\tsecondsToMillis,\n\tconvertTimestampToUTCDateUTCTime,\n\tconvertUTCDateUTCTimeToTimeStamp,\n\tgetTotalMilliseconds,\n\tgetCurrentMonth,\n\tgetCurrentDay,\n\tisCurrentDateWithinRange,\n\ttimestampToISO,\n\tisoToTimestamp,\n\tsqliteToTimestamp,\n\tsqliteToISO,\n\tisoToSQLite,\n\ttimestampToSqlite,\n};\n"
  },
  {
    "path": "src/shared/util/tokenConfig.ts",
    "content": "// src/shared/util/tokenConfig.ts\n\n/**\n * This script contains shared configuration constants for authentication tokens,\n * used by both the client and server.\n */\n\n/** The expiration duration of access tokens, in milliseconds. */\nconst ACCESS_TOKEN_EXPIRY_MILLIS: number = 1000 * 60 * 15; // 15 minutes\n// const ACCESS_TOKEN_EXPIRY_MILLIS: number = 1000 * 20; // 20 seconds, for testing purposes.\n\nexport default {\n\tACCESS_TOKEN_EXPIRY_MILLIS,\n};\n"
  },
  {
    "path": "src/shared/util/uuid.ts",
    "content": "// src/shared/util/uuid.ts\n\n/**\n * This script generates unique identifiers for us.\n *\n * ZERO dependancies.\n */\n\nconst BASE_36_CHARSET: string = '0123456789abcdefghijklmnopqrstuvwxyz';\nconst BASE_62_CHARSET: string = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';\n\n/**\n * Generates a random ID of the provided length, with the characters 0-9, a-z, and A-Z.\n */\nfunction generateID_Base62(length: number): string {\n\treturn generateIDWithCharset(length, BASE_62_CHARSET);\n}\n\n/**\n * Generates a random ID of the provided length, with the characters 0-9, a-z.\n */\nfunction generateID_Base36(length: number): string {\n\treturn generateIDWithCharset(length, BASE_36_CHARSET);\n}\n\n/**\n * Generates a random ID of the provided length using the specified character set.\n * @param length - The length of the desired ID\n * @param characters - The character set to use for generating the ID\n */\nfunction generateIDWithCharset(length: number, characters: string): string {\n\tlet result = '';\n\tconst charactersLength = characters.length;\n\tfor (let i = 0; i < length; i++) {\n\t\tresult += characters.charAt(Math.floor(Math.random() * charactersLength));\n\t}\n\treturn result;\n}\n\n/**\n * Generates a **UNIQUE** ID of the provided length, with the characters 0-9 and a-z.\n * The provided object should contain the keys of the existing IDs.\n * @param length - The length of the desired ID\n * @param object - The object that contains keys of the existing IDs.\n */\nfunction genUniqueID(length: number, object: Record<string, any>): string {\n\tlet id: string;\n\tdo {\n\t\tid = generateID_Base62(length);\n\t} while (object[id] !== undefined);\n\treturn id;\n}\n\n/** Generates a random numeric ID of the provided length, with the numbers 0-9. */\nfunction generateNumbID(length: number): number {\n\tconst zeroOne = Math.random();\n\tconst multiplier = 10 ** length;\n\treturn Math.floor(zeroOne * multiplier);\n}\n\n/**\n * Converts a number from base 10 to base 62.\n * MUST BE POSITIVE!!!\t0+\n */\nfunction base10ToBase62(num: number): string {\n\tif (!Number.isInteger(num) || num < 0)\n\t\tthrow new Error(\n\t\t\t'Input must be a non-negative integer when converting base 10 to base 62. Received: ' +\n\t\t\t\tnum,\n\t\t);\n\n\tconst characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';\n\tlet result = '';\n\n\t// Handle zero as a special case\n\tif (num === 0) return '0';\n\n\twhile (num > 0) {\n\t\tconst remainder = num % 62;\n\t\tresult = characters[remainder] + result;\n\t\tnum = Math.floor(num / 62);\n\t}\n\n\treturn result;\n}\n\n/**\n * Converts a number from base 62 to base 10.\n * MUST BE VALID BASE 62!!!\n */\nfunction base62ToBase10(base62Str: string): number {\n\tif (typeof base62Str !== 'string' || base62Str.length === 0)\n\t\tthrow new Error('Input must be a non-empty string when converting base 62 to base 10.');\n\n\tconst characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';\n\tconst base = 62;\n\n\tlet result = 0;\n\tfor (let i = 0; i < base62Str.length; i++) {\n\t\tconst char = base62Str[i]!;\n\t\tconst value = characters.indexOf(char);\n\n\t\tif (value === -1) {\n\t\t\tthrow new Error(`Invalid character '${char}' in base 62 string.`);\n\t\t}\n\n\t\tresult = result * base + value;\n\t}\n\n\treturn result;\n}\n\nexport default {\n\tgenerateID_Base62,\n\tgenerateID_Base36,\n\tgenUniqueID,\n\tgenerateNumbID,\n\tbase10ToBase62,\n\tbase62ToBase10,\n};\n"
  },
  {
    "path": "src/shared/util/validators.ts",
    "content": "// src/shared/util/validators.ts\n\n/**\n * This has shared validators between client and server,\n * to avoid repeating email/password/username validation\n * and possibly missing to update things both in client and server\n *\n * TODO:\n * - Return list of errors instead of only one, also removes the need for the `Ok` value\n * - Possibly return a class (?) with a .getTranslationKey() function or add some other way to do that (then there could also be the .isValid property)\n */\n\nenum PasswordValidationResult {\n\tOk,\n\tPasswordTooShort,\n\tPasswordTooLong,\n\tPasswordIsPassword,\n}\n\nenum EmailValidationResult {\n\tOk,\n\tInvalidFormat,\n\tEmailTooLong,\n}\n\nenum UsernameValidationResult {\n\tOk,\n\tUsernameTooShort,\n\tUsernameTooLong,\n\tOnlyLettersAndNumbers,\n\tUsernameIsReserved,\n}\n\ntype PasswordValidationResultTranslations =\n\t| 'js-pwd_too_short'\n\t| 'js-pwd_too_long'\n\t| 'js-pwd_not_pwd';\ntype EmailValidationResultTranslations = 'js-email_too_long' | 'js-email_invalid';\ntype UsernameValidationResultTranslations =\n\t| 'js-username_reserved'\n\t| 'js-username_tooshort'\n\t| 'js-username_length'\n\t| 'js-username_wrongenc';\n\nconst passwordErrorTranslations = new Map<number, PasswordValidationResultTranslations>();\npasswordErrorTranslations.set(PasswordValidationResult.PasswordTooShort, 'js-pwd_too_short');\npasswordErrorTranslations.set(PasswordValidationResult.PasswordTooLong, 'js-pwd_too_long');\npasswordErrorTranslations.set(PasswordValidationResult.PasswordIsPassword, 'js-pwd_not_pwd');\n\nconst emailErrorTranslations = new Map<number, EmailValidationResultTranslations>();\nemailErrorTranslations.set(EmailValidationResult.EmailTooLong, 'js-email_too_long');\nemailErrorTranslations.set(EmailValidationResult.InvalidFormat, 'js-email_invalid');\n\nconst usernameErrorTranslations = new Map<number, UsernameValidationResultTranslations>();\nusernameErrorTranslations.set(UsernameValidationResult.UsernameIsReserved, 'js-username_reserved');\nusernameErrorTranslations.set(UsernameValidationResult.UsernameTooShort, 'js-username_tooshort');\nusernameErrorTranslations.set(UsernameValidationResult.UsernameTooLong, 'js-username_length'); // there is no translation for js-username_toolong\nusernameErrorTranslations.set(\n\tUsernameValidationResult.OnlyLettersAndNumbers,\n\t'js-username_wrongenc',\n);\n\nfunction getPasswordErrorTranslation(\n\terr: PasswordValidationResult,\n): PasswordValidationResultTranslations | undefined {\n\treturn passwordErrorTranslations.get(err);\n}\n\nfunction getEmailErrorTranslation(\n\terr: EmailValidationResult,\n): EmailValidationResultTranslations | undefined {\n\treturn emailErrorTranslations.get(err);\n}\n\nfunction getUsernameErrorTranslation(\n\terr: UsernameValidationResult,\n): UsernameValidationResultTranslations | undefined {\n\treturn usernameErrorTranslations.get(err);\n}\n\n/** Usernames that are reserved. New members cannot use these are their name. */\n// prettier-ignore\nconst reservedUsernames: string[] = [\n\t'infinitechess',\n\t'support', 'infinitechesssupport',\n\t'administrator',\n\t'amazon', 'amazonsupport', 'aws', 'awssupport',\n\t'apple', 'applesupport',\n\t'microsoft', 'microsoftsupport',\n\t'google', 'googlesupport',\n\t'adobe', 'adobesupport',\n\t'youtube', 'facebook', 'tiktok', 'twitter', 'x', 'instagram', 'snapchat',\n\t'tesla', 'elonmusk', 'meta',\n\t'walmart', 'costco',\n\t'valve', 'valvesupport',\n\t'github',\n\t'nvidia', 'amd', 'intel', 'msi', 'tsmc', 'gigabyte',\n\t'roblox',\n\t'minecraft',\n\t'fortnite',\n\t'teamfortress2',\n\t'amongus', 'innersloth', 'henrystickmin',\n\t'halflife', 'halflife2', 'gordonfreeman',\n\t'epic', 'epicgames', 'epicgamessupport',\n\t'taylorswift', 'kimkardashian', 'tomcruise', 'keanureeves', 'morganfreeman', 'willsmith',\n\t'office', 'office365',\n\t'usa', 'america',\n\t'donaldtrump', 'joebiden'\n];\n\n/**\n * Shared logic to validate passwords\n * @param password The password to check\n * @returns `Ok` if the password is valid, otherwise another member of that enum\n */\nfunction validatePassword(password: string): PasswordValidationResult {\n\tif (password.length < 6) return PasswordValidationResult.PasswordTooShort;\n\tif (password.length > 72) return PasswordValidationResult.PasswordTooLong;\n\tif (password.toLowerCase() === 'password') return PasswordValidationResult.PasswordIsPassword;\n\treturn PasswordValidationResult.Ok;\n}\n\n/**\n * Shared logic to validate emails.\n * **Note**: Does not check if the email is taken or banned, that's on the server to do.\n * @param email The email to check\n * @returns `Ok` if the email is valid, otherwise another member of that enum\n */\nfunction validateEmail(email: string): EmailValidationResult {\n\tif (email.length > 320) return EmailValidationResult.EmailTooLong;\n\tif (!validateEmailFormat(email)) return EmailValidationResult.InvalidFormat;\n\treturn EmailValidationResult.Ok;\n}\n\nfunction validateEmailFormat(email: string): boolean {\n\t// Credit for the regex: https://stackoverflow.com/a/201378\n\t// prettier-ignore\n\tconst regex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])/; // eslint-disable-line no-control-regex\n\treturn regex.test(email.toLowerCase());\n}\n\n/**\n * Shared logic to validate usernames.\n * **Note**: Does not check if the username is taken, that's on the server to do.\n * @param username The username to check\n * @returns `Ok` if the username is valid, otherwise another member of that enum\n * @todo Return a list of errors instead of just one, for better checking (then the Ok could also be replaced by just checking if the list length is 0, which might be cleaner)\n */\nfunction validateUsername(username: string): UsernameValidationResult {\n\tif (username.length < 3) return UsernameValidationResult.UsernameTooShort;\n\tif (username.length > 20) return UsernameValidationResult.UsernameTooLong;\n\tif (!onlyLettersAndNumbers(username)) return UsernameValidationResult.OnlyLettersAndNumbers;\n\tif (reservedUsernames.includes(username.toLowerCase()))\n\t\treturn UsernameValidationResult.UsernameIsReserved;\n\treturn UsernameValidationResult.Ok;\n}\n\nfunction onlyLettersAndNumbers(string: string): boolean {\n\tif (!string) return true;\n\treturn /^[a-zA-Z0-9]+$/.test(string);\n}\n\nexport default {\n\tvalidatePassword,\n\tPasswordValidationResult,\n\tvalidateEmail,\n\tEmailValidationResult,\n\tvalidateUsername,\n\tUsernameValidationResult,\n\tgetPasswordErrorTranslation,\n\tgetEmailErrorTranslation,\n\tgetUsernameErrorTranslation,\n};\n"
  },
  {
    "path": "src/shared/util/wsutil.ts",
    "content": "// src/shared/util/wsutil.ts\n\n/*\n * This script should contain utility methods regarding\n * sockets that both the CLIENT and server can use.\n */\n\n// Constants ---------------------------------------------------------------------------------\n\n/**\n * After this much time of no messages sent, the server sends a\n * 'renewconnection' keepalive expecting an echo back.\n */\nconst timeOfInactivityToRenewConnection = 10000;\n\n// Variables ---------------------------------------------------------------------------------\n\n// Possible websocket closure reasons:\n\n// Server closure reasons:\n// 1000 \"Connection expired\"  (This can say this even if in dev tools we disable our network)\n// 1008 \"Unable to identify client IP address\"\n// 1008 \"Authentication needed\"\n// 1008 \"Logged out\"\n// 1009 \"Too Many Requests. Try again soon.\"\n// 1009 \"Message Too Big\"\n// 1009 \"Too Many Sockets\"\n// 1009 \"Origin Error\"\n// 1014 \"No echo heard\"  (Client took too long to respond)\n\n// Client closure reasons:\n// 1000 \"Connection closed by client\"\n// 1000 \"Connection closed by client. Renew.\"\n\n// Other:\n// 1006 \"\" Network error\n// 1001 \"\" Endpoint going away. (Closed tab without performing cleanup)\n\n// All client-side closure codes:\n\n// 1000: Normal closure.\n// 1001: Endpoint going away.\n// 1002: Protocol error.\n// 1003: Unsupported data.\n// 1005: No status code received (reserved).\n// 1006: Abnormal closure, no further detail available (reserved). This is usually a network interruption, OR the server is down.\n// 1007: Invalid data received.\n// 1008: Policy violation.\n// 1009: Message too big.\n// 1010: Missing extension.\n// 1011: Internal server error.\n// 1012: Service restart.\n// 1013: Try again later.\n// 1014: Bad gateway.\n// 1015: TLS handshake failure (reserved).\n\n// Possible closure reasons (pairings of code and reason):\n\n// 1000 \"Connection expired\"  (This can say this even if in dev tools we disable our network)\n// 1000 \"Connection closed by client\"\n// 1000 \"Connection closed by client. Renew.\"\n// 1008 \"Unable to identify client IP address\"\n// 1008 \"Authentication needed\"\n// 1008 \"Logged out\" (Happens when we click log out button)\n// 1009 \"Too Many Requests. Try again soon.\"\n// 1009 \"Message Too Big\"\n// 1009 \"Too Many Sockets\"\n// 1009 \"Origin Error\"\n// 1014 \"No echo heard\"  (Client took too long to respond)\n\n// These are the closure reasons where we will RETAIN their invite for a set amount of time before deleting it by disconnection!\n// We will also give them 5 seconds to reconnect before we tell their opponent they have disconnected.\n// If the closure code is NOT one of the ones below, it means they purposefully closed the socket (like closed the tab),\n// so IMMEDIATELY tell their opponent they disconnected!\nconst closureCodesNotByChoice: number[] = [1006];\nconst closureReasonsNotByChoice: string[] = [\n\t'Connection expired',\n\t'Message Too Big',\n\t'Too Many Sockets',\n\t'No echo heard',\n\t'Connection closed by client. Renew.',\n];\n\n// Functions ---------------------------------------------------------------------------------\n\n/**\n * Determines if the WebSocket closure was not initiated by the client (i.e., they had no control over the closure).\n * If this returns `true`, the client is allowed 5 seconds to reconnect before notifying their opponent of the disconnection.\n * @param code - The WebSocket closure code.\n * @param reason - The reason provided for the WebSocket closure.\n * @returns `true` if the closure was not initiated by the client, otherwise `false`.\n */\nfunction wasSocketClosureNotByTheirChoice(code: number, reason: string): boolean {\n\treturn (\n\t\tclosureCodesNotByChoice.includes(code) || closureReasonsNotByChoice.includes(reason.trim())\n\t);\n}\n\n// -----------------------------------------------------------------------------------------\n\nexport default {\n\ttimeOfInactivityToRenewConnection,\n\twasSocketClosureNotByTheirChoice,\n};\n"
  },
  {
    "path": "src/tests/integrationUtils.ts",
    "content": "// src/tests/integrationUtils.ts\n\nimport { testRequest } from './testRequest';\n\nimport { generateAccount } from '../server/controllers/createAccountController';\n\n// Variables -------------------------------------------------------------------\n\n/** Counter to ensure unique usernames for each test user */\nlet userCounter = 0;\n\n// Functions -------------------------------------------------------------------\n\n/** Creates a new test user, logs them in, and returns their username and session cookie. */\nasync function createAndLoginUser(): Promise<{\n\tuser_id: number;\n\tusername: string;\n\tcookie: string;\n}> {\n\tuserCounter++;\n\tconst username = `ChessMaster-${userCounter}`;\n\tconst user_id = await generateAccount({\n\t\tusername,\n\t\temail: `${username}@example.com`,\n\t\tpassword: 'Password123!',\n\t\tautoVerify: true,\n\t});\n\n\tconst response = await testRequest().post('/auth').send({ username, password: 'Password123!' });\n\n\t// Extract the session cookies\n\tconst cookies = response.headers['set-cookie'] as unknown as string[]; // set-cookie is actually an array\n\tconst jwt = cookies.find((c) => c.startsWith('jwt='));\n\tconst memberInfo = cookies.find((c) => c.startsWith('memberInfo='));\n\n\tif (!jwt || !memberInfo) throw new Error('Missing login cookies');\n\n\t// Return both combined\n\treturn {\n\t\tuser_id,\n\t\tusername,\n\t\tcookie: [jwt, memberInfo].filter(Boolean).join(';'),\n\t};\n}\n\n// Exports -------------------------------------------------------------------\n\nexport default {\n\tcreateAndLoginUser,\n};\n"
  },
  {
    "path": "src/tests/testRequest.ts",
    "content": "// src/tests/testRequest.ts\n\nimport request, { Test } from 'supertest';\n\nimport app from '../server/app.js';\n\n/**\n * A wrapper around supertest to automatically set common headers\n * required by the application (e.g. to bypass HTTPS redirects and 404s).\n */\nexport function testRequest(): {\n\tget: (url: string) => Test;\n\tpost: (url: string) => Test;\n\tput: (url: string) => Test;\n\tpatch: (url: string) => Test;\n\tdelete: (url: string) => Test;\n} {\n\tconst req = request(app);\n\tconst commonHeaders = {\n\t\t'X-Forwarded-Proto': 'https', // Fakes HTTPS to bypass middleware redirect\n\t\t'User-Agent': 'supertest', // Required to bypass middleware rate limiting\n\t};\n\n\treturn {\n\t\tget: (url: string) => req.get(url).set(commonHeaders),\n\t\tpost: (url: string) => req.post(url).set(commonHeaders),\n\t\tput: (url: string) => req.put(url).set(commonHeaders),\n\t\tpatch: (url: string) => req.patch(url).set(commonHeaders),\n\t\tdelete: (url: string) => req.delete(url).set(commonHeaders),\n\t};\n}\n"
  },
  {
    "path": "src/tests/tests-setup.ts",
    "content": "// src/tests/tests-setup.ts\n\nimport type { NextFunction, Request, Response } from 'express';\n\nimport { vi, afterAll } from 'vitest';\n\n// Set up environment variables for testing.\n// Prevents `test` workflow job failing due to missing secrets.\nprocess.env['ACCESS_TOKEN_SECRET'] = 'test_access_secret';\nprocess.env['REFRESH_TOKEN_SECRET'] = 'test_refresh_secret';\n\n// Stop Console Bloat\n// Store the original functions so we can restore them after\nconst originalLog = console.log;\nconst originalError = console.error;\nconst originalWarn = console.warn;\n// Redirect console functions to empty functions\nconsole.log = vi.fn();\nconsole.error = vi.fn();\nconsole.warn = vi.fn();\n\n// Mock Logger to prevent file writes\n// This tells Vitest whenever any file imports logEvents.js, give them these empty functions instead.\nvi.mock('../server/middleware/logEvents.js', () => ({\n\tlogEvents: vi.fn(), // Do nothing\n\tlogEventsAndPrint: vi.fn(), // Do nothing\n\treqLogger: (_req: Request, _res: Response, next: NextFunction) => next(), // Continue to next middleware\n\tlogWebsocketStart: vi.fn(), // Do nothing\n\tlogReqWebsocketIn: vi.fn(), // Do nothing\n\tlogReqWebsocketOut: vi.fn(), // Do nothing\n}));\n\n// Restore console functions after tests finish so Vitest can print the summary\nafterAll(() => {\n\tconsole.log = originalLog;\n\tconsole.error = originalError;\n\tconsole.warn = originalWarn;\n});\n"
  },
  {
    "path": "src/types/globals.d.ts",
    "content": "// src/types/globals.d.ts\n\nimport type { MemberInfo } from '../server/types';\nimport type { TranslationsObject } from './translations';\n\n/**\n * Client-side translations subset.\n * EJS templates use spread operators to flatten nested translation objects.\n * For example, `...t('play.javascript', {returnObjects: true})` spreads all properties\n * from `play.javascript` directly into the global translations object.\n */\ntype ClientTranslations = TranslationsObject['index']['javascript'] &\n\tTranslationsObject['play']['javascript'] &\n\tTranslationsObject['play']['play-menu'] &\n\tTranslationsObject['member']['javascript'] &\n\tTranslationsObject['login']['javascript'] &\n\tTranslationsObject['leaderboard']['javascript'] &\n\tTranslationsObject['create-account']['javascript'] &\n\tTranslationsObject['reset-password']['javascript'] &\n\tTranslationsObject['password-validation'];\n\ndeclare global {\n\t/**\n\t * Global translations object injected by EJS templates.\n\t * Contains flattened translation properties from various sections.\n\t * The actual shape varies by page, but this represents the union of all possible translations.\n\t */\n\tconst translations: ClientTranslations;\n\n\t/** htmlscript injected inline inside the game page. It handles the loading animation. */\n\tvar htmlscript: {\n\t\t/** Called on failure to load a page asset. */\n\t\tcallback_LoadingError: () => void;\n\t\t/** Removes this specific html element's listener for a loading error. */\n\t\tremoveOnerror: (this: HTMLElement) => void;\n\t};\n\n\t/** Main script that starts the game loop. Called from htmlscript.js */\n\tvar main: {\n\t\tstart: () => void;\n\t};\n\n\t// Our Custom Events\n\tinterface DocumentEventMap {\n\t\tping: CustomEvent<number>;\n\t\t'socket-closed': CustomEvent<void>;\n\t\t'premoves-toggle': CustomEvent<boolean>;\n\t\t'lingering-annotations-toggle': CustomEvent<boolean>;\n\t\t'starfield-toggle': CustomEvent<boolean>;\n\t\t'master-volume-change': CustomEvent<number>;\n\t\t'ambience-toggle': CustomEvent<boolean>;\n\t\t'ray-count-change': CustomEvent<number>;\n\t\tcanvas_resize: CustomEvent<{ width: number; height: number }>;\n\t}\n\n\t// Add an optional 'memberInfo' to the global Express Request interface\n\tnamespace Express {\n\t\texport interface Request {\n\t\t\tmemberInfo?: MemberInfo;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/types/shaders.d.ts",
    "content": "// src/types/shaders.d.ts\n\n/*\n * This tells TypeScript all .glsl imports are strings.\n *\n * This can't be put inside globals.d.ts because TypeScript\n * has a weird rule that global declarations must\n * be in a separate file from module declarations.\n */\n\ndeclare module '*.glsl' {\n\tconst content: string;\n\texport default content;\n}\n"
  },
  {
    "path": "src/types/translations.ts",
    "content": "// src/types/translations.ts\n\n/**\n * This file is auto-generated by scripts/generate-translation-types.ts on build.\n * Do NOT edit manually!\n */\n\n/**\n * Flat dot-notation union type for server-side i18next.\n * Use with i18next.t() function.\n * @example\n * i18next.t(\"play.javascript.termination.checkmate\")\n */\nexport type TranslationKeys =\n\t| 'name'\n\t| 'english_name'\n\t| 'direction'\n\t| 'version'\n\t| 'maintainer'\n\t| 'header.home'\n\t| 'header.play'\n\t| 'header.news'\n\t| 'header.login'\n\t| 'header.profile'\n\t| 'header.createaccount'\n\t| 'header.logout'\n\t| 'header.leaderboard'\n\t| 'header.settings.language'\n\t| 'header.settings.appearance'\n\t| 'header.settings.appearance-theme'\n\t| 'header.settings.appearance-coordinates'\n\t| 'header.settings.appearance-starfield'\n\t| 'header.settings.appearance-advanced-effects'\n\t| 'header.settings.legalmoves'\n\t| 'header.settings.legalmoves-squares'\n\t| 'header.settings.legalmoves-dots'\n\t| 'header.settings.gameplay'\n\t| 'header.settings.gameplay-drag'\n\t| 'header.settings.gameplay-premove'\n\t| 'header.settings.gameplay-animations'\n\t| 'header.settings.gameplay-fast_transitions'\n\t| 'header.settings.gameplay-lingering_annotations'\n\t| 'header.settings.perspective'\n\t| 'header.settings.perspective-mouse-sensitivity'\n\t| 'header.settings.perspective-fov'\n\t| 'header.settings.sound'\n\t| 'header.settings.sound-master-volume'\n\t| 'header.settings.sound-ambience'\n\t| 'header.settings.ping'\n\t| 'header.settings.reset-to-default'\n\t| 'footer.contact'\n\t| 'footer.terms_of_service'\n\t| 'footer.source_code'\n\t| 'footer.language'\n\t| 'member.javascript.js-confirm_delete'\n\t| 'member.javascript.js-enter_password'\n\t| 'member.title'\n\t| 'member.verify_message'\n\t| 'member.resend_message'\n\t| 'member.verify_confirm'\n\t| 'member.joined'\n\t| 'member.seen'\n\t| 'member.practice_progress'\n\t| 'member.ranked_elo'\n\t| 'member.infinity_leaderboard_position'\n\t| 'member.infinity_leaderboard_rating_deviation'\n\t| 'member.reveal_info'\n\t| 'member.account_info_heading'\n\t| 'member.email'\n\t| 'member.delete_account'\n\t| 'member.badge-tooltips.checkmate_bronze'\n\t| 'member.badge-tooltips.checkmate_silver'\n\t| 'member.badge-tooltips.checkmate_gold'\n\t| 'leaderboard.javascript.supported_variants'\n\t| 'leaderboard.javascript.rank'\n\t| 'leaderboard.javascript.player'\n\t| 'leaderboard.javascript.rating'\n\t| 'leaderboard.title'\n\t| 'leaderboard.inactive_players'\n\t| 'leaderboard.your_global_ranking'\n\t| 'leaderboard.show_more'\n\t| 'index.title'\n\t| 'index.secondary_title'\n\t| 'index.what_is_it_title'\n\t| 'index.what_is_it_pargaraphs'\n\t| 'index.how_to_title'\n\t| 'index.how_to_paragraph'\n\t| 'index.about_title'\n\t| 'index.about_paragraphs'\n\t| 'index.patreon_title'\n\t| 'index.github_title'\n\t| 'index.javascript.contribution_count_singular'\n\t| 'index.javascript.contribution_count_plural'\n\t| 'credits.title'\n\t| 'credits.copyright'\n\t| 'credits.variants_heading'\n\t| 'credits.variants_credits'\n\t| 'credits.textures_heading'\n\t| 'credits.textures_licensed_under'\n\t| 'credits.sounds_heading'\n\t| 'credits.sounds_credits'\n\t| 'credits.code_heading'\n\t| 'credits.code_credits'\n\t| 'credits.language_heading'\n\t| 'credits.language_credits'\n\t| 'create-account.title'\n\t| 'create-account.username'\n\t| 'create-account.email'\n\t| 'create-account.password'\n\t| 'create-account.create_button'\n\t| 'create-account.agreement'\n\t| 'create-account.javascript.js-username_reserved'\n\t| 'create-account.javascript.js-username_length'\n\t| 'create-account.javascript.js-username_tooshort'\n\t| 'create-account.javascript.js-username_wrongenc'\n\t| 'create-account.javascript.js-email_invalid'\n\t| 'create-account.javascript.js-email_too_long'\n\t| 'create-account.javascript.js-email_inuse'\n\t| 'reset-password.javascript.js-pwd_no_match'\n\t| 'reset-password.javascript.reset-password'\n\t| 'reset-password.javascript.processing'\n\t| 'reset-password.javascript.network-error'\n\t| 'password-validation.js-pwd_too_short'\n\t| 'password-validation.js-pwd_too_long'\n\t| 'password-validation.js-pwd_not_pwd'\n\t| 'play.title'\n\t| 'play.loading'\n\t| 'play.error'\n\t| 'play.main-menu.credits'\n\t| 'play.main-menu.play'\n\t| 'play.main-menu.practice'\n\t| 'play.main-menu.guide'\n\t| 'play.main-menu.editor'\n\t| 'play.editor.position'\n\t| 'play.editor.tools'\n\t| 'play.editor.selection'\n\t| 'play.editor.palette'\n\t| 'play.editor.color'\n\t| 'play.editor.tooltip_reset'\n\t| 'play.editor.tooltip_clear'\n\t| 'play.editor.tooltip_load'\n\t| 'play.editor.tooltip_save_as'\n\t| 'play.editor.tooltip_save'\n\t| 'play.editor.tooltip_copy_notation'\n\t| 'play.editor.tooltip_paste_notation'\n\t| 'play.editor.tooltip_gamerules'\n\t| 'play.editor.tooltip_start_local'\n\t| 'play.editor.tooltip_start_engine'\n\t| 'play.editor.tooltip_normal'\n\t| 'play.editor.tooltip_eraser'\n\t| 'play.editor.tooltip_selection_tool'\n\t| 'play.editor.tooltip_specialrights'\n\t| 'play.editor.tooltip_select_all'\n\t| 'play.editor.tooltip_clear_selection'\n\t| 'play.editor.tooltip_copy_selection'\n\t| 'play.editor.tooltip_paste_selection'\n\t| 'play.editor.tooltip_invert_color'\n\t| 'play.editor.tooltip_rotate_left'\n\t| 'play.editor.tooltip_rotate_right'\n\t| 'play.editor.tooltip_flip_horizontal'\n\t| 'play.editor.tooltip_flip_vertical'\n\t| 'play.editor.reset_header'\n\t| 'play.editor.reset_message'\n\t| 'play.editor.clear_header'\n\t| 'play.editor.clear_message'\n\t| 'play.editor.enter_position_name'\n\t| 'play.editor.save_button'\n\t| 'play.editor.name_header'\n\t| 'play.editor.pieces_header'\n\t| 'play.editor.date_header'\n\t| 'play.editor.no_saves'\n\t| 'play.editor.gamerules_header'\n\t| 'play.editor.player_to_move'\n\t| 'play.editor.white'\n\t| 'play.editor.black'\n\t| 'play.editor.en_passant'\n\t| 'play.editor.move_rule'\n\t| 'play.editor.promotion_ranks_white'\n\t| 'play.editor.promotion_ranks_black'\n\t| 'play.editor.promotion_pieces'\n\t| 'play.editor.global_special_rights'\n\t| 'play.editor.pawn_double_push'\n\t| 'play.editor.castling_label'\n\t| 'play.editor.win_conditions'\n\t| 'play.editor.checkmate'\n\t| 'play.editor.royal_capture'\n\t| 'play.editor.all_royals_captured'\n\t| 'play.editor.all_pieces_captured'\n\t| 'play.editor.world_border'\n\t| 'play.editor.start_local_game'\n\t| 'play.editor.start_local_game_message'\n\t| 'play.editor.start_engine_game'\n\t| 'play.editor.play_as'\n\t| 'play.editor.time_control'\n\t| 'play.editor.engine_difficulty'\n\t| 'play.editor.easy'\n\t| 'play.editor.medium'\n\t| 'play.editor.hard'\n\t| 'play.editor.use_default_border'\n\t| 'play.editor.start_engine_game_message'\n\t| 'play.editor.yes'\n\t| 'play.editor.no'\n\t| 'play.guide.title'\n\t| 'play.guide.rules'\n\t| 'play.guide.rules_paragraphs'\n\t| 'play.guide.careful_heading'\n\t| 'play.guide.careful_paragraphs'\n\t| 'play.guide.controls_heading'\n\t| 'play.guide.controls_paragraph'\n\t| 'play.guide.keybinds'\n\t| 'play.guide.controls_paragraph2'\n\t| 'play.guide.keybinds_extra'\n\t| 'play.guide.fairy_heading'\n\t| 'play.guide.fairy_paragraph'\n\t| 'play.guide.back'\n\t| 'play.guide.pieces.chancellor.name'\n\t| 'play.guide.pieces.chancellor.description'\n\t| 'play.guide.pieces.archbishop.name'\n\t| 'play.guide.pieces.archbishop.description'\n\t| 'play.guide.pieces.amazon.name'\n\t| 'play.guide.pieces.amazon.description'\n\t| 'play.guide.pieces.guard.name'\n\t| 'play.guide.pieces.guard.description'\n\t| 'play.guide.pieces.hawk.name'\n\t| 'play.guide.pieces.hawk.description'\n\t| 'play.guide.pieces.centaur.name'\n\t| 'play.guide.pieces.centaur.description'\n\t| 'play.guide.pieces.knightrider.name'\n\t| 'play.guide.pieces.knightrider.description'\n\t| 'play.guide.pieces.huygen.name'\n\t| 'play.guide.pieces.huygen.description'\n\t| 'play.guide.pieces.rose.name'\n\t| 'play.guide.pieces.rose.description'\n\t| 'play.guide.pieces.obstacle.name'\n\t| 'play.guide.pieces.obstacle.description'\n\t| 'play.guide.pieces.void.name'\n\t| 'play.guide.pieces.void.description'\n\t| 'play.practice-menu.title'\n\t| 'play.practice-menu.play'\n\t| 'play.practice-menu.back'\n\t| 'play.practice-menu.difficulty'\n\t| 'play.play-menu.title'\n\t| 'play.play-menu.colors'\n\t| 'play.play-menu.online'\n\t| 'play.play-menu.local'\n\t| 'play.play-menu.computer'\n\t| 'play.play-menu.variant'\n\t| 'play.play-menu.Classical'\n\t| 'play.play-menu.Confined_Classical'\n\t| 'play.play-menu.Classical_Plus'\n\t| 'play.play-menu.CoaIP'\n\t| 'play.play-menu.Pawndard'\n\t| 'play.play-menu.Knighted_Chess'\n\t| 'play.play-menu.Palace'\n\t| 'play.play-menu.Knightline'\n\t| 'play.play-menu.Core'\n\t| 'play.play-menu.Standarch'\n\t| 'play.play-menu.Pawn_Horde'\n\t| 'play.play-menu.Space_Classic'\n\t| 'play.play-menu.Space'\n\t| 'play.play-menu.Obstocean'\n\t| 'play.play-menu.Abundance'\n\t| 'play.play-menu.Amazon_Chandelier'\n\t| 'play.play-menu.Containment'\n\t| 'play.play-menu.Classical_Limit_7'\n\t| 'play.play-menu.CoaIP_Limit_7'\n\t| 'play.play-menu.Chess'\n\t| 'play.play-menu.Classical_KOTH'\n\t| 'play.play-menu.CoaIP_KOTH'\n\t| 'play.play-menu.CoaIP_HO'\n\t| 'play.play-menu.CoaIP_RO'\n\t| 'play.play-menu.CoaIP_NO'\n\t| 'play.play-menu.Omega'\n\t| 'play.play-menu.Omega_Squared'\n\t| 'play.play-menu.Omega_Cubed'\n\t| 'play.play-menu.Omega_Fourth'\n\t| 'play.play-menu.4x4x4x4_Chess'\n\t| 'play.play-menu.5D_Chess'\n\t| 'play.play-menu.no_clock'\n\t| 'play.play-menu.clock'\n\t| 'play.play-menu.minutes'\n\t| 'play.play-menu.seconds'\n\t| 'play.play-menu.infinite_time'\n\t| 'play.play-menu.color'\n\t| 'play.play-menu.piece_colors'\n\t| 'play.play-menu.private'\n\t| 'play.play-menu.no'\n\t| 'play.play-menu.yes'\n\t| 'play.play-menu.rated'\n\t| 'play.play-menu.casual'\n\t| 'play.play-menu.easy'\n\t| 'play.play-menu.medium'\n\t| 'play.play-menu.hard'\n\t| 'play.play-menu.join_games'\n\t| 'play.play-menu.private_invite'\n\t| 'play.play-menu.your_invite'\n\t| 'play.play-menu.create_invite'\n\t| 'play.play-menu.join'\n\t| 'play.play-menu.copy'\n\t| 'play.play-menu.back'\n\t| 'play.play-menu.code'\n\t| 'play.gamebuttontooltips.undo_transition'\n\t| 'play.gamebuttontooltips.expand_fit_all'\n\t| 'play.gamebuttontooltips.recenter'\n\t| 'play.gamebuttontooltips.annotations'\n\t| 'play.gamebuttontooltips.erase'\n\t| 'play.gamebuttontooltips.collapse'\n\t| 'play.gamebuttontooltips.rewind_move'\n\t| 'play.gamebuttontooltips.forward_move'\n\t| 'play.gamebuttontooltips.undo_edit'\n\t| 'play.gamebuttontooltips.redo_edit'\n\t| 'play.gamebuttontooltips.pause'\n\t| 'play.gamebuttontooltips.undo'\n\t| 'play.gamebuttontooltips.restart'\n\t| 'play.pause.title'\n\t| 'play.pause.resume'\n\t| 'play.pause.arrows'\n\t| 'play.pause.perspective'\n\t| 'play.pause.copy'\n\t| 'play.pause.paste'\n\t| 'play.pause.offer_draw'\n\t| 'play.pause.practice_menu'\n\t| 'play.pause.main_menu'\n\t| 'play.drawoffer.question'\n\t| 'play.javascript.guest_indicator'\n\t| 'play.javascript.you_indicator'\n\t| 'play.javascript.engine_indicator'\n\t| 'play.javascript.player_name_white_generic'\n\t| 'play.javascript.player_name_black_generic'\n\t| 'play.javascript.white_to_move'\n\t| 'play.javascript.black_to_move'\n\t| 'play.javascript.your_move'\n\t| 'play.javascript.their_move'\n\t| 'play.javascript.lost_network'\n\t| 'play.javascript.failed_to_load'\n\t| 'play.javascript.planned_feature'\n\t| 'play.javascript.main_menu'\n\t| 'play.javascript.resign_game'\n\t| 'play.javascript.abort_game'\n\t| 'play.javascript.offer_draw'\n\t| 'play.javascript.accept_draw'\n\t| 'play.javascript.arrows_off'\n\t| 'play.javascript.arrows_defense'\n\t| 'play.javascript.arrows_all'\n\t| 'play.javascript.arrows_all_hippogonals'\n\t| 'play.javascript.toggled'\n\t| 'play.javascript.menu_online'\n\t| 'play.javascript.menu_local'\n\t| 'play.javascript.menu_computer'\n\t| 'play.javascript.invite_error_digits'\n\t| 'play.javascript.invite_copied'\n\t| 'play.javascript.move_counter'\n\t| 'play.javascript.constructing_mesh'\n\t| 'play.javascript.rotating_mesh'\n\t| 'play.javascript.lost_connection'\n\t| 'play.javascript.please_wait'\n\t| 'play.javascript.webgl_unsupported'\n\t| 'play.javascript.bigints_unsupported'\n\t| 'play.javascript.versus'\n\t| 'play.javascript.easy'\n\t| 'play.javascript.medium'\n\t| 'play.javascript.hard'\n\t| 'play.javascript.insane'\n\t| 'play.javascript.checkmate_logged_out'\n\t| 'play.javascript.checkmate_bronze'\n\t| 'play.javascript.checkmate_silver'\n\t| 'play.javascript.checkmate_gold'\n\t| 'play.javascript.checkmate_bronze_unearned'\n\t| 'play.javascript.checkmate_silver_unearned'\n\t| 'play.javascript.checkmate_gold_unearned'\n\t| 'play.javascript.coords-invalid'\n\t| 'play.javascript.coords-exceeded'\n\t| 'play.javascript.piecenames.void'\n\t| 'play.javascript.piecenames.obstacle'\n\t| 'play.javascript.piecenames.king'\n\t| 'play.javascript.piecenames.giraffe'\n\t| 'play.javascript.piecenames.camel'\n\t| 'play.javascript.piecenames.zebra'\n\t| 'play.javascript.piecenames.knightrider'\n\t| 'play.javascript.piecenames.amazon'\n\t| 'play.javascript.piecenames.queen'\n\t| 'play.javascript.piecenames.royalQueen'\n\t| 'play.javascript.piecenames.hawk'\n\t| 'play.javascript.piecenames.chancellor'\n\t| 'play.javascript.piecenames.archbishop'\n\t| 'play.javascript.piecenames.centaur'\n\t| 'play.javascript.piecenames.royalCentaur'\n\t| 'play.javascript.piecenames.rose'\n\t| 'play.javascript.piecenames.knight'\n\t| 'play.javascript.piecenames.guard'\n\t| 'play.javascript.piecenames.huygen'\n\t| 'play.javascript.piecenames.rook'\n\t| 'play.javascript.piecenames.bishop'\n\t| 'play.javascript.piecenames.pawn'\n\t| 'play.javascript.copypaste.copied_game'\n\t| 'play.javascript.copypaste.cannot_paste_in_public'\n\t| 'play.javascript.copypaste.cannot_paste_in_rated'\n\t| 'play.javascript.copypaste.cannot_paste_in_engine'\n\t| 'play.javascript.copypaste.cannot_paste_after_moves'\n\t| 'play.javascript.copypaste.clipboard_denied'\n\t| 'play.javascript.copypaste.clipboard_invalid'\n\t| 'play.javascript.copypaste.game_needs_to_specify'\n\t| 'play.javascript.copypaste.pasting_game'\n\t| 'play.javascript.copypaste.pasting_in_private'\n\t| 'play.javascript.copypaste.piece_count'\n\t| 'play.javascript.copypaste.exceeded'\n\t| 'play.javascript.copypaste.changed_wincon'\n\t| 'play.javascript.copypaste.loaded_from_clipboard'\n\t| 'play.javascript.copypaste.copied_position'\n\t| 'play.javascript.copypaste.loaded_position_from_clipboard'\n\t| 'play.javascript.copypaste.reset_position'\n\t| 'play.javascript.copypaste.clear_position'\n\t| 'play.javascript.rendering.on'\n\t| 'play.javascript.rendering.off'\n\t| 'play.javascript.rendering.icon_rendering_off'\n\t| 'play.javascript.rendering.icon_rendering_on'\n\t| 'play.javascript.rendering.perspective'\n\t| 'play.javascript.rendering.perspective_mode_on_desktop'\n\t| 'play.javascript.rendering.movement_tutorial'\n\t| 'play.javascript.rendering.regenerated_pieces'\n\t| 'play.javascript.invites.move_mouse'\n\t| 'play.javascript.invites.cannot_cancel'\n\t| 'play.javascript.invites.you_are_white'\n\t| 'play.javascript.invites.you_are_black'\n\t| 'play.javascript.invites.random'\n\t| 'play.javascript.invites.accept'\n\t| 'play.javascript.invites.cancel'\n\t| 'play.javascript.invites.create_invite'\n\t| 'play.javascript.invites.cancel_invite'\n\t| 'play.javascript.invites.start_game'\n\t| 'play.javascript.invites.join_existing_active_games'\n\t| 'play.javascript.onlinegame.afk_warning'\n\t| 'play.javascript.onlinegame.opponent_afk'\n\t| 'play.javascript.onlinegame.opponent_disconnected'\n\t| 'play.javascript.onlinegame.opponent_lost_connection'\n\t| 'play.javascript.onlinegame.auto_resigning_in'\n\t| 'play.javascript.onlinegame.auto_aborting_in'\n\t| 'play.javascript.onlinegame.not_logged_in'\n\t| 'play.javascript.onlinegame.game_no_longer_exists'\n\t| 'play.javascript.onlinegame.another_window_connected'\n\t| 'play.javascript.websocket.no_connection'\n\t| 'play.javascript.websocket.reconnected'\n\t| 'play.javascript.websocket.unable_to_identify_ip'\n\t| 'play.javascript.websocket.online_play_disabled'\n\t| 'play.javascript.websocket.too_many_requests'\n\t| 'play.javascript.websocket.message_too_big'\n\t| 'play.javascript.websocket.too_many_sockets'\n\t| 'play.javascript.websocket.origin_error'\n\t| 'play.javascript.websocket.connection_closed'\n\t| 'play.javascript.websocket.please_report_bug'\n\t| 'play.javascript.websocket.malformed_message'\n\t| 'play.javascript.results.you_checkmate'\n\t| 'play.javascript.results.you_time'\n\t| 'play.javascript.results.you_resignation'\n\t| 'play.javascript.results.you_disconnect'\n\t| 'play.javascript.results.you_royalcapture'\n\t| 'play.javascript.results.you_allroyalscaptured'\n\t| 'play.javascript.results.you_allpiecescaptured'\n\t| 'play.javascript.results.you_koth'\n\t| 'play.javascript.results.you_generic'\n\t| 'play.javascript.results.draw_stalemate'\n\t| 'play.javascript.results.draw_repetition'\n\t| 'play.javascript.results.draw_moverule'\n\t| 'play.javascript.results.draw_insuffmat'\n\t| 'play.javascript.results.draw_agreement'\n\t| 'play.javascript.results.draw_generic'\n\t| 'play.javascript.results.aborted'\n\t| 'play.javascript.results.opponent_checkmate'\n\t| 'play.javascript.results.opponent_time'\n\t| 'play.javascript.results.opponent_resignation'\n\t| 'play.javascript.results.opponent_disconnect'\n\t| 'play.javascript.results.opponent_royalcapture'\n\t| 'play.javascript.results.opponent_allroyalscaptured'\n\t| 'play.javascript.results.opponent_allpiecescaptured'\n\t| 'play.javascript.results.opponent_koth'\n\t| 'play.javascript.results.opponent_generic'\n\t| 'play.javascript.results.white_checkmate'\n\t| 'play.javascript.results.black_checkmate'\n\t| 'play.javascript.results.white_time'\n\t| 'play.javascript.results.black_time'\n\t| 'play.javascript.results.white_resignation'\n\t| 'play.javascript.results.black_resignation'\n\t| 'play.javascript.results.white_disconnect'\n\t| 'play.javascript.results.black_disconnect'\n\t| 'play.javascript.results.white_royalcapture'\n\t| 'play.javascript.results.black_royalcapture'\n\t| 'play.javascript.results.white_allroyalscaptured'\n\t| 'play.javascript.results.black_allroyalscaptured'\n\t| 'play.javascript.results.white_allpiecescaptured'\n\t| 'play.javascript.results.black_allpiecescaptured'\n\t| 'play.javascript.results.white_koth'\n\t| 'play.javascript.results.black_koth'\n\t| 'play.javascript.results.bug_generic'\n\t| 'play.javascript.editor.expand_sidebar'\n\t| 'play.javascript.editor.collapse_sidebar'\n\t| 'play.javascript.editor.new_position'\n\t| 'play.javascript.editor.load_position_header'\n\t| 'play.javascript.editor.save_position_as_header'\n\t| 'play.javascript.editor.delete_title'\n\t| 'play.javascript.editor.delete_message'\n\t| 'play.javascript.editor.load_title'\n\t| 'play.javascript.editor.load_message'\n\t| 'play.javascript.editor.overwrite_title'\n\t| 'play.javascript.editor.overwrite_message'\n\t| 'play.javascript.editor.tooltip_load_position'\n\t| 'play.javascript.editor.tooltip_save_to_cloud'\n\t| 'play.javascript.editor.tooltip_remove_from_cloud'\n\t| 'play.javascript.editor.tooltip_delete_position'\n\t| 'play.javascript.editor.position_loaded'\n\t| 'play.javascript.editor.cannot_start_local_empty'\n\t| 'play.javascript.editor.cannot_start_engine_empty'\n\t| 'play.javascript.editor.position_not_supported'\n\t| 'play.javascript.editor.illegal_position_king_capture'\n\t| 'play.javascript.editor.saved_in_browser'\n\t| 'play.javascript.editor.position_corrupted'\n\t| 'play.javascript.editor.failed_to_load'\n\t| 'play.javascript.editor.failed_to_convert_icn'\n\t| 'play.javascript.editor.too_large_for_cloud'\n\t| 'play.javascript.editor.failed_to_upload'\n\t| 'play.javascript.editor.saved_to_cloud'\n\t| 'play.javascript.editor.no_changes'\n\t| 'play.javascript.editor.failed_to_load_cloud'\n\t| 'play.javascript.editor.failed_to_delete_cloud'\n\t| 'play.javascript.editor.failed_to_remove_cloud'\n\t| 'play.javascript.editor.saved_locally'\n\t| 'play.javascript.editor.failed_to_fetch_cloud'\n\t| 'terms.title'\n\t| 'terms.warning'\n\t| 'terms.consent'\n\t| 'terms.guardian_consent'\n\t| 'terms.parents_header'\n\t| 'terms.parents_paragraphs'\n\t| 'terms.fair_play_header'\n\t| 'terms.fair_play_paragraph1'\n\t| 'terms.fair_play_paragraph2'\n\t| 'terms.fair_play_rules'\n\t| 'terms.cleanliness_header'\n\t| 'terms.cleanliness_rules'\n\t| 'terms.privacy_header'\n\t| 'terms.privacy_rules'\n\t| 'terms.cookie_header'\n\t| 'terms.cookie_paragraphs'\n\t| 'terms.conclusion_header'\n\t| 'terms.conclusion_paragraphs'\n\t| 'terms.thanks'\n\t| 'login.title'\n\t| 'login.username'\n\t| 'login.password'\n\t| 'login.login_button'\n\t| 'login.send_reset_link'\n\t| 'login.forgot_question'\n\t| 'login.back_to_login'\n\t| 'login.forgot_instruction'\n\t| 'login.javascript.network-error'\n\t| 'reset_password.title'\n\t| 'reset_password.instruction'\n\t| 'reset_password.new_password'\n\t| 'reset_password.confirm_password'\n\t| 'reset_password.submit_button'\n\t| 'error-pages.400_message'\n\t| 'error-pages.409_message'\n\t| 'error-pages.500_message'\n\t| 'news.title'\n\t| 'news.more_dev_logs'\n\t| 'server.javascript.ws-invalid_username'\n\t| 'server.javascript.ws-incorrect_password'\n\t| 'server.javascript.ws-login_failure_retry_in'\n\t| 'server.javascript.ws-seconds'\n\t| 'server.javascript.ws-second'\n\t| 'server.javascript.ws-username_letters'\n\t| 'server.javascript.ws-username_taken'\n\t| 'server.javascript.ws-username_bad_word'\n\t| 'server.javascript.ws-email_too_long'\n\t| 'server.javascript.ws-email_invalid'\n\t| 'server.javascript.ws-email_in_use'\n\t| 'server.javascript.ws-email_domain_invalid'\n\t| 'server.javascript.ws-email_blacklisted'\n\t| 'server.javascript.ws-password_length'\n\t| 'server.javascript.ws-password_password'\n\t| 'server.javascript.ws-password-reset-link-sent'\n\t| 'server.javascript.ws-password-change-success'\n\t| 'server.javascript.ws-password-reset-token-invalid'\n\t| 'server.javascript.ws-forbidden_wrong_account'\n\t| 'server.javascript.ws-deleting_account_not_found'\n\t| 'server.javascript.ws-deleting_account_in_game'\n\t| 'server.javascript.ws-server_error'\n\t| 'server.javascript.ws-not_found'\n\t| 'server.javascript.ws-forbidden'\n\t| 'server.javascript.ws-already_in_game'\n\t| 'server.javascript.ws-you_cheated'\n\t| 'server.javascript.ws-opponent_cheated'\n\t| 'server.javascript.ws-cannot_resign_finished_game'\n\t| 'server.javascript.ws-invalid_code'\n\t| 'server.javascript.ws-game_aborted'\n\t| 'server.javascript.ws-rated_invite_verification_needed'\n\t| 'rate-limiting.generic'\n\t| 'rate-limiting.error';\n\n/**\n * Nested object type for client-side translation access.\n * Represents the full structure of the translation object.\n */\nexport interface TranslationsObject {\n\tname: string;\n\tenglish_name: string;\n\tdirection: string;\n\tversion: string;\n\tmaintainer: string;\n\theader: {\n\t\thome: string;\n\t\tplay: string;\n\t\tnews: string;\n\t\tlogin: string;\n\t\tprofile: string;\n\t\tcreateaccount: string;\n\t\tlogout: string;\n\t\tleaderboard: string;\n\t\tsettings: {\n\t\t\tlanguage: string;\n\t\t\tappearance: string;\n\t\t\t'appearance-theme': string;\n\t\t\t'appearance-coordinates': string;\n\t\t\t'appearance-starfield': string;\n\t\t\t'appearance-advanced-effects': string;\n\t\t\tlegalmoves: string;\n\t\t\t'legalmoves-squares': string;\n\t\t\t'legalmoves-dots': string;\n\t\t\tgameplay: string;\n\t\t\t'gameplay-drag': string;\n\t\t\t'gameplay-premove': string;\n\t\t\t'gameplay-animations': string;\n\t\t\t'gameplay-fast_transitions': string;\n\t\t\t'gameplay-lingering_annotations': string;\n\t\t\tperspective: string;\n\t\t\t'perspective-mouse-sensitivity': string;\n\t\t\t'perspective-fov': string;\n\t\t\tsound: string;\n\t\t\t'sound-master-volume': string;\n\t\t\t'sound-ambience': string;\n\t\t\tping: string[];\n\t\t\t'reset-to-default': string;\n\t\t};\n\t};\n\tfooter: {\n\t\tcontact: string;\n\t\tterms_of_service: string;\n\t\tsource_code: string;\n\t\tlanguage: string;\n\t};\n\tmember: {\n\t\tjavascript: {\n\t\t\t'js-confirm_delete': string;\n\t\t\t'js-enter_password': string;\n\t\t};\n\t\ttitle: string;\n\t\tverify_message: string;\n\t\tresend_message: string[];\n\t\tverify_confirm: string;\n\t\tjoined: string;\n\t\tseen: string;\n\t\tpractice_progress: string;\n\t\tranked_elo: string;\n\t\tinfinity_leaderboard_position: string;\n\t\tinfinity_leaderboard_rating_deviation: string;\n\t\treveal_info: string;\n\t\taccount_info_heading: string;\n\t\temail: string;\n\t\tdelete_account: string;\n\t\t'badge-tooltips': {\n\t\t\tcheckmate_bronze: string;\n\t\t\tcheckmate_silver: string;\n\t\t\tcheckmate_gold: string;\n\t\t};\n\t};\n\tleaderboard: {\n\t\tjavascript: {\n\t\t\tsupported_variants: string;\n\t\t\trank: string;\n\t\t\tplayer: string;\n\t\t\trating: string;\n\t\t};\n\t\ttitle: string;\n\t\tinactive_players: string[];\n\t\tyour_global_ranking: string;\n\t\tshow_more: string;\n\t};\n\tindex: {\n\t\ttitle: string;\n\t\tsecondary_title: string;\n\t\twhat_is_it_title: string;\n\t\twhat_is_it_pargaraphs: string[];\n\t\thow_to_title: string;\n\t\thow_to_paragraph: string[];\n\t\tabout_title: string;\n\t\tabout_paragraphs: string[];\n\t\tpatreon_title: string;\n\t\tgithub_title: string;\n\t\tjavascript: {\n\t\t\tcontribution_count_singular: string[];\n\t\t\tcontribution_count_plural: string[];\n\t\t};\n\t};\n\tcredits: {\n\t\ttitle: string;\n\t\tcopyright: string;\n\t\tvariants_heading: string;\n\t\tvariants_credits: string[];\n\t\ttextures_heading: string;\n\t\ttextures_licensed_under: string;\n\t\tsounds_heading: string;\n\t\tsounds_credits: string[];\n\t\tcode_heading: string;\n\t\tcode_credits: string[];\n\t\tlanguage_heading: string;\n\t\tlanguage_credits: string[];\n\t};\n\t'create-account': {\n\t\ttitle: string;\n\t\tusername: string;\n\t\temail: string;\n\t\tpassword: string;\n\t\tcreate_button: string;\n\t\tagreement: string[];\n\t\tjavascript: {\n\t\t\t'js-username_reserved': string;\n\t\t\t'js-username_length': string;\n\t\t\t'js-username_tooshort': string;\n\t\t\t'js-username_wrongenc': string;\n\t\t\t'js-email_invalid': string;\n\t\t\t'js-email_too_long': string;\n\t\t\t'js-email_inuse': string;\n\t\t};\n\t};\n\t'reset-password': {\n\t\tjavascript: {\n\t\t\t'js-pwd_no_match': string;\n\t\t\t'reset-password': string;\n\t\t\tprocessing: string;\n\t\t\t'network-error': string;\n\t\t};\n\t};\n\t'password-validation': {\n\t\t'js-pwd_too_short': string;\n\t\t'js-pwd_too_long': string;\n\t\t'js-pwd_not_pwd': string;\n\t};\n\tplay: {\n\t\ttitle: string;\n\t\tloading: string;\n\t\terror: string;\n\t\t'main-menu': {\n\t\t\tcredits: string;\n\t\t\tplay: string;\n\t\t\tpractice: string;\n\t\t\tguide: string;\n\t\t\teditor: string;\n\t\t};\n\t\teditor: {\n\t\t\tposition: string;\n\t\t\ttools: string;\n\t\t\tselection: string;\n\t\t\tpalette: string;\n\t\t\tcolor: string;\n\t\t\ttooltip_reset: string;\n\t\t\ttooltip_clear: string;\n\t\t\ttooltip_load: string;\n\t\t\ttooltip_save_as: string;\n\t\t\ttooltip_save: string;\n\t\t\ttooltip_copy_notation: string;\n\t\t\ttooltip_paste_notation: string;\n\t\t\ttooltip_gamerules: string;\n\t\t\ttooltip_start_local: string;\n\t\t\ttooltip_start_engine: string;\n\t\t\ttooltip_normal: string;\n\t\t\ttooltip_eraser: string;\n\t\t\ttooltip_selection_tool: string;\n\t\t\ttooltip_specialrights: string;\n\t\t\ttooltip_select_all: string;\n\t\t\ttooltip_clear_selection: string;\n\t\t\ttooltip_copy_selection: string;\n\t\t\ttooltip_paste_selection: string;\n\t\t\ttooltip_invert_color: string;\n\t\t\ttooltip_rotate_left: string;\n\t\t\ttooltip_rotate_right: string;\n\t\t\ttooltip_flip_horizontal: string;\n\t\t\ttooltip_flip_vertical: string;\n\t\t\treset_header: string;\n\t\t\treset_message: string;\n\t\t\tclear_header: string;\n\t\t\tclear_message: string;\n\t\t\tenter_position_name: string;\n\t\t\tsave_button: string;\n\t\t\tname_header: string;\n\t\t\tpieces_header: string;\n\t\t\tdate_header: string;\n\t\t\tno_saves: string;\n\t\t\tgamerules_header: string;\n\t\t\tplayer_to_move: string;\n\t\t\twhite: string;\n\t\t\tblack: string;\n\t\t\ten_passant: string;\n\t\t\tmove_rule: string;\n\t\t\tpromotion_ranks_white: string;\n\t\t\tpromotion_ranks_black: string;\n\t\t\tpromotion_pieces: string;\n\t\t\tglobal_special_rights: string;\n\t\t\tpawn_double_push: string;\n\t\t\tcastling_label: string;\n\t\t\twin_conditions: string;\n\t\t\tcheckmate: string;\n\t\t\troyal_capture: string;\n\t\t\tall_royals_captured: string;\n\t\t\tall_pieces_captured: string;\n\t\t\tworld_border: string;\n\t\t\tstart_local_game: string;\n\t\t\tstart_local_game_message: string;\n\t\t\tstart_engine_game: string;\n\t\t\tplay_as: string;\n\t\t\ttime_control: string;\n\t\t\tengine_difficulty: string;\n\t\t\teasy: string;\n\t\t\tmedium: string;\n\t\t\thard: string;\n\t\t\tuse_default_border: string;\n\t\t\tstart_engine_game_message: string;\n\t\t\tyes: string;\n\t\t\tno: string;\n\t\t};\n\t\tguide: {\n\t\t\ttitle: string;\n\t\t\trules: string;\n\t\t\trules_paragraphs: string[];\n\t\t\tcareful_heading: string;\n\t\t\tcareful_paragraphs: string[];\n\t\t\tcontrols_heading: string;\n\t\t\tcontrols_paragraph: string;\n\t\t\tkeybinds: string[];\n\t\t\tcontrols_paragraph2: string;\n\t\t\tkeybinds_extra: string[];\n\t\t\tfairy_heading: string;\n\t\t\tfairy_paragraph: string;\n\t\t\tback: string;\n\t\t\tpieces: {\n\t\t\t\tchancellor: {\n\t\t\t\t\tname: string;\n\t\t\t\t\tdescription: string;\n\t\t\t\t};\n\t\t\t\tarchbishop: {\n\t\t\t\t\tname: string;\n\t\t\t\t\tdescription: string;\n\t\t\t\t};\n\t\t\t\tamazon: {\n\t\t\t\t\tname: string;\n\t\t\t\t\tdescription: string;\n\t\t\t\t};\n\t\t\t\tguard: {\n\t\t\t\t\tname: string;\n\t\t\t\t\tdescription: string;\n\t\t\t\t};\n\t\t\t\thawk: {\n\t\t\t\t\tname: string;\n\t\t\t\t\tdescription: string;\n\t\t\t\t};\n\t\t\t\tcentaur: {\n\t\t\t\t\tname: string;\n\t\t\t\t\tdescription: string;\n\t\t\t\t};\n\t\t\t\tknightrider: {\n\t\t\t\t\tname: string;\n\t\t\t\t\tdescription: string;\n\t\t\t\t};\n\t\t\t\thuygen: {\n\t\t\t\t\tname: string;\n\t\t\t\t\tdescription: string;\n\t\t\t\t};\n\t\t\t\trose: {\n\t\t\t\t\tname: string;\n\t\t\t\t\tdescription: string;\n\t\t\t\t};\n\t\t\t\tobstacle: {\n\t\t\t\t\tname: string;\n\t\t\t\t\tdescription: string;\n\t\t\t\t};\n\t\t\t\tvoid: {\n\t\t\t\t\tname: string;\n\t\t\t\t\tdescription: string;\n\t\t\t\t};\n\t\t\t};\n\t\t};\n\t\t'practice-menu': {\n\t\t\ttitle: string;\n\t\t\tplay: string;\n\t\t\tback: string;\n\t\t\tdifficulty: string;\n\t\t};\n\t\t'play-menu': {\n\t\t\ttitle: string;\n\t\t\tcolors: string;\n\t\t\tonline: string;\n\t\t\tlocal: string;\n\t\t\tcomputer: string;\n\t\t\tvariant: string;\n\t\t\tClassical: string;\n\t\t\tConfined_Classical: string;\n\t\t\tClassical_Plus: string;\n\t\t\tCoaIP: string;\n\t\t\tPawndard: string;\n\t\t\tKnighted_Chess: string;\n\t\t\tPalace: string;\n\t\t\tKnightline: string;\n\t\t\tCore: string;\n\t\t\tStandarch: string;\n\t\t\tPawn_Horde: string;\n\t\t\tSpace_Classic: string;\n\t\t\tSpace: string;\n\t\t\tObstocean: string;\n\t\t\tAbundance: string;\n\t\t\tAmazon_Chandelier: string;\n\t\t\tContainment: string;\n\t\t\tClassical_Limit_7: string;\n\t\t\tCoaIP_Limit_7: string;\n\t\t\tChess: string;\n\t\t\tClassical_KOTH: string;\n\t\t\tCoaIP_KOTH: string;\n\t\t\tCoaIP_HO: string;\n\t\t\tCoaIP_RO: string;\n\t\t\tCoaIP_NO: string;\n\t\t\tOmega: string;\n\t\t\tOmega_Squared: string;\n\t\t\tOmega_Cubed: string;\n\t\t\tOmega_Fourth: string;\n\t\t\t'4x4x4x4_Chess': string;\n\t\t\t'5D_Chess': string;\n\t\t\tno_clock: string;\n\t\t\tclock: string;\n\t\t\tminutes: string;\n\t\t\tseconds: string;\n\t\t\tinfinite_time: string;\n\t\t\tcolor: string;\n\t\t\tpiece_colors: string[];\n\t\t\tprivate: string;\n\t\t\tno: string;\n\t\t\tyes: string;\n\t\t\trated: string;\n\t\t\tcasual: string;\n\t\t\teasy: string;\n\t\t\tmedium: string;\n\t\t\thard: string;\n\t\t\tjoin_games: string;\n\t\t\tprivate_invite: string;\n\t\t\tyour_invite: string;\n\t\t\tcreate_invite: string;\n\t\t\tjoin: string;\n\t\t\tcopy: string;\n\t\t\tback: string;\n\t\t\tcode: string;\n\t\t};\n\t\tgamebuttontooltips: {\n\t\t\tundo_transition: string;\n\t\t\texpand_fit_all: string;\n\t\t\trecenter: string;\n\t\t\tannotations: string;\n\t\t\terase: string;\n\t\t\tcollapse: string;\n\t\t\trewind_move: string;\n\t\t\tforward_move: string;\n\t\t\tundo_edit: string;\n\t\t\tredo_edit: string;\n\t\t\tpause: string;\n\t\t\tundo: string;\n\t\t\trestart: string;\n\t\t};\n\t\tpause: {\n\t\t\ttitle: string;\n\t\t\tresume: string;\n\t\t\tarrows: string;\n\t\t\tperspective: string;\n\t\t\tcopy: string;\n\t\t\tpaste: string;\n\t\t\toffer_draw: string;\n\t\t\tpractice_menu: string;\n\t\t\tmain_menu: string;\n\t\t};\n\t\tdrawoffer: {\n\t\t\tquestion: string;\n\t\t};\n\t\tjavascript: {\n\t\t\tguest_indicator: string;\n\t\t\tyou_indicator: string;\n\t\t\tengine_indicator: string;\n\t\t\tplayer_name_white_generic: string;\n\t\t\tplayer_name_black_generic: string;\n\t\t\twhite_to_move: string;\n\t\t\tblack_to_move: string;\n\t\t\tyour_move: string;\n\t\t\ttheir_move: string;\n\t\t\tlost_network: string;\n\t\t\tfailed_to_load: string;\n\t\t\tplanned_feature: string;\n\t\t\tmain_menu: string;\n\t\t\tresign_game: string;\n\t\t\tabort_game: string;\n\t\t\toffer_draw: string;\n\t\t\taccept_draw: string;\n\t\t\tarrows_off: string;\n\t\t\tarrows_defense: string;\n\t\t\tarrows_all: string;\n\t\t\tarrows_all_hippogonals: string;\n\t\t\ttoggled: string;\n\t\t\tmenu_online: string;\n\t\t\tmenu_local: string;\n\t\t\tmenu_computer: string;\n\t\t\tinvite_error_digits: string;\n\t\t\tinvite_copied: string;\n\t\t\tmove_counter: string;\n\t\t\tconstructing_mesh: string;\n\t\t\trotating_mesh: string;\n\t\t\tlost_connection: string;\n\t\t\tplease_wait: string;\n\t\t\twebgl_unsupported: string;\n\t\t\tbigints_unsupported: string;\n\t\t\tversus: string;\n\t\t\teasy: string;\n\t\t\tmedium: string;\n\t\t\thard: string;\n\t\t\tinsane: string;\n\t\t\tcheckmate_logged_out: string;\n\t\t\tcheckmate_bronze: string;\n\t\t\tcheckmate_silver: string;\n\t\t\tcheckmate_gold: string;\n\t\t\tcheckmate_bronze_unearned: string;\n\t\t\tcheckmate_silver_unearned: string;\n\t\t\tcheckmate_gold_unearned: string;\n\t\t\t'coords-invalid': string;\n\t\t\t'coords-exceeded': string;\n\t\t\tpiecenames: {\n\t\t\t\tvoid: string;\n\t\t\t\tobstacle: string;\n\t\t\t\tking: string;\n\t\t\t\tgiraffe: string;\n\t\t\t\tcamel: string;\n\t\t\t\tzebra: string;\n\t\t\t\tknightrider: string;\n\t\t\t\tamazon: string;\n\t\t\t\tqueen: string;\n\t\t\t\troyalQueen: string;\n\t\t\t\thawk: string;\n\t\t\t\tchancellor: string;\n\t\t\t\tarchbishop: string;\n\t\t\t\tcentaur: string;\n\t\t\t\troyalCentaur: string;\n\t\t\t\trose: string;\n\t\t\t\tknight: string;\n\t\t\t\tguard: string;\n\t\t\t\thuygen: string;\n\t\t\t\trook: string;\n\t\t\t\tbishop: string;\n\t\t\t\tpawn: string;\n\t\t\t};\n\t\t\tcopypaste: {\n\t\t\t\tcopied_game: string;\n\t\t\t\tcannot_paste_in_public: string;\n\t\t\t\tcannot_paste_in_rated: string;\n\t\t\t\tcannot_paste_in_engine: string;\n\t\t\t\tcannot_paste_after_moves: string;\n\t\t\t\tclipboard_denied: string;\n\t\t\t\tclipboard_invalid: string;\n\t\t\t\tgame_needs_to_specify: string;\n\t\t\t\tpasting_game: string;\n\t\t\t\tpasting_in_private: string;\n\t\t\t\tpiece_count: string;\n\t\t\t\texceeded: string;\n\t\t\t\tchanged_wincon: string;\n\t\t\t\tloaded_from_clipboard: string;\n\t\t\t\tcopied_position: string;\n\t\t\t\tloaded_position_from_clipboard: string;\n\t\t\t\treset_position: string;\n\t\t\t\tclear_position: string;\n\t\t\t};\n\t\t\trendering: {\n\t\t\t\ton: string;\n\t\t\t\toff: string;\n\t\t\t\ticon_rendering_off: string;\n\t\t\t\ticon_rendering_on: string;\n\t\t\t\tperspective: string;\n\t\t\t\tperspective_mode_on_desktop: string;\n\t\t\t\tmovement_tutorial: string;\n\t\t\t\tregenerated_pieces: string;\n\t\t\t};\n\t\t\tinvites: {\n\t\t\t\tmove_mouse: string;\n\t\t\t\tcannot_cancel: string;\n\t\t\t\tyou_are_white: string;\n\t\t\t\tyou_are_black: string;\n\t\t\t\trandom: string;\n\t\t\t\taccept: string;\n\t\t\t\tcancel: string;\n\t\t\t\tcreate_invite: string;\n\t\t\t\tcancel_invite: string;\n\t\t\t\tstart_game: string;\n\t\t\t\tjoin_existing_active_games: string;\n\t\t\t};\n\t\t\tonlinegame: {\n\t\t\t\tafk_warning: string;\n\t\t\t\topponent_afk: string;\n\t\t\t\topponent_disconnected: string;\n\t\t\t\topponent_lost_connection: string;\n\t\t\t\tauto_resigning_in: string;\n\t\t\t\tauto_aborting_in: string;\n\t\t\t\tnot_logged_in: string;\n\t\t\t\tgame_no_longer_exists: string;\n\t\t\t\tanother_window_connected: string;\n\t\t\t};\n\t\t\twebsocket: {\n\t\t\t\tno_connection: string;\n\t\t\t\treconnected: string;\n\t\t\t\tunable_to_identify_ip: string;\n\t\t\t\tonline_play_disabled: string;\n\t\t\t\ttoo_many_requests: string;\n\t\t\t\tmessage_too_big: string;\n\t\t\t\ttoo_many_sockets: string;\n\t\t\t\torigin_error: string;\n\t\t\t\tconnection_closed: string;\n\t\t\t\tplease_report_bug: string;\n\t\t\t\tmalformed_message: string;\n\t\t\t};\n\t\t\tresults: {\n\t\t\t\tyou_checkmate: string;\n\t\t\t\tyou_time: string;\n\t\t\t\tyou_resignation: string;\n\t\t\t\tyou_disconnect: string;\n\t\t\t\tyou_royalcapture: string;\n\t\t\t\tyou_allroyalscaptured: string;\n\t\t\t\tyou_allpiecescaptured: string;\n\t\t\t\tyou_koth: string;\n\t\t\t\tyou_generic: string;\n\t\t\t\tdraw_stalemate: string;\n\t\t\t\tdraw_repetition: string;\n\t\t\t\tdraw_moverule: string[];\n\t\t\t\tdraw_insuffmat: string;\n\t\t\t\tdraw_agreement: string;\n\t\t\t\tdraw_generic: string;\n\t\t\t\taborted: string;\n\t\t\t\topponent_checkmate: string;\n\t\t\t\topponent_time: string;\n\t\t\t\topponent_resignation: string;\n\t\t\t\topponent_disconnect: string;\n\t\t\t\topponent_royalcapture: string;\n\t\t\t\topponent_allroyalscaptured: string;\n\t\t\t\topponent_allpiecescaptured: string;\n\t\t\t\topponent_koth: string;\n\t\t\t\topponent_generic: string;\n\t\t\t\twhite_checkmate: string;\n\t\t\t\tblack_checkmate: string;\n\t\t\t\twhite_time: string;\n\t\t\t\tblack_time: string;\n\t\t\t\twhite_resignation: string;\n\t\t\t\tblack_resignation: string;\n\t\t\t\twhite_disconnect: string;\n\t\t\t\tblack_disconnect: string;\n\t\t\t\twhite_royalcapture: string;\n\t\t\t\tblack_royalcapture: string;\n\t\t\t\twhite_allroyalscaptured: string;\n\t\t\t\tblack_allroyalscaptured: string;\n\t\t\t\twhite_allpiecescaptured: string;\n\t\t\t\tblack_allpiecescaptured: string;\n\t\t\t\twhite_koth: string;\n\t\t\t\tblack_koth: string;\n\t\t\t\tbug_generic: string;\n\t\t\t};\n\t\t\teditor: {\n\t\t\t\texpand_sidebar: string;\n\t\t\t\tcollapse_sidebar: string;\n\t\t\t\tnew_position: string;\n\t\t\t\tload_position_header: string;\n\t\t\t\tsave_position_as_header: string;\n\t\t\t\tdelete_title: string;\n\t\t\t\tdelete_message: string[];\n\t\t\t\tload_title: string;\n\t\t\t\tload_message: string[];\n\t\t\t\toverwrite_title: string;\n\t\t\t\toverwrite_message: string[];\n\t\t\t\ttooltip_load_position: string;\n\t\t\t\ttooltip_save_to_cloud: string;\n\t\t\t\ttooltip_remove_from_cloud: string;\n\t\t\t\ttooltip_delete_position: string;\n\t\t\t\tposition_loaded: string;\n\t\t\t\tcannot_start_local_empty: string;\n\t\t\t\tcannot_start_engine_empty: string;\n\t\t\t\tposition_not_supported: string;\n\t\t\t\tillegal_position_king_capture: string;\n\t\t\t\tsaved_in_browser: string;\n\t\t\t\tposition_corrupted: string;\n\t\t\t\tfailed_to_load: string;\n\t\t\t\tfailed_to_convert_icn: string;\n\t\t\t\ttoo_large_for_cloud: string;\n\t\t\t\tfailed_to_upload: string;\n\t\t\t\tsaved_to_cloud: string;\n\t\t\t\tno_changes: string;\n\t\t\t\tfailed_to_load_cloud: string;\n\t\t\t\tfailed_to_delete_cloud: string;\n\t\t\t\tfailed_to_remove_cloud: string;\n\t\t\t\tsaved_locally: string;\n\t\t\t\tfailed_to_fetch_cloud: string;\n\t\t\t};\n\t\t};\n\t};\n\tterms: {\n\t\ttitle: string;\n\t\twarning: string[];\n\t\tconsent: string;\n\t\tguardian_consent: string;\n\t\tparents_header: string;\n\t\tparents_paragraphs: string[];\n\t\tfair_play_header: string;\n\t\tfair_play_paragraph1: string[];\n\t\tfair_play_paragraph2: string;\n\t\tfair_play_rules: string[];\n\t\tcleanliness_header: string;\n\t\tcleanliness_rules: string[];\n\t\tprivacy_header: string;\n\t\tprivacy_rules: string[];\n\t\tcookie_header: string;\n\t\tcookie_paragraphs: string[];\n\t\tconclusion_header: string;\n\t\tconclusion_paragraphs: string[];\n\t\tthanks: string;\n\t};\n\tlogin: {\n\t\ttitle: string;\n\t\tusername: string;\n\t\tpassword: string;\n\t\tlogin_button: string;\n\t\tsend_reset_link: string;\n\t\tforgot_question: string;\n\t\tback_to_login: string;\n\t\tforgot_instruction: string;\n\t\tjavascript: {\n\t\t\t'network-error': string;\n\t\t};\n\t};\n\treset_password: {\n\t\ttitle: string;\n\t\tinstruction: string;\n\t\tnew_password: string;\n\t\tconfirm_password: string;\n\t\tsubmit_button: string;\n\t};\n\t'error-pages': {\n\t\t'400_message': string;\n\t\t'409_message': string[];\n\t\t'500_message': string;\n\t};\n\tnews: {\n\t\ttitle: string;\n\t\tmore_dev_logs: string[];\n\t};\n\tserver: {\n\t\tjavascript: {\n\t\t\t'ws-invalid_username': string;\n\t\t\t'ws-incorrect_password': string;\n\t\t\t'ws-login_failure_retry_in': string;\n\t\t\t'ws-seconds': string;\n\t\t\t'ws-second': string;\n\t\t\t'ws-username_letters': string;\n\t\t\t'ws-username_taken': string;\n\t\t\t'ws-username_bad_word': string;\n\t\t\t'ws-email_too_long': string;\n\t\t\t'ws-email_invalid': string;\n\t\t\t'ws-email_in_use': string;\n\t\t\t'ws-email_domain_invalid': string;\n\t\t\t'ws-email_blacklisted': string;\n\t\t\t'ws-password_length': string;\n\t\t\t'ws-password_password': string;\n\t\t\t'ws-password-reset-link-sent': string;\n\t\t\t'ws-password-change-success': string;\n\t\t\t'ws-password-reset-token-invalid': string;\n\t\t\t'ws-forbidden_wrong_account': string;\n\t\t\t'ws-deleting_account_not_found': string;\n\t\t\t'ws-deleting_account_in_game': string;\n\t\t\t'ws-server_error': string;\n\t\t\t'ws-not_found': string;\n\t\t\t'ws-forbidden': string;\n\t\t\t'ws-already_in_game': string;\n\t\t\t'ws-you_cheated': string;\n\t\t\t'ws-opponent_cheated': string;\n\t\t\t'ws-cannot_resign_finished_game': string;\n\t\t\t'ws-invalid_code': string;\n\t\t\t'ws-game_aborted': string;\n\t\t\t'ws-rated_invite_verification_needed': string;\n\t\t};\n\t};\n\t'rate-limiting': {\n\t\tgeneric: string;\n\t\terror: string;\n\t};\n}\n"
  },
  {
    "path": "translation/changes.json",
    "content": "{\n  \"99\": {\n    \"note\": \"Renamed selection, selection-drag, selection-premove, selection-animations, selection-lingering_annotations on lines 27-31 to gameplay, gameplay-drag, gameplay-premove, gameplay-animations, gameplay-lingering_annotations. Added gameplay-fast_transitions on line 32.\"\n  },\n  \"98\": {\n    \"note\": \"Added appearance-coordinates on line 21.\"\n  },\n  \"97\": {\n    \"note\": \"Added credits.code_credits[2] 'by FirePlank.' on line 114.\"\n  },\n  \"96\": {\n    \"note\": \"Added play.editor.no_saves on line 238.\"\n  },\n  \"95\": {\n    \"note\": \"Removed ws-server_restarting, ws-server_under_maintenance, ws-minutes, ws-minute, lines 757-760. Also removed server_restarting, server_restarting_in, minute, minutes, lines 549-552. \"\n  },\n  \"94\": {\n    \"note\": \"Update server_restarting line 549 to reflect games are resumed now after server restarts.\"\n  },\n  \"93\": {\n    \"note\": \"Deleted the whole [play.javascript.termination] section, lines 567-581\"\n  },\n  \"92\": {\n    \"note\": \"Deleted invalid_wincon line 505\"\n  },\n  \"91\": {\n    \"note\": \"Added illegal_position_king_capture line 654.\"\n  },\n  \"90\": {\n    \"note\": \"Removed editing_heading and editing_paragraphs from [play.guide] lines 309-314\"\n  },\n  \"89\": {\n    \"note\": \"Added [play.editor] section (lines 193-272) and [play.javascript.editor] section (lines 634-672).\"\n  },\n  \"88\": {\n    \"note\": \"Added rate-limiting.error on line 671.\"\n  },\n  \"87\": {\n    \"note\": \"Added malformed_message on line 491 under play.javascript.websocket.\"\n  },\n  \"86\": {\n    \"note\": \"Added js-username_reserved & js-username_length lines 156-157. Deleted ws-username_length line 635. Deleted ws-username_reserved line 639.\"\n  },\n  \"85\": {\n    \"note\": \"Changed seen on line 132 from an array to a single string, deleting the 'ago' part.\",\n    \"changes\": [\"member.seen\"]\n  },\n  \"84\": {\n    \"note\": \"Added menu_computer on line 368.\"\n  },\n  \"83\": {\n    \"note\": \"Replaced ws-game_aborted_cheating on line 659 with ws-you_cheated and ws-opponent_cheated.\"\n  },\n  \"82\": {\n    \"note\": \"Added easy, medium, and hard, lines 302-304\"\n  },\n  \"81\": {\n    \"note\": \"Split inactive_players on line 174 into an array with two quotes.\"\n  },\n  \"80\": {\n    \"note\": \"Renamed ws-you_are_banned to ws-email_blacklisted on line 639, and edited its message.\"\n  },\n  \"79\": {\n    \"note\": \"Updated ToS sentence on line 560 'Abuse bugs or glitches in order to abort the game...'\"\n  },\n  \"78\": {\n    \"note\": \"Added [rate-limiting] section lines 662-663.\"\n  },\n  \"77\": {\n    \"note\": \"Added js-email_too_long on line 159. Removed js-username_specs on line 155. Removed ws-password_format on line 642. Removed js-pwd_incorrect_format on line 168.\"\n  },\n  \"76\": {\n    \"note\": \"Added contribution_count_singular and renamed contribution_count to contribution_count_plural (lines 75-76).\"\n  },\n  \"75\": {\n    \"note\": \"Modified undo_edit and redo_edit to include keyboard shortcuts on lines 320-321.\"\n  },\n  \"74\": {\n    \"note\": \"Added Palace line 265. Added CoaIP_RO & CoaIP_NO on lines 282-283.\"\n  },\n  \"73\": {\n    \"note\": \"Added section play.javascript.piecenames on lines 387-409. Added copied_position, loaded_position_from_clipboard, reset_position, clear_position on lines 427-430.\"\n  },\n  \"72\": {\n    \"note\": \"Added play.guide.pieces.rose on line 242.\"\n  },\n  \"71\": {\n    \"note\": \"Added coords-exceeded line 384.\"\n  },\n  \"70\": {\n    \"note\": \"Renamed board, board-theme, and board-starfield to appearance, appearance-theme, appearance-starfield, lines 18-20. Also, added appearance-advanced-effects on line 21.\"\n  },\n  \"69\": {\n    \"note\": \"Added sound, sound-master-volume, and sound-ambience, lines 32-34.\"\n  },\n  \"68\": {\n    \"note\": \"Deleted shaders_failed & failed_compiling_shaders, lines 366-367.\"\n  },\n  \"67\": {\n    \"note\": \"Added coords-invalid line 381\"\n  },\n  \"66\": {\n    \"note\": \"Added board-theme & board-starfield lines 19-20\"\n  },\n  \"65\": {\n    \"note\": \"Deleted slidelimit_not_number line 397. Changed invalid_wincon_white and invalid_wincon_black on line 389 to invalid_wincon.\"\n  },\n  \"64\": {\n    \"note\": \"Added undo_edit and redo_edit on lines 310-311.\"\n  },\n  \"63\": {\n    \"note\": \"Deleted password_reset_message 133\"\n  },\n  \"62\": {\n    \"note\": \"Added ws-deleting_account_in_game on line 611.\"\n  },\n  \"61\": {\n    \"note\": \"Added German traslation credit 115\"\n  },\n  \"60\": {\n    \"note\": \"Changed lines 153-164, added all of reset-password.javascript, and moved js-pwd_incorrect_format, js-pwd_too_short, js-pwd_too_long, js-pwd_not_pwd to below the new password-validation section. Added ws-password-reset-token-invalid 604. Added all of login.javascript 567-569.\"\n  },\n  \"59\": {\n    \"note\": \"Added ALL of reset_password lines 559-565\"\n  },\n  \"58\": {\n    \"note\": \"Deleted forgot_password 553. Added send_reset_link, forgot_question, back_to_login lines 554-556, ws-password-reset-link-sent, ws-password-change-success 588-589. Deleted ws-member_not_found 590, ws-username_and_password_required, ws-username_and_password_string 570-571, ws-unable_to_identify_client_ip, ws-you_are_banned_by_server, ws-too_many_requests_to_server, ws-bad_request 591-594. Added forgot_instruction 557.\"\n  },\n  \"57\": {\n    \"note\": \"Deleted ws-no_abort_game_over and ws-no_abort_after_moves on lines 601-602.\"\n  },\n  \"56\": {\n    \"note\": \"Deleted bug_koth line 504, bug_threecheck 501, bug_allpiecescaptured 498, bug_allroyalscaptured 495, bug_royalcapture 492, bug_time 489, bug_checkmate 486, black_threecheck 495, white_threecheck 494, opponent_threecheck 481, you_threecheck 464, threecheck 448. Added white_resignation, black_resignation, white_disconnect, black_disconnect line 485, \"\n  },\n  \"55\": {\n    \"note\": \"Added cannot_paste_in_rated line 373.\"\n  },\n  \"54\": {\n    \"note\": \"Reworded terms.fair_play_paragraph1 line 517, terms.fair_play_rules line 520, and added line 521. Deleted update on line 554.\"\n  },\n  \"53\": {\n    \"note\": \"Added header.leaderboard on line 14. Added leaderboard.javascript with 4 entries on line 43. Added infinity_leaderboard_rating_deviation on line 127. Added new leaderboard category with 4 entries on line 158. Added engine_indicator line 323. Deleted you_indicator line 401.\"\n  },\n  \"52\": {\n    \"note\": \"Deleted textures_credits line 90.\"\n  },\n  \"51\": {\n    \"note\": \"Added member.infinity_leaderboard_position on line 122 and added server.javascript.ws-rated_invite_verification_needed on line 603\"\n  },\n  \"50\": {\n    \"note\": \"Added annotations, erase, and collapse, lines 285-287.\"\n  },\n  \"49\": {\n    \"note\": \"Added selection-lingering_annotations, line 25.\"\n  },\n  \"48\": {\n    \"note\": \"Added selection-animations, line 24.\"\n  },\n  \"47\": {\n    \"note\": \"Added ranked_elo on line 119.\"\n  },\n  \"46\": {\n    \"note\": \"On line 183 added a note that holding Control will force-drag the board.\"\n  },\n  \"45\": {\n    \"note\": \"Added credits for 3 new variants 'Confined Classical', '4x4x4x4 Chess', and '5D Chess' on lines 82-84.\"\n  },\n  \"44\": {\n    \"note\": \"Replaced progress_checkmate on line 333 with a comment, and added 7 more translations on lines 339-345 in play.javascript\"\n  },\n  \"43\": {\n    \"note\": \"Added all of member.badge-tooltips, lines 122-125\"\n  },\n  \"42\": {\n    \"note\": \"Added member.practice_progress to line 115\"\n  },\n  \"41\": {\n    \"note\": \"Replaced play.play-menu.5D_Chess on line 245 with 4x4x4x4_Chess and 5D_Chess\"\n  },\n  \"40\": {\n    \"note\": \"Deleted toggled_edit, line 356\"\n  },\n  \"39\": {\n    \"note\": \"Deleted checkmates and tactics, lines 208-209. Modified title line 207. Deleted menu_checkmate line 326\"\n  },\n  \"38\": {\n    \"note\": \"Deleted Trappist_1 line 244, and deleted credit for Trappist-1 on line 82.\"\n  },\n  \"37\": {\n    \"note\": \"Added the huygen to play.guide.pieces (line 203).\"\n  },\n  \"36\": {\n    \"note\": \"Added practice_menu to play.pause on line 288\"\n  },\n  \"35\": {\n    \"note\": \"Added undo and restart to play.gamebuttontooltips on lines 277-278\"\n  },\n  \"34\": {\n    \"note\": \"Moved versus, easy, medium, hard and insane from play.practice-menu to play.javascript on lines 328-332\"\n  },\n  \"33\": {\n    \"note\": \"Added progress_checkmate on line 332 under play.javascript\"\n  },\n  \"32\": {\n    \"note\": [\n      \"Added practice key on line 149 under play.main-menu.\",\n      \"Added play.practice-menu section on line 206, and everything beneath it.\",\n      \"Added menu_checkmate key on line 331.\",\n      \"Added cannot_paste_in_engine key on line 336 under play.javascript.copypaste.\"\n    ]\n  },\n  \"31\": {\n    \"note\": \"Added variant credits for 'Chess on an Infinite Plane - Huygens Options by V. Reinhart.' and 'Trappist-1 by V. Reinhart' on lines 81-82.\"\n  },\n  \"30\": {\n    \"note\": \"Added email_domain_invalid, line 539.\"\n  },\n  \"29\": {\n    \"note\": [\n      \"Added CoaIP_HO and Trappist_1, lines 230-231. These contain the names of the new variants.\",\n      \"Added Confined_Classical, line 213.\",\n      \"Added arrows_all_hippogonals, line 302\"\n    ]\n  },\n  \"28\": {\n    \"note\": [\n      \"Deleted unknown_action_received_1 & unknown_action_received_2 on lines 346-347\",\n      \"Deleted all of play.footer, lines 264-267\",\n      \"Added player_name_white_generic and player_name_black_generic, lines 280 & 281\"\n    ]\n  },\n  \"27\": {\n    \"note\": \"Added index.github_title and index.javascript.contribution_count, lines 57-60\"\n  },\n  \"26\": {\n    \"note\": \"Modified webgl_unsupported, line 303\",\n    \"changes\": [\"play.javascript.webgl_unsupported\"]\n  },\n  \"25\": {\n    \"note\": \"Added the following in header.settings, lines 21-23: selection, selection-drag, selection-premove.\"\n  },\n  \"24\": {\n    \"note\": \"Deleted play.javascript.copypaste.loaded, line 323\"\n  },\n  \"23\": {\n    \"note\": [\n      \"Deleted member.rating, line 105\",\n      \"Deleted ws-unauthorized_patron_page, line 548\",\n      \"Deleted ws-refresh_token_not_found_logged_out, ws-refresh_token_not_found, ws-refresh_token_expired, and ws-refresh_token_invalid, lines 534-537\",\n      \"Updated index.how_to_paragraph, line 47\",\n      \"Added play.play-menu.5D_Chess, line 227\"\n    ]\n  },\n  \"22\": {\n    \"note\": \"Deleted all of header.javascript (lines 19-23). Changed the value of header.home (line 7). Added header.profile (line 11) & header.logout (line 10). Deleted disable_holiday_theme_desktop and disable_holiday_theme_mobile (lines 299-300). Added all of header.settings (lines 15-25).\"\n  },\n  \"21\": {\n    \"note\": \"Added notices for how to disable the holiday theme, lines 299-300. DELETED IN UPDATE 22.\"\n  },\n  \"20\": {\n    \"note\": \"Added fields for game button tooltips, lines 241-247\",\n    \"changes\": [\"play.gamebuttontooltips\"]\n  },\n  \"19\": {\n    \"note\": \"Updated wording of verify_message and resend_message, lines 94-95\"\n  },\n  \"18\": {\n    \"note\": \"Added Spanish translation credit, line 89\"\n  },\n  \"17\": {\n    \"note\": \"Added credits.language_heading and credits.language_credits, line 82\"\n  },\n  \"16\": {\n    \"note\": \"Deleted all news posts. The only keys you keep in the `news` section is `title` and `more_dev_logs`! Please verify your language's news posts have been translated correctly within translation/news!\"\n  },\n  \"15\": {\n    \"note\": \"Added news.sept11-2024, line 493. ALL NEWS POSTS DELETED IN FUTURE CHANGE.\"\n  },\n  \"14\": {\n    \"note\": \"Added translations for the tab titles. Added [play] and play.title on lines 116 & 117. Changed play.main-menu.loading to play.loading, and changed play.main-menu.error to play.error\",\n    \"changes\": [\n      \"index.title\",\n      \"member.title\",\n      \"play.title\",\n      \"play.main-menu.loading\",\n      \"play.main-menu.error\",\n      \"play.loading\",\n      \"play.error\"\n    ]\n  },\n  \"13\": {\n    \"note\": \"Added draw offers. Deleted ws-player_already_has_invite, ws-accept_own_invite, and ws-invite_cancelled\",\n    \"changes\": [\n      \"play.pause.offer_draw\",\n      \"play.drawoffer.question\",\n      \"play.javascript.offer_draw\",\n      \"play.javascript.accept_draw\",\n      \"play.javascript.termination.agreement\",\n      \"play.javascript.results.draw_agreement\",\n      \"server.javascript.ws-player_already_has_invite\",\n      \"server.javascript.ws-accept_own_invite\",\n      \"server.javascript.ws-invite_cancelled\"\n    ]\n  },\n  \"12\": {\n    \"note\": \"Corrected opponen_resignation to opponent_resignation\",\n    \"changes\": [\"play.javascript.results.opponen_resignation\"]\n  },\n  \"11\": {\n    \"note\": \"Changed the structure of play.javascript.termination.moverule\",\n    \"changes\": [\"play.javascript.termination.moverule\"]\n  },\n  \"10\": {\n    \"note\": \"Added news post Aug 1, 2024. Deleted news post Aug 26, 2023.\",\n    \"changes\": [\n      \"news.aug1-2024.date\",\n      \"news.aug1-2024.text_top\",\n      \"news.aug1-2024.update_list\",\n      \"news.aug26-2023.date\",\n      \"news.aug26-2023.text_top\",\n      \"news.aug26-2023.text_box\"\n    ]\n  },\n  \"9\": {\n    \"note\": \"In the guide, under controls, where it describes what the Tab key does, added sentence 'Clicking these arrows will teleport you to the piece they're pointing to.' line 142. Also changed controls_paragraph, line 137.\",\n    \"changes\": [\"play.guide.controls_paragraph\", \"play.guide.keybinds\"]\n  },\n  \"8\": {\n    \"note\": \"Added a knightrider description for the guide, on line 170.\",\n    \"changes\": [\"play.guide.pieces.knightrider\"]\n  },\n  \"7\": {\n    \"note\": \"Added ws-server_under_maintenance on line 561.\",\n    \"changes\": [\"server.javascript.ws-server_under_maintenance\"]\n  },\n  \"6\": {\n    \"note\": \"Added all of play.javascript.termination, line 344, which provides spoken language descriptions of what caused the termination of the game.\"\n  },\n  \"5\": {\n    \"note\": \"Deleted create-account.argreement on line 100, and replaced it with create-account.agreement which is different. Deleted play.play-menu.variants. Added several entries to play.play-menu. Added several entries to server.javascript.\",\n    \"changes\": [\n      \"create-account.argreement\",\n      \"create-account.agreement\",\n      \"play.play-menu.variants\",\n      \"play.play-menu.Classical\",\n      \"play.play-menu.Classical_Plus\",\n      \"play.play-menu.CoaIP\",\n      \"play.play-menu.Pawndard\",\n      \"play.play-menu.Knighted_Chess\",\n      \"play.play-menu.Knightline\",\n      \"play.play-menu.Core\",\n      \"play.play-menu.Standarch\",\n      \"play.play-menu.Pawn_Horde\",\n      \"play.play-menu.Space_Classic\",\n      \"play.play-menu.Space\",\n      \"play.play-menu.Obstocean\",\n      \"play.play-menu.Abundance\",\n      \"play.play-menu.Amazon_Chandelier\",\n      \"play.play-menu.Containment\",\n      \"play.play-menu.Classical_Limit_7\",\n      \"play.play-menu.CoaIP_Limit_7\",\n      \"play.play-menu.Chess\",\n      \"play.play-menu.Classical_KOTH\",\n      \"play.play-menu.CoaIP_KOTH\",\n      \"play.play-menu.Omega\",\n      \"play.play-menu.Omega_Squared\",\n      \"play.play-menu.Omega_Cubed\",\n      \"play.play-menu.Omega_Fourth\",\n      \"play.play-menu.no_clock\",\n      \"play.play-menu.casual\",\n      \"server.javascript.ws-username_reserved\",\n      \"server.javascript.ws-already_in_game\",\n      \"server.javascript.ws-player_already_has_invite\",\n      \"server.javascript.ws-invite_cancelled\",\n      \"server.javascript.ws-accept_own_invite\",\n      \"server.javascript.ws-server_restarting\",\n      \"server.javascript.ws-minutes\",\n      \"server.javascript.ws-minute\",\n      \"server.javascript.ws-no_abort_game_over\",\n      \"server.javascript.ws-no_abort_after_moves\",\n      \"server.javascript.ws-game_aborted_cheating\",\n      \"server.javascript.ws-cannot_resign_finished_game\",\n      \"server.javascript.ws-invalid_code\",\n      \"server.javascript.ws-game_aborted\"\n    ]\n  },\n  \"4\": {\n    \"note\": \"Added news.july22-2024 on line 462\",\n    \"changes\": [\"news.july22-2024\"]\n  },\n  \"3\": {\n    \"note\": \"added play.javascript.invites.start_game on line 314\",\n    \"changes\": [\"play.javascript.invites.start_game\"]\n  },\n  \"2\": {\n    \"note\": \"added login.login_button on line 448\",\n    \"changes\": [\"login.login_button\"]\n  },\n  \"1\": {\n    \"note\": \"inital commit of en-US-toml\"\n  }\n}\n"
  },
  {
    "path": "translation/de-DE.toml",
    "content": "name = \"Deutsch\" # Name of language\r\nenglish_name = \"German\"\r\ndirection = \"ltr\" # Change to \"rtl\" for right to left languages\r\nversion = \"84\"\r\nmaintainer = \"estetique_bs,tsevasa\"\r\n\r\n[header]\r\nhome = \"Unendliches Schach\"\r\nplay = \"Spielen\"\r\nnews = \"Neuigkeiten\"\r\nlogin = \"Anmelden\"\r\nprofile = \"Profil\"\r\ncreateaccount = \"Konto erstellen\"\r\nlogout = \"Abmelden\"\r\nleaderboard = \"Bestenliste\"\r\n\r\n[header.settings]\r\nlanguage = \"Sprache\"\r\nappearance = \"Erscheinungsbild\" # Board color/theme and visual effects\r\nappearance-theme = \"Brett\"\r\nappearance-starfield = \"Sternenfeld\" # The Starfield space animation underneath void\r\nappearance-advanced-effects = \"Grafik-Effekte\" # Post processing and board tile effects at extreme distances\r\nlegalmoves = \"Legale Züge\" # Legal moves shape\r\nlegalmoves-squares = \"Quadrate\"\r\nlegalmoves-dots = \"Punkte\" # Dots and 4 corner triangles\r\nselection = \"Auswahl\"\r\nselection-drag = \"Figuren ziehen\"\r\nselection-premove = \"Vorauszüge\"\r\nselection-animations = \"Animationen\"\r\nselection-lingering_annotations = \"Verbleibende Anmerkungen\"\r\nperspective = \"Perspektive\" # Perspective-mode\r\nperspective-mouse-sensitivity = \"Mausempfindlichkeit\"\r\nperspective-fov = \"Sichtfeld\"\r\nsound = \"Ton\"\r\nsound-master-volume = \"Gesamtlautstärke\"\r\nsound-ambience = \"Umgebungsklang\"\r\nping = [\"Ping\", \"ms\"] # A number is inserted between these 2 strings.\r\nreset-to-default = \"Auf Standard zurücksetzen\"\r\n\r\n[footer]\r\ncontact = \"Kontaktieren Sie uns\"\r\nterms_of_service = \"Nutzungsbedingungen\"\r\nsource_code = \"Quellcode\"\r\nlanguage = \"Sprache\"\r\n\r\n[member.javascript]\r\njs-confirm_delete = \"Sind Sie sicher, dass Sie Ihr Konto löschen möchten? Dies kann NICHT rückgängig gemacht werden! Klicken Sie auf OK, um Ihr Passwort einzugeben.\"\r\njs-enter_password = \"Geben Sie Ihr Passwort ein, um Ihr Konto ENDGÜLTIG zu löschen:\"\r\n\r\n[leaderboard.javascript]\r\nsupported_variants = \"Diese Bestenliste wird für die folgenden Varianten verwendet:\"\r\nrank = \"Rang\"\r\nplayer = \"Spieler\"\r\nrating = \"Bewertung\"\r\n\r\n[index]\r\ntitle = \"Unendliches Schach | Startseite - Die offizielle Website\" # The tab title\r\nsecondary_title = \"Die offizielle Website für Live-Spiele!\"\r\nwhat_is_it_title = \"Was ist das?\"\r\nwhat_is_it_pargaraphs = [\r\n\"Unendliches Schach ist eine Schachvariante, bei der das Brett keinen Rand hat, und somit viel größer als das vertraute 8x8-Brett ist. Die Dame, Türme und Läufer haben <em>keine Begrenzung</em> darin, wie weit sie pro Zug ziehen können. Wählen Sie eine beliebige natürliche Zahl bis zur Unendlichkeit!\",\r\n\"Ohne Einschränkung der Zugweite sind Stellungen möglich, bei denen die Schachmatt-Uhr oder Schachmatt-in-<em>#</em>-Zahl durch die erste unendliche Ordinalzahl, <strong>ω</strong> (<em>Omega</em>), dargestellt wird. Tatsächlich haben Forscher zeigen können, dass die Schachmatt-Uhr den Wert <strong>jeder</strong> abzählbaren Ordinalzahl annehmen kann!\",\r\n\"Wie Sie sich vorstellen können, gibt es eine unendliche Anzahl an möglichen Startkonfigurationen, von denen Sie viele auf dieser Webseite kompetitiv spielen können! Ihr Endziel ist immer noch Schachmatt, was neue Taktiken erfordert, da es keine Ränder gibt, an die der feindliche König gedrängt werden kann. Ein Spiel dauert normalerweise nicht viel länger als normale Schachpartien. Bauern wandeln weiterhin auf der ersten bzw. achten Reihe um!\",\r\n]\r\nhow_to_title = \"Wie kann ich spielen?\"\r\nhow_to_paragraph = [\"Die aktuelle Version 1.10 ist auf der Seite \",\"Spielen\",\" verfügbar!\"]\r\nabout_title = \"Über das Projekt\"\r\nabout_paragraphs = [\r\n\"Ich bin Naviary. Seit ich das Unendliche Schach (das Konzept existierte lange vor dieser Website) zum ersten Mal entdeckte, hat es mich sehr fasziniert und mit seinen Möglichkeiten begeistert! Bis vor kurzem war das Spielen ziemlich schwierig, da die Spieler Bilder des aktuellen Brettes erstellen und für jeden gespielten Zug hin und her senden mussten. Deswegen wissen wenige davon oder haben es spielen können.\",\r\n[\"Mein Ziel ist es, eine Möglichkeit zu schaffen, Unendliches Schach für jeden leicht spielbar zu machen und eine Community darum herum aufzubauen. Ich habe unzählige Stunden meiner eigenen Zeit für diese Website aufgewendet, sie gepflegt und das Spiel entwickelt. Ich habe viele weitere Ideen, die mich noch einige Zeit beschäftigen werden. Obwohl ich dies kostenlos anbieten möchte, hat das Leben seine Anforderungen. Um mich finanziell zu unterstützen, ziehen Sie bitte in Betracht, meinem \", \"Patreon\", \" beizutreten.\"]\r\n]\r\npatreon_title = \"Patreon Unterstützer\"\r\ngithub_title = \"Github Mitwirkende\"\r\n\r\n[index.javascript]\r\ncontribution_count_singular = [\"\", \" Beitrag\"] # A number is inserted between these 2 strings.\r\ncontribution_count_plural = [\"\", \" Beiträge\"]\r\n\r\n[credits]\r\ntitle = \"Credits\"\r\ncopyright = \"Alles auf der Website, was nicht unten aufgeführt ist, unterliegt dem Copyright von www.InfiniteChess.org\"\r\nvariants_heading = \"Varianten\"\r\nvariants_credits = [\r\n\"Core entworfen von Andreas Tsevas.\",\r\n\"Space entworfen von Andreas Tsevas.\",\r\n\"Space Classic entworfen von Andreas Tsevas.\",\r\n\"Coaip („Chess on an Infinite Plane“) entworfen von V. Reinhart.\",\r\n\"Pawn Horde entworfen von Inaccessible Cardinal.\",\r\n\"Abundance entworfen von Clicktuck Suskriberz.\",\r\n\"Pawndard von SexyLexi.\",\r\n\"Classical+ von SexyLexi.\",\r\n\"Knightline von Inaccessible Cardinal.\",\r\n\"Knighted Chess von cycy98.\",\r\n\"entworfen von Cory Evans und Joel Hamkins.\",\r\n\"entworfen von Andreas Tsevas.\",\r\n\"entworfen von Cory Evans und Joel Hamkins.\",\r\n\"entworfen von Cory Evans, Joel Hamkins und Norman Lewis Perlmutter.\",\r\n\"Chess on an Infinite Plane - Huygens Options von V. Reinhart.\",\r\n\"Confined Classical von Andreas Tsevas.\",\r\n\"4x4x4x4 Chess von Andreas Tsevas.\",\r\n\"5D Chess von Jace.\",\r\n]\r\ntextures_heading = \"Texturen\"\r\ntextures_licensed_under = \"Texturen lizenziert unter der\"\r\nsounds_heading = \"Sounds\"\r\nsounds_credits = [\r\n[\"Einige Sounds werden vom\", \"Projekt unter der\"],\r\n\"Andere Sounds erstellt von Naviary.\",\r\n]\r\ncode_heading = \"Code\"\r\ncode_credits = [\r\n\"von Brandon Jones und Colin MacKenzie IV.\",\r\n\"von Andreas Tsevas und Naviary.\",\r\n]\r\nlanguage_heading = \"Sprachübersetzungen\"\r\nlanguage_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded.\r\n\"Französisch von \", \"Life Enjoyer\", \" und \", \"cycy98\", \".\",\r\n\"Vereinfachtes Chinesisch von \", \"Heinrich Xiao\", \".\",\r\n\"Traditionelles Chinesisch von \", \"Heinrich Xiao\", \".\",\r\n\"Polnisch von \", \"Tymon Becella\", \".\", # Apsurt\r\n\"Portugiesisch von \", \"Emerson P. Machado\", \".\", # The_Skeleton on discord\r\n\"Spanisch von \", \"xa31er\", \".\",\r\n\"Deutsch von \", \"Estetique\", \".\"\r\n]\r\n\r\n[member]\r\ntitle = \"Mitglied\" # The tab name\r\nverify_message = \"Bitte überprüfen Sie Ihre E-Mails, um Ihr Konto zu verifizieren. Unverifizierte Konten werden nach 3 Tagen gelöscht.\"\r\nresend_message = [\"Keine E-Mail erhalten? Überprüfen Sie Ihren Spam-Ordner. Versuchen Sie auch, die \", \"E-Mail erneut zu senden.\", \" Wenn Sie sie immer noch nicht finden können, \", \"kontaktieren Sie uns.\"]\r\nverify_confirm = \"Vielen Dank! Ihr Konto wurde verifiziert.\"\r\njoined = \"Beigetreten:\"\r\nseen = [\"Zuletzt gesehen:\", \" her\"]\r\npractice_progress = \"Fortschritt im Übungsmodus:\"\r\nranked_elo = \"Rating:\"\r\ninfinity_leaderboard_position = \"Globale Rangliste:\"\r\ninfinity_leaderboard_rating_deviation = \"Rating-Abweichung:\"\r\nreveal_info = \"Kontoinformationen anzeigen\"\r\naccount_info_heading = \"Kontoinformationen\"\r\nemail = \"E-Mail:\"\r\ndelete_account = \"Konto löschen\"\r\n\r\n[member.badge-tooltips]\r\ncheckmate_bronze = \"Schachmatt-Veteran: 50% aller Übungs-Schachmattes lösen.\"\r\ncheckmate_silver = \"Schachmatt-Profi: 75% aller Übungs-Schachmattes lösen.\"\r\ncheckmate_gold = \"Schachmatt-Meister: 100% aller Übungs-Schachmattes lösen.\"\r\n\r\n[create-account]\r\ntitle = \"Konto erstellen\" # The tab name\r\nusername = \"Benutzername:\"\r\nemail = \"E-Mail:\"\r\npassword = \"Passwort:\"\r\ncreate_button = \"Konto erstellen\"\r\nagreement = [\"Ich stimme den \", \"Nutzungsbedingungen\", \" zu.\"]  # the middle entry is a hyperlink, the others are not\r\n\r\n[create-account.javascript]\r\njs-username_tooshort = \"Der Benutzername muss mindestens 3 Zeichen lang sein\"\r\njs-username_wrongenc = \"Der Benutzername darf nur Groß- und Kleinbuchstaben von A bis Z und Zahlen von 0 bis 9 enthalten\"\r\njs-email_invalid = \"Dies ist keine gültige E-Mail\"\r\njs-email_too_long = \"Die E-Mail ist zu lang\"\r\njs-email_inuse = \"Diese E-Mail ist bereits in Verwendung\"\r\n\r\n[reset-password.javascript]\r\njs-pwd_no_match = \"Die Passwörter stimmen nicht überein.\"\r\nreset-password = \"Passwort zurücksetzen\"\r\nprocessing = \"In Bearbeitung...\"\r\nnetwork-error = \"Es ist ein Netzwerkfehler aufgetreten. Bitte versuchen Sie es erneut.\"\r\n\r\n[password-validation]\r\njs-pwd_too_short = \"Passwort muss mindestens 6 Zeichen lang sein\"\r\njs-pwd_too_long = \"Passwort darf nicht länger als 72 Zeichen sein\"\r\njs-pwd_not_pwd = \"Passwort darf nicht 'password' sein\"\r\n\r\n[leaderboard]\r\ntitle = \"Bestenliste\"\r\ninactive_players = [\"Inaktive Spieler mit Rating-Unsicherheit größer als \", \" sind von der Bestenliste ausgeschlossen.\"] # A number is inserted between these two quotes\r\nyour_global_ranking = \"Ihr globaler Rang:\"\r\nshow_more = \"Mehr anzeigen...\"\r\n\r\n[play]\r\ntitle = \"Unendliches Schach - Spielen\" # The tab title\r\nloading = \"LÄDT\"\r\nerror = \"FEHLER\"\r\n\r\n[play.main-menu]\r\ncredits = \"Credits\"\r\nplay = \"Spielen\"\r\npractice = \"Üben\"\r\nguide = \"Anleitung\"\r\neditor = \"Brett-Editor\"\r\n\r\n[play.guide]\r\ntitle = \"Anleitung\"\r\nrules = \"Regeln\"\r\nrules_paragraphs = [\r\n\"Die Regeln des Unendlichen Schachs sind fast identisch mit denen des klassischen Schachs, außer dass das Brett in alle Richtungen unendlich ist! Dies sind die einzigen Hinweise und Änderungen, die Sie beachten müssen:\",\r\n\"Figuren mit gleitenden Zügen, wie Türme, Läufer, und Damen, haben keine Einschränkung darin, wie weit sie in einem Zug ziehen können! Solange ihr Weg frei ist, können sie Millionen von Feldern ziehen!\",\r\n[\"In der Grundvariante „Klassisch“ wandeln weiße Bauern auf Reihe 8 und schwarze Bauern auf Reihe 1 um. Dies wird, wie Sie im Bild sehen können, durch die dünnen schwarzen Linien angedeutet. Bauern müssen nur die gegenüberliegende Linie erreichen, \", \"nicht\", \" sie überqueren, um umzuwandeln.\"],\r\n\"Felder werden nicht länger durch ihren Buchstaben und ihre Reihennummer (z.B. a1) beschrieben; stattdessen wird jedes Feld durch ein Paar von x- und y-Koordinaten definiert. Das Feld a1 ist zu (1,1) geworden, und das Feld h8 zu (8,8). Auf Desktop-Geräten werden die Koordinaten, über denen sich Ihre Maus befindet, oben auf dem Bildschirm angezeigt.\",\r\n\"Alle anderen Regeln sind die gleichen wie im klassischen Schach, wie Schachmatt, Patt, dreifache Stellungswiederholung, die 50-Züge-Regel, Rochade, En Passant usw.!\"\r\n]\r\ncareful_heading = \"Seien Sie vorsichtig!\"\r\ncareful_paragraphs = [\r\n\"Die Offenheit des unendlichen Bretts macht es sehr einfach, Gabeln, Fesselungen und Spieße auszunutzen. Ihre Rückseite ist oft sehr anfällig. Achten Sie auf solche Taktiken! Seien Sie kreativ beim Bilden von Schutz für Ihren König und Ihre Türme! Die Eröffnungsstrategie unterscheidet sich stark vom klassischen Schach.\",\r\n\"Viele andere Varianten wurden geschaffen, um Ihre Rückseite zu stärken.\"\r\n]\r\ncontrols_heading = \"Steuerung\"\r\ncontrols_paragraph = \"Klicken und ziehen Sie das Brett, um sich zu bewegen. Scrollen Sie, um hinein- und herauszuzoomen. Klicken Sie auf eine beliebige Figur, einschließlich der Figuren Ihres Gegners, um jederzeit ihre legalen Züge anzuzeigen! Zusätzliche Steuerelemente sind:\"\r\nkeybinds = [\r\n\" um das Sichtfeld zu bewegen.\",\r\n[\"Leertaste\", \" und \", \"Shift\", \" zum Hinein- und Herauszoomen.\"],\r\n[\"Escape\", \" pausiert das Spiel.\"],\r\n[\"Tab\", \" schaltet die Pfeilindikatoren am Bildschirmrand um, die auf Figuren außerhalb des Bildschirms weisen. Standardmäßig ist diese Einstellung auf „Verteidigung“ eingestellt, sodass Pfeile für die Figuren angezeigt werden, die sich auf Felder ihres Sichtfeldes bewegen könnten. \", \"Tab\", \" kann diesen Modus auf „Alle“ oder „Aus“ umschalten; „Alle“ zeigt Pfeile für alle Figuren an, unabhängig davon, ob sie sich auf Ihren Bildschirm bewegen können. Diese Einstellung kann auch im Pausenmenü umgeschaltet werden. Das Klicken auf einen Pfeil teleportiert Sie zu der Figur, auf die er zeigt.\"],\r\n[\"Strg\", \" erzwingt das Ziehen des Bretts anstatt einer Figur, wenn das Ziehen in den Einstellungen aktiviert ist.\"],\r\n\" schaltet auf den „Bearbeitungsmodus“ in lokalen Spielen um. Dies ermöglicht Ihnen, jede Figur überall auf das Brett zu bewegen! Sehr nützlich für die Analyse.\"\r\n ]\r\ncontrols_paragraph2 = \"Das waren die wichtigsten Steuerungen, die Sie kennen müssen. Aber hier sind einige Extras, falls Sie sie jemals benötigen sollten!\"\r\nkeybinds_extra = [\r\n\" setzt die Darstellung der Figuren zurück. Das ist nützlich, wenn sie unsichtbar werden. Dieser Glitch kann auftreten, wenn Sie die Figuren extreme Entfernungen (wie 1e21) bewegen.\",\r\n\" schaltet die Darstellung der Navigations- und Spielinformationsleisten um, was für Aufnahmen nützlich sein kann. Streaming und das Erstellen von Videos über das Spiel sind willkommen!\",\r\n\" schaltet den FPS-Zähler ein. Dieser zeigt an, wie oft das Spiel pro Sekunde aktualisiert wird, nicht immer die Anzahl der gerenderten Frames, da das Spiel das Rendern überspringt, wenn sich nichts Sichtbares geändert hat, um die Leistung zu steigern.\",\r\n\" schaltet die das Rendering der Icons um. Diese sind anklickbare Mini-Bilder der Figuren, wenn weit genug herausgezoomt wird. Bei importierten Spielen mit über 50.000 Figuren wird dies automatisch ausgeschaltet, da es die Leistung drastisch reduziert.\",\r\n[\" (Backtick, auf der gleichen Taste wie \", \") schaltet in den Debug-Modus um.\"],\r\n]\r\nfairy_heading = \"Märchenschachfiguren\"\r\nfairy_paragraph = \"Sie wissen bereits alles Nötige, um die Standardvariante „Klassisch“ zu spielen. Märchenschachfiguren werden im konventionellen Schach nicht verwendet, sind aber in andere Varianten integriert! Wenn Sie sich in einer Variante mit Ihnen unbekannten Figuren wiederfinden, erfahren Sie hier, wie sie funktionieren!\"\r\nediting_heading = \"Brettbearbeitung\"\r\nediting_paragraphs = [\r\n[\"Es ist derzeit ein externer \", \"Brett-Editor\", \" auf einem öffentlichen Google Sheet verfügbar! Dieses enthält Anweisungen zur Verwendung und erfordert grundlegende Google Sheets-Kenntnisse. Nach der Einrichtung können Sie benutzerdefinierte Positionen über die Schaltfläche „Spiel einfügen“ im Optionsmenü erstellen und in das Spiel importieren!\"],\r\n\"Um eine benutzerdefinierte Position mit einem Freund zu spielen, lassen Sie ihn eine private Einladung annehmen, dann können Sie beide den Spielcode einfügen, um zu spielen!\",\r\n\"Ein In-Game-Bretteditor ist noch geplant.\",\r\n]\r\nback = \"Zurück\"\r\n\r\n[play.guide.pieces]\r\nchancellor = {name=\"Kanzler\", description=\"Zieht wie ein Turm und ein Springer zusammen.\"}\r\narchbishop = {name=\"Erzbischof\", description=\"Zieht wie ein Läufer und ein Springer zusammen.\"}\r\namazon = {name=\"Amazone\", description=\"Zieht wie eine Dame und ein Springer zusammen. Dies ist die stärkste Figur im Spiel!\"}\r\nguard = {name=\"Wache\", description=\"Zieht wie ein König, ist aber nicht anfällig für Schach oder Schachmatt.\"}\r\nhawk = {name=\"Falke\", description=\"Springt genau 2 oder 3 Felder in jede Richtung.\"}\r\ncentaur = {name=\"Zentaur\", description=\"Zieht wie ein Springer und eine Wache zusammen.\"}\r\nknightrider = {name=\"Rösselsprinter\", description=\"Springt wie ein Springer unendlich weit in eine Richtung, bis er blockiert wird.\"}\r\nhuygen = {name=\"Huygen\", description=\"Springt unendlich weit in eine der vier Kardinalrichtungen, wobei er nur Felder mit primzahligem Abstand vom Startfeld besucht, bis er blockiert wird.\"}\r\nrose = {name=\"Rose\", description=\"Zirkulärer Rösselsprinter. Die Rose bewegt sich auf kreisförmigen Bahnen im oder gegen den Uhrzeigersinn, indem sie wie ein Springer hüpft und sich nach jedem Hüpfen um 45 Grad dreht. Die Rose kann von anderen Figuren blockiert werden, weshalb das rote Feld im Bild für die Rose unerreichbar ist.\"}\r\nobstacle = {name=\"Hindernis\", description=\"Eine neutrale Figur (von keinem Spieler kontrolliert), die die Bewegung blockiert, aber geschlagen werden kann.\"}\r\nvoid = {name=\"Leere\", description=\"Eine neutrale Figur (nicht von einem Spieler kontrolliert), die die Abwesenheit eines Feldes darstellt. Figuren dürfen sich nicht durch oder auf sie bewegen.\"}\r\n\r\n[play.practice-menu]\r\ntitle = \"Übung - Schachmatts\"\r\nplay = \"Spielen\"\r\nback = \"Zurück\"\r\ndifficulty = \"Schwierigkeit\"\r\n\r\n[play.play-menu]\r\ntitle = \"Spielen - Online\"\r\ncolors = \"Farben\"\r\nonline = \"Online\"\r\nlocal = \"Lokal\"\r\ncomputer = \"Computer\"\r\nvariant = \"Variante\"\r\nClassical = \"Klassisch\"\r\nConfined_Classical = \"Eingegrenztes Klassisch\"\r\nClassical_Plus = \"Klassisch+\"\r\nCoaIP = \"Schach auf einer unendlichen Ebene\"\r\nPawndard = \"Pawndard\"\r\nKnighted_Chess = \"Springer-Schach\"\r\nPalace = \"Palast\"\r\nKnightline = \"Springerschnur\"\r\nCore = \"Kern\"\r\nStandarch = \"Standard\"\r\nPawn_Horde = \"Bauernhorde\"\r\nSpace_Classic = \"Raum Klassisch\"\r\nSpace = \"Raum\"\r\nObstocean = \"Hindernismeer\"\r\nAbundance = \"Überfluss\"\r\nAmazon_Chandelier = \"Amazonenleuchter\"\r\nContainment = \"Eingrenzung\"\r\nClassical_Limit_7 = \"Klassisch - Limit 7\"\r\nCoaIP_Limit_7 = \"CoaIP - Limit 7\"\r\nChess = \"Schach\"\r\nClassical_KOTH = \"Experimentell: Klassisch - KOTH\"\r\nCoaIP_KOTH = \"Experimentell: CoaIP - KOTH\"\r\nCoaIP_HO = \"Schach auf einer unendlichen Ebene - Huygens Option\"\r\nCoaIP_RO = \"Schach auf einer unendlichen Ebene - Rosen Option\"\r\nCoaIP_NO = \"Schach auf einer unendlichen Ebene - Rösselsprinter Option\"\r\nOmega = \"Showcase: Omega\"\r\nOmega_Squared = \"Showcase: Omega^2\"\r\nOmega_Cubed = \"Showcase: Omega^3\"\r\nOmega_Fourth = \"Showcase: Omega^4\"\r\n4x4x4x4_Chess = \"4×4×4×4 Schach\"\r\n5D_Chess = \"5D Schach\"\r\nno_clock = \"Keine Uhr\"\r\nclock = \"Uhr\"\r\nminutes = \"min\"\r\nseconds = \"s\"\r\ninfinite_time = \"Unendliche Zeit\"\r\ncolor = \"Farbe\"\r\npiece_colors = [\"Zufällig\", \"Weiß\", \"Schwarz\"]\r\nprivate = \"Privat\"\r\nno = \"Nein\"\r\nyes = \"Ja\"\r\nrated = \"Gewertet\"\r\ncasual = \"Ungewertet\"\r\neasy = \"Einfach\"\r\nmedium = \"Mittel\"\r\nhard = \"Schwer\"\r\njoin_games = \"Beitreten - Aktive Spiele:\"\r\nprivate_invite = \"Private Einladung:\"\r\nyour_invite = \"Ihr Einladungscode:\"\r\ncreate_invite = \"Einladung erstellen\"\r\njoin = \"Beitreten\"\r\ncopy = \"Kopieren\"\r\nback = \"Zurück\"\r\ncode = \"Code\"\r\n\r\n[play.gamebuttontooltips]\r\nundo_transition = \"Übergang rückgängig machen\"\r\nexpand_fit_all = \"Auf alle passen\"\r\nrecenter = \"Zentrieren\"\r\nannotations = \"Anmerkungen zeichnen\"\r\nerase = \"Anmerkungen löschen\"\r\ncollapse = \"Anmerkungen einklappen\"\r\nrewind_move = \"Zug zurückspulen\"\r\nforward_move = \"Zug vorspulen\"\r\nundo_edit = \"Rückgängig (Strg+Z)\" # Board editor\r\nredo_edit = \"Wiederherstellen (Strg+Y)\" # Board editor\r\npause = \"Pause\"\r\nundo = \"Zug rückgängig machen\" # Checkmate practice game\r\nrestart = \"Spiel neu starten\" # Checkmate practice game\r\n\r\n[play.pause]\r\ntitle = \"Pausiert\"\r\nresume = \"Fortsetzen\"\r\narrows = \"Pfeile: Verteidigung\"\r\nperspective = \"Perspektive: Aus\"\r\ncopy = \"Spiel kopieren\"\r\npaste = \"Spiel einfügen\"\r\noffer_draw = \"Remis anbieten\"\r\npractice_menu = \"Übungsmenü\"\r\nmain_menu = \"Hauptmenü\"\r\n\r\n[play.drawoffer] # The draw offer UI that appears on the bottom bar\r\nquestion = \"Remis akzeptieren?\"\r\n\r\n[play.javascript] # Not text that's included in the html, but text that scripts use!\r\nguest_indicator = \"(Gast)\"\r\nyou_indicator = \"(Sie)\"\r\nengine_indicator = \"Engine\"\r\nplayer_name_white_generic = \"Weiß\"\r\nplayer_name_black_generic = \"Schwarz\"\r\nwhite_to_move = \"Weiß am Zug\"\r\nblack_to_move = \"Schwarz am Zug\"\r\nyour_move = \"Ihr Zug\"\r\ntheir_move = \"Zug des Gegners\"\r\nlost_network = \"Netzwerk verloren.\"\r\nfailed_to_load = \"Eine oder mehrere Ressourcen konnten nicht geladen werden. Bitte aktualisieren Sie.\"\r\nplanned_feature = \"Diese Funktion ist geplant!\"\r\nmain_menu = \"Hauptmenü\"\r\nresign_game = \"Aufgeben\"\r\nabort_game = \"Spiel abbrechen\"\r\noffer_draw = \"Remis anbieten\" # Offer draw button text in the pause menu\r\naccept_draw = \"Remis akzeptieren\" # Offer draw button text in the pause menu\r\narrows_off = \"Pfeile: Aus\"\r\narrows_defense = \"Pfeile: Verteidigung\"\r\narrows_all = \"Pfeile: Alle\"\r\narrows_all_hippogonals = \"Pfeile: Alle (mit Hippogonalen)\"\r\ntoggled = \"Eingeschaltet\"\r\nmenu_online = \"Spielen - Online\"\r\nmenu_local = \"Spielen - Lokal\"\r\nmenu_computer = \"Spielen - Computer\"\r\ninvite_error_digits = \"Einladungscode muss 5 Ziffern lang sein.\"\r\ninvite_copied = \"Einladungscode in Zwischenablage kopiert.\"\r\nmove_counter = \"Zug:\"\r\nconstructing_mesh = \"Mesh wird konstruiert\"\r\nrotating_mesh = \"Mesh wird gedreht\"\r\nlost_connection = \"Verbindung verloren.\"\r\nplease_wait = \"Bitte warten Sie einen Moment, um diese Aufgabe auszuführen.\"\r\nwebgl_unsupported = \"Bitte aktualisieren Sie Ihren Browser! Er unterstützt WebGL2 nicht.\"\r\nbigints_unsupported = \"BigInts werden nicht unterstützt. Bitte aktualisieren Sie Ihren Browser.\\nBigInts werden benötigt, um das Brett unendlich zu machen.\"\r\n# Checkmate Practice\r\nversus = \"gegen\"\r\neasy = \"Leicht\"\r\nmedium = \"Mittel\"\r\nhard = \"Schwer\"\r\ninsane = \"Wahnsinnig\"\r\ncheckmate_logged_out = \"Sie müssen angemeldet sein, um Abzeichen zu verdienen.\"\r\ncheckmate_bronze = \"Schachmatt-Veteran: 50% aller Übungs-Schachmattes lösen.\"\r\ncheckmate_silver = \"Schachmatt-Profi: 75% aller Übungs-Schachmattes lösen.\"\r\ncheckmate_gold = \"Schachmatt-Meister: 100% aller Übungs-Schachmattes lösen.\"\r\ncheckmate_bronze_unearned = \"Lösen Sie 50% aller Übungs-Schachmattes, um dieses Abzeichen zu verdienen.\"\r\ncheckmate_silver_unearned = \"Lösen Sie 75% aller Übungs-Schachmattes, um dieses Abzeichen zu verdienen.\"\r\ncheckmate_gold_unearned = \"Lösen Sie 100% aller Übungs-Schachmattes, um dieses Abzeichen zu verdienen.\"\r\ncoords-invalid = \"Ungültiges Koordinatenformat. Bitte ganze Zahlen oder e-Notation eingeben (z. B. 1.23e4).\"\r\ncoords-exceeded = \"So weit darf man nicht teleportieren! Das wäre ja zu einfach ;)\"\r\n\r\n[play.javascript.piecenames] # The string representations of each raw piece type, as found in typeutil.strtypes\r\nvoid = \"Leere\"\r\nobstacle = \"Hindernis\"\r\nking = \"König\"\r\ngiraffe = \"Giraffe\"\r\ncamel = \"Kamel\"\r\nzebra = \"Zebra\"\r\nknightrider = \"Rösselsprinter\"\r\namazon = \"Amazone\"\r\nqueen = \"Dame\"\r\nroyalQueen = \"Royale Dame\"\r\nhawk = \"Falke\"\r\nchancellor = \"Kanzler\"\r\narchbishop = \"Erzbischof\"\r\ncentaur = \"Zentaur\"\r\nroyalCentaur = \"Royaler Zentaur\"\r\nrose = \"Rose\"\r\nknight = \"Springer\"\r\nguard = \"Wache\"\r\nhuygen = \"Huygen\"\r\nrook = \"Turm\"\r\nbishop = \"Läufer\"\r\npawn = \"Bauer\"\r\n\r\n[play.javascript.copypaste]\r\ncopied_game = \"Spiel in Zwischenablage kopiert!\"\r\ncannot_paste_in_public = \"Spiel kann nicht in einem öffentlichen Match eingefügt werden!\"\r\ncannot_paste_in_rated = \"Spiel kann nicht in einem gewerteten Match eingefügt werden!\"\r\ncannot_paste_in_engine = \"Spiel kann nicht in einem Engine-Match eingefügt werden!\"\r\ncannot_paste_after_moves = \"Spiel kann nicht eingefügt werden, nachdem gezogen wurde!\"\r\nclipboard_denied = \"Zwischenablage-Berechtigung verweigert. Dies könnte Ihr Browser sein.\"\r\nclipboard_invalid = \"Zwischenablage nicht in gültiger ICN-Notation.\"\r\ngame_needs_to_specify = \"Spiel muss entweder die Metadaten 'Variante' oder die Eigenschaft 'Position' angeben.\"\r\ninvalid_wincon = \"Spieler hat eine ungültige Gewinnbedingung\"\r\npasting_game = \"Spiel wird eingefügt...\"\r\npasting_in_private = \"Das Einfügen eines Spiels in einem privaten Match führt zu einer Desynchronisation, wenn Ihr Gegner nicht dasselbe tut!\"\r\npiece_count = \"Figurenanzahl\"\r\nexceeded = \"überschritten\"\r\nchanged_wincon = \"Schachmatt-Gewinnbedingungen wurden auf Königsfang geändert und die Icon-Darstellung deaktiviert. Drücken Sie 'P' zum Reaktivieren (nicht empfohlen).\"\r\nloaded_from_clipboard = \"Spiel aus Zwischenablage geladen!\"\r\ncopied_position = \"Position in Zwischenablage kopiert!\"\r\nloaded_position_from_clipboard = \"Position aus Zwischenablage geladen!\"\r\nreset_position = \"Position wurde zurückgesetzt!\"\r\nclear_position = \"Position wurde geleert!\"\r\n\r\n[play.javascript.rendering]\r\non = \"An\"\r\noff = \"Aus\"\r\nicon_rendering_off = \"Icon-Darstellung deaktiviert.\"\r\nicon_rendering_on = \"Icon-Darstellung aktiviert.\"\r\nperspective = \"Perspektive\"\r\nperspective_mode_on_desktop = \"Perspektivmodus ist auf dem Desktop verfügbar!\"\r\nmovement_tutorial = \"WASD zum Bewegen. Leertaste und Shift zum Zoomen.\"\r\nregenerated_pieces = \"Figuren regeneriert.\"\r\n\r\n[play.javascript.invites]\r\nmove_mouse = \"Bewegen Sie die Maus, um die Verbindung wiederherzustellen.\"\r\ncannot_cancel = \"Einladung mit undefinierter ID kann nicht abgebrochen werden.\"\r\nyou_are_white = \"Sie sind: Weiß\"\r\nyou_are_black = \"Sie sind: Schwarz\"\r\nrandom = \"Zufällig\"\r\naccept = \"Annehmen\"\r\ncancel = \"Abbrechen\"\r\ncreate_invite = \"Einladung erstellen\"\r\ncancel_invite = \"Einladung abbrechen\"\r\nstart_game = \"Spiel starten\"\r\njoin_existing_active_games = \"Beitreten - Aktive Spiele:\"\r\n\r\n[play.javascript.onlinegame]\r\nafk_warning = \"Sie sind AFK.\"\r\nopponent_afk = \"Gegner ist AFK.\"\r\nopponent_disconnected = \"Gegner hat die Verbindung getrennt.\"\r\nopponent_lost_connection = \"Gegner hat die Verbindung verloren.\"\r\nauto_resigning_in = \"Automatische Aufgabe in\"\r\nauto_aborting_in = \"Automatischer Abbruch in\"\r\nnot_logged_in = \"Sie sind nicht angemeldet. Bitte melden Sie sich an, um die Verbindung zu diesem Spiel wiederherzustellen.\"\r\ngame_no_longer_exists = \"Spiel existiert nicht mehr.\"\r\nanother_window_connected = \"Ein anderes Fenster hat sich verbunden.\"\r\nserver_restarting = \"Server wird in Kürze neu gestartet...\"\r\nserver_restarting_in = \"Server wird neu gestartet in\"\r\nminute = \"Minute\"\r\nminutes = \"Minuten\"\r\n\r\n[play.javascript.websocket]\r\nno_connection = \"Keine Verbindung.\"\r\nreconnected = \"Wieder verbunden.\"\r\nunable_to_identify_ip = \"IP-Adresse kann nicht identifiziert werden.\"\r\nonline_play_disabled = \"Online-Spiel deaktiviert. Cookies werden nicht unterstützt. Versuchen Sie einen anderen Browser.\"\r\ntoo_many_requests = \"Zu viele Anfragen. Versuchen Sie es später erneut.\"\r\nmessage_too_big = \"Nachricht zu groß.\"\r\ntoo_many_sockets = \"Zu viele Sockets\"\r\norigin_error = \"Ursprungsfehler.\"\r\nconnection_closed = \"Verbindung unerwartet getrennt. Server-Nachricht:\"\r\nplease_report_bug = \"Dies sollte niemals passieren, bitte melden Sie diesen Fehler!\"\r\n\r\n[play.javascript.termination] # What caused the termination of the game, in spoken language\r\ncheckmate = \"Schachmatt\"\r\nstalemate = \"Patt\"\r\nrepetition = \"Dreifache Stellungswiederholung\"\r\nmoverule = [\"\", \"-Züge-Regel\"]  # The game inserts a number inbetween these two strings\r\ninsuffmat = \"Unzureichendes Material\"\r\nroyalcapture = \"Königsfang\"\r\nallroyalscaptured = \"Alle Könige geschlagen\"\r\nallpiecescaptured = \"Alle Figuren geschlagen\"\r\nkoth = \"König des Hügels\"\r\nresignation = \"Aufgabe\"\r\nagreement = \"Vereinbarung\"\r\ntime = \"Zeitüberschreitung\"\r\naborted = \"Abgebrochen\" # Game was cancelled (no elo exchanged)\r\ndisconnect = \"Verlassen\" # A player left\r\n\r\n[play.javascript.results]\r\nyou_checkmate = \"Sie haben gewonnen durch Schachmatt!\"\r\nyou_time = \"Sie haben auf Zeit gewonnen!\"\r\nyou_resignation = \"Sie haben gewonnen durch Aufgabe!\"\r\nyou_disconnect = \"Sie haben gewonnen durch Verlassen!\"\r\nyou_royalcapture = \"Sie haben durch Schlagen des Königs gewonnen!\"\r\nyou_allroyalscaptured = \"Sie haben gewonnen, da alle Könige geschlagen wurden!\"\r\nyou_allpiecescaptured = \"Sie haben gewonnen, da alle Figuren geschlagen wurden!\"\r\nyou_koth = \"Sie haben durch König des Hügels gewonnen!\"\r\nyou_generic = \"Sie haben gewonnen!\"\r\ndraw_stalemate = \"Remis durch Patt!\"\r\ndraw_repetition = \"Remis durch Stellungswiederholung!\"\r\ndraw_moverule = [\"Remis durch die \", \"-Züge-Regel!\"] # The game inserts a number inbetween these two strings\r\ndraw_insuffmat = \"Remis aufgrund unzureichenden Materials!\"\r\ndraw_agreement = \"Remis durch Vereinbarung!\"\r\ndraw_generic = \"Remis!\"\r\naborted = \"Spiel abgebrochen.\"\r\nopponent_checkmate = \"Sie haben verloren durch Schachmatt!\"\r\nopponent_time = \"Sie haben auf Zeit verloren!\"\r\nopponent_resignation = \"Sie haben verloren durch Aufgabe!\"\r\nopponent_disconnect = \"Sie haben verloren durch Verlassen!\"\r\nopponent_royalcapture = \"Sie haben durch Schlagen des Königs verloren!\"\r\nopponent_allroyalscaptured = \"Sie haben verloren, da alle Könige gefangen wurden!\"\r\nopponent_allpiecescaptured = \"Sie haben verloren, da alle Figuren gefangen wurden!\"\r\nopponent_koth = \"Sie haben durch König des Hügels verloren!\"\r\nopponent_generic = \"Sie haben verloren!\"\r\nwhite_checkmate = \"Weiß gewinnt durch Schachmatt!\"\r\nblack_checkmate = \"Schwarz gewinnt durch Schachmatt!\"\r\nwhite_time = \"Weiß gewinnt auf Zeit!\"\r\nblack_time = \"Schwarz gewinnt auf Zeit!\"\r\nwhite_resignation = \"Weiß gewinnt durch Aufgabe!\"\r\nblack_resignation = \"Schwarz gewinnt durch Aufgabe!\"\r\nwhite_disconnect = \"Weiß gewinnt durch Verlassen!\"\r\nblack_disconnect = \"Schwarz gewinnt durch Verlassen!\"\r\nwhite_royalcapture = \"Weiß gewinnt durch Schlagen des Königs!\"\r\nblack_royalcapture = \"Schwarz gewinnt durch Schlagen des Königs!\"\r\nwhite_allroyalscaptured = \"Weiß gewinnt, da alle Könige geschlagen wurden!\"\r\nblack_allroyalscaptured = \"Schwarz gewinnt, da alle Könige geschlagen wurden!\"\r\nwhite_allpiecescaptured = \"Weiß gewinnt, da alle Figuren geschlagen wurden!\"\r\nblack_allpiecescaptured = \"Schwarz gewinnt, da alle Figuren geschlagen wurden!\"\r\nwhite_koth = \"Weiß gewinnt durch König des Hügels!\"\r\nblack_koth = \"Schwarz gewinnt durch König des Hügels!\"\r\nbug_generic = \"Ein Fehler ist aufgetreten, bitte melden Sie ihn!\"\r\n\r\n[terms]\r\ntitle = \"Nutzungsbedingungen\"\r\nwarning = [\"DIESES DOKUMENT IST RECHTLICH NICHT BINDEND. Wir sind nur für die englische Version dieses Dokuments verantwortlich. Diese Übersetzung dient ausschließlich zu allgemeinen Informationszwecken. Sie können die offizielle englische Version \", \"hier\", \" einsehen.\"]\r\nconsent = \"Durch die Nutzung dieser Website erklären Sie sich mit den folgenden Bedingungen einverstanden. Wenn Sie nicht zustimmen, müssen Sie die Nutzung der Website sofort einstellen.\"\r\nguardian_consent = \"Wenn Sie unter 18 Jahre alt sind, müssen Sie die Zustimmung eines Elternteils oder Erziehungsberechtigten einholen, um diese Website zu nutzen und ein Konto zu erstellen.\"\r\nparents_header = \"Eltern\"\r\nparents_paragraphs = [\r\n\"Es gibt einen Algorithmus, der Benutzern das Festlegen ihres Namens auf gängige Schimpfwörter verbietet. Derzeit gibt es keine Kommunikationsmethode zwischen Mitgliedern auf der Website.\",\r\n\"Derzeit können Mitglieder kein eigenes Profilbild einstellen. Es ist geplant, diese Funktion zu erlauben. Zu diesem Zeitpunkt werden wir unser Bestes tun, um unangemessene Profilbilder zu verhindern.\",\r\n]\r\nfair_play_header = \"Fair Play\"\r\nfair_play_paragraph1 = [\"Sie dürfen nicht mehr als ein Konto besitzen.\"]\r\nfair_play_paragraph2 = \"Um das Spielen für alle unterhaltsam und fair zu halten, dürfen Sie NICHT:\"\r\nfair_play_rules = [\r\n\"Den Code in irgendeiner Weise modifizieren oder manipulieren, einschließlich, aber nicht beschränkt auf: Verwendung von Konsolenbefehlen, lokalen Überschreibungen, benutzerdefinierten Skripten, Modifikation von HTTP-Anfragen, WebSocket-Nachrichten usw. Dies kann geschehen, um das Spiel absichtlich zu stören, illegale Züge zu spielen oder sich einen Vorteil zu verschaffen.\",\r\n\"Bugs oder Glitches ausnutzen, um das Spiel abzubrechen, sich einen Vorteil zu verschaffen oder das Spiel anderweitig unspielbar zu machen.\",\r\n\"In gewerteten Spielen Hilfe/Ratschläge von einer anderen Person oder einem Programm bezüglich Ihrer Züge erhalten. (Das Erstellen einer Engine ist in Ordnung und erwünscht, aber Sie müssen deren Verwendung auf ungewertete Gelegenheitsspiele beschränken)\",\r\n\"Rating-Punkte mit anderen Personen tauschen, indem Sie absichtlich verlieren, um das Rating Ihres Gegners zu erhöhen, oder indem Sie Rating-Punkte von einem Gegner erhalten, der beabsichtigt zu verlieren, um Ihre eigene Wertung zu erhöhen. Dies missbraucht das System und führt zu ungenauen Wertungen gemäß Ihrem Fähigkeitsniveau.\"\r\n]\r\ncleanliness_header = \"Sauberkeit\"\r\ncleanliness_rules = [\r\n\"In all Ihrer Sprache auf der Website müssen Sie sauber bleiben, keine Obszönitäten oder Flüche verwenden. Sie dürfen niemanden schikanieren, belästigen oder bedrohen oder etwas Illegales tun. Sie dürfen keine anderen Benutzer oder Foren spammen.\",\r\n\"Sie dürfen keine unangemessenen, anzüglichen oder blutigen Bilder in Ihr Profil hochladen. Dies kann zu einem Bann oder zur Kündigung Ihres Kontos führen.\"\r\n]\r\nprivacy_header = \"Datenschutz\"\r\nprivacy_rules = [\r\n\"Derzeit sammeln wir nur Ihre E-Mail-Adresse als persönliche Information. Dies dient dazu, die Konten der Nutzer zu verifizieren und eine Möglichkeit zu bieten, ihre Identität bei der Anforderung eines Passwort-Resets nachzuweisen. Wir versenden keine Werbe-E-Mails oder Angebote. Wir teilen keine E-Mail-Adresse von Nutzern mit anderen.\",\r\n\"InfiniteChess.org kann Daten über Ihre Nutzung der Website sammeln, einschließlich Ihrer IP-Adresse. Dies soll dazu beitragen, Angriffe von Bots und anderen unerwünschten Entitäten zu verhindern und genaue Statistiken in der Datenbank zu führen. Dies ist NICHT Ihre Wohnadresse.\",\r\n\"Alle Spiele, die Sie auf dieser Website spielen, werden zu öffentlichen Informationen. Wenn Sie anonym bleiben möchten, teilen Sie Ihren Benutzernamen nicht mit Freunden oder Familie. Wenn dies Ihr Wunsch ist, liegt es in Ihrer Verantwortung, sicherzustellen, dass niemand herausfindet, dass Ihr Benutzername mit Ihrer menschlichen Identität verbunden ist.\",\r\n\"Ihr Online-Status und die ungefähre letzte Aktivitätszeit auf der Website sind ebenfalls öffentliche Informationen.\",\r\n[\"Während InfiniteChess.org sich nach besten Kräften bemühen wird, die Konten und persönlichen Informationen aller zu schützen, können Sie uns im Falle eines Hacks oder Datenlecks nicht verklagen. Sollte ein Datenleck jemals auftreten, werden die Benutzer auf der \", \"News\", \"-Seite benachrichtigt.\"],\r\n\"Es sind keine Inhalte auf der Website zum Kauf verfügbar. Andere persönliche Informationen werden nicht gesammelt.\",\r\n\"Um Ihre privaten Informationen von unseren Servern löschen zu lassen, können Sie Ihr Konto über Ihre Profilseite löschen. Das Einzige, was wir NICHT löschen werden, ist Ihre Spielhistorie, die mit Ihrem Benutzernamen verknüpft ist, da alle Spiele öffentliche Informationen sind.\",\r\n]\r\ncookie_header = \"Cookie-Richtlinie\"\r\ncookie_paragraphs = [\r\n\"Diese Website verwendet Cookies, kleine Textdateien, die in Ihrem Browser gespeichert und beim Herstellen von Verbindungen an den Server gesendet werden. Der Zweck dieser Cookies besteht in der Validierung Ihrer Anmeldesitzung, dass Ihr Browser dem Schachspiel gehört, in dem er sich befindet, und Benutzerpräferenzen zu speichern, damit diese beim erneuten Besuch der Website erhalten bleiben. Die Website verwendet keine Drittanbieter-Cookies, Cookies werden nicht mit externen Parteien geteilt.\",\r\n\"Cookies sind für das korrekte Funktionieren dieser Website und des Spiels erforderlich. Wenn Sie nicht möchten, dass die Website Cookies speichert, müssen Sie die Nutzung der Website einstellen. Sie können in den Einstellungen Ihres Browsers vorhandene Cookies löschen. Durch die fortgesetzte Nutzung dieser Website stimmen Sie der Verwendung von Cookies zu.\"\r\n]\r\nconclusion_header = \"Fazit\"\r\nconclusion_paragraphs = [\r\n\"Jede Verletzung dieser Bedingungen kann zu einem Bann oder zur Kündigung Ihres Kontos führen. InfiniteChess.org möchte jedem die Möglichkeit geben, zu spielen und Spaß zu haben! Wir behalten uns jedoch das Recht vor, jederzeit die Konten von Benutzern zu sperren oder zu kündigen, aus Gründen, die nicht offengelegt werden müssen. Es können keine Anklagen gegen uns erhoben werden.\",\r\n[\"Diese Nutzungsbedingungen können jederzeit geändert werden. Es liegt in IHRER Verantwortung, sich über die neuesten Änderungen auf dem Laufenden zu halten! Wenn diese Nutzungsbedingungen ein Update erhalten, wird diese Information auf der \", \"News\", \"-Seite veröffentlicht. Wenn Sie zum Zeitpunkt eines Updates der Nutzungsbedingungen den neuen Bedingungen nicht zustimmen, müssen Sie die Nutzung der Website sofort einstellen. Sie können Ihr Konto von Ihrer Profilseite löschen. Wenn Sie Ihr Konto löschen, werden alle Ihre privaten Informationen und Kontodaten gelöscht, AUSSER Ihre Spielhistorie, die mit Ihrem Benutzernamen verknüpft ist, da dies öffentliche Informationen sind.\"],\r\n[\"Diese Seite ist Open Source. Sie dürfen alles auf dieser Website kopieren oder verbreiten, solange Sie die in den \", \"Lizenzbedingungen\", \" dargelegten Bedingungen einhalten! Sollte dieser Link defekt sein, liegt es in Ihrer Verantwortung, die Bedingungen zu finden.\"],\r\n\"Wir können nicht garantieren, dass die Website zu 100% der Zeit läuft. Wir können auch nicht garantieren, dass Daten niemals beschädigt werden.\",\r\n\"Sie dürfen keine illegalen Aktivitäten auf der Website durchführen.\",\r\n[\"Wenn Sie Fragen zu diesen Bedingungen oder andere Fragen zur Website haben, \", \"schreiben Sie uns eine E-Mail!\"]\r\n]\r\nthanks = \"Vielen Dank!\"\r\n\r\n[login]\r\ntitle = \"Anmelden\" # The tab name\r\nusername = \"Benutzername:\"\r\npassword = \"Passwort:\"\r\nlogin_button = \"Anmelden\"\r\nsend_reset_link = \"Zurücksetzungslink senden\"\r\nforgot_question = \"Passwort vergessen?\"\r\nback_to_login = \"Zurück zur Anmeldung\"\r\nforgot_instruction = \"Bitte geben Sie die mit Ihrem Konto verbundene E-Mail-Adresse ein.\"\r\n\r\n[login.javascript]\r\nnetwork-error = \"Es ist ein Netzwerkfehler aufgetreten. Bitte versuchen Sie es erneut.\"\r\n\r\n[reset_password]\r\ntitle = \"Zurücksetzung Ihres Passworts\"\r\ninstruction = \"Bitte geben Sie Ihr neues Passwort ein und bestätigen Sie es.\"\r\nnew_password = \"Neues Passwort\"\r\nconfirm_password = \"Bestätigen Sie Ihr Passwort\"\r\nsubmit_button = \"Passwort zurücksetzen\"\r\n\r\n[error-pages] # Messages shown on some error pages explaining what went wrong\r\n400_message = \"Ungültige Parameter wurden empfangen.\"\r\n409_message = [\"Möglicherweise gab es einen Konflikt mit Benutzername oder E-Mail. Bitte \", \"laden Sie\", \" die Seite neu.\"]\r\n500_message = \"Dies sollte nicht passieren. Es gibt noch einiges zu debuggen!\"\r\n\r\n[news]\r\ntitle = \"Neuigkeiten\" # The tab name\r\nmore_dev_logs = [\"Weitere Entwickler-Logs werden auf dem \", \"offiziellen Discord\", \" und in den \", \"chess.com Foren veröffentlicht!\"]\r\n\r\n[server.javascript]\r\nws-invalid_username = \"Benutzername ist ungültig\"\r\nws-incorrect_password = \"Passwort ist falsch\"\r\nws-login_failure_retry_in = \"Anmeldung fehlgeschlagen, versuchen Sie es erneut in\"\r\nws-seconds = \"Sekunden\" # unit of time\r\nws-second = \"Sekunde\" # unit of time\r\nws-username_length = \"Benutzername muss 3 bis 20 Zeichen lang sein\"\r\nws-username_letters = \"Benutzername darf nur Groß- und Kleinbuchstaben von A bis Z und Zahlen von 0 bis 9 enthalten\"\r\nws-username_taken = \"Dieser Benutzername ist bereits vergeben\"\r\nws-username_bad_word = \"Dieser Benutzername enthält ein nicht erlaubtes Wort\"\r\nws-username_reserved = \"Dieser Benutzername ist reserviert\"\r\nws-email_too_long = \"Ihre E-Mail ist zu laaaaaaaang.\"\r\nws-email_invalid = \"Dies ist keine gültige E-Mail\"\r\nws-email_in_use = \"Diese E-Mail ist bereits in Verwendung\"\r\nws-email_domain_invalid = \"Ungültige Domain.\"\r\nws-email_blacklisted = \"Ihre E-Mail-Adresse ist gesperrt.\"\r\nws-password_length = \"Passwort muss 6 bis 72 Zeichen lang sein\"\r\nws-password_password = \"Passwort darf nicht 'password' sein\"\r\nws-password-reset-link-sent = \"Falls ein Konto mit dieser E-Mail Adresse existiert, wurde ein Link zur Zurücksetzung des Passworts gesendet.\"\r\nws-password-change-success = \"Ihr Passwort wurde erfolgreich zurückgesetzt. Sie werden in Kürze auf die Anmeldeseite weitergeleitet.\"\r\nws-password-reset-token-invalid = \"Token zum Zurücksetzen des Passworts ist ungültig oder abgelaufen.\"\r\nws-forbidden_wrong_account = \"Zugang verweigert. Dies ist nicht Ihr Konto.\"\r\nws-deleting_account_not_found = \"Konto konnte nicht gelöscht werden. Konto nicht gefunden.\"\r\nws-deleting_account_in_game = \"Sie können Ihr Konto nicht löschen, während Sie noch mit einem Online-Spiel verbunden sind.\"\r\nws-server_error = \"Entschuldigung, es gab einen Serverfehler! Bitte gehen Sie zurück.\"\r\nws-not_found = \"404 Nicht gefunden\"\r\nws-forbidden = \"Verboten.\"\r\nws-already_in_game = \"Sie sind bereits in einem Spiel.\"\r\nws-server_restarting = \"Der Server startet in\" # The server inserts a number immediately after this, followed by the correct plurality of minutes.\r\nws-server_under_maintenance = \"Server ist wegen Wartungsarbeiten offline. Schauen Sie bald wieder vorbei!\" # Can be changed at will to change the display message.\r\nws-minutes = \"Minuten\" # unit of time\r\nws-minute = \"Minute\" # unit of time\r\nws-you_cheated = \"Hoppla! Sie haben einen illegalen Zug gemacht. Das Spiel wurde abgebrochen. Wenn dies ein Fehler war, bitte melden Sie diesen Bug!\"\r\nws-opponent_cheated = \"Ihr Gegner hat versucht, einen illegalen Zug zu machen. Das Spiel wurde abgebrochen.\"\r\nws-cannot_resign_finished_game = \"Spiel kann nicht aufgegeben werden, es ist bereits beendet.\"\r\nws-invalid_code = \"Ungültiger Code!\" # Invite code doesn't match any existing invites\r\nws-game_aborted = \"Spiel abgebrochen.\" # Invite was cancelled as you clicked on it\r\nws-rated_invite_verification_needed = \"Um gewertet zu spielen, müssen Sie mit einem verifizierten Konto angemeldet sein.\"\r\n\r\n[rate-limiting]\r\n\r\ngeneric = \"Sie haben zu viele Anfragen gestellt, bitte versuchen Sie es später erneut.\"\r\n"
  },
  {
    "path": "translation/el-GR.toml",
    "content": "name = \"Ελληνικά\" # Name of language\nenglish_name = \"Greek\"\ndirection = \"ltr\" # Change to \"rtl\" for right to left languages\nversion = \"84\"\nmaintainer = \"tsevasa\"\n\n[header]\nhome = \"Άπειρο Σκάκι\"\nplay = \"Παίξτε\"\nnews = \"Νέα\"\nlogin = \"Σύνδεση\"\nprofile = \"Προφίλ\"\ncreateaccount = \"Δημιουργία Λογαριασμού\"\nlogout = \"Αποσύνδεση\"\nleaderboard = \"Κατάταξη\"\n\n[header.settings]\nlanguage = \"Γλώσσα\"\nappearance = \"Εμφάνιση\" # Board color/theme and visual effects\nappearance-theme = \"Σκακιέρα\"\nappearance-starfield = \"Αστρικό Πεδίο\" # The Starfield space animation underneath void\nappearance-advanced-effects = \"Προχωρημένα Εφέ\" # Post processing and board tile effects at extreme distances\nlegalmoves = \"Νόμιμες Κινήσεις\" # Legal moves shape\nlegalmoves-squares = \"Τετράγωνα\"\nlegalmoves-dots = \"Τελείες\" # Dots and 4 corner triangles\nselection = \"Επιλογή\"\nselection-drag = \"Σύρσιμο κομματιών\"\nselection-premove = \"Προκινήσεις\"\nselection-animations = \"Κινούμενα εφέ\"\nselection-lingering_annotations = \"Παραμένουσες Σημειώσεις\"\nperspective = \"Προοπτική\" # Perspective-mode\nperspective-mouse-sensitivity = \"Ευαισθησία Ποντικιού\"\nperspective-fov = \"Οπτικό Πεδίο\"\nsound = \"Ήχος\"\nsound-master-volume = \"Κύρια Ένταση\"\nsound-ambience = \"Ατμόσφαιρα\"\nping = [\"Ping\", \"ms\"] # A number is inserted between these 2 strings.\nreset-to-default = \"Επαναφορά προεπιλογών\"\n\n[footer]\ncontact = \"Επικοινωνία\"\nterms_of_service = \"Όροι Χρήσης\"\nsource_code = \"Πηγαίος Κώδικας\"\nlanguage = \"Γλώσσα\"\n\n[member.javascript]\njs-confirm_delete = \"Είστε σίγουροι ότι θέλετε να διαγράψετε τον λογαριασμό σας; Αυτό ΔΕΝ μπορεί να αναιρεθεί! Πατήστε OK για να εισαγάγετε τον κωδικό σας.\"\njs-enter_password = \"Εισαγάγετε τον κωδικό σας για να διαγράψετε τον λογαριασμό σας ΜΟΝΙΜΑ:\"\n\n[leaderboard.javascript]\nsupported_variants = \"Αυτή η κατάταξη χρησιμοποιείται για τις εξής αρχικές θέσεις:\"\nrank = \"Θέση\"\nplayer = \"Παίκτης\"\nrating = \"Βαθμολογία\"\n\n[index]\ntitle = \"Άπειρο Σκάκι | Αρχική - Επίσημος Ιστότοπος\" # The tab title\nsecondary_title = \"Η επίσημη ιστοσελίδα για live παιχνίδια!\"\nwhat_is_it_title = \"Τι είναι;\"\nwhat_is_it_pargaraphs = [\n\"Το Άπειρο Σκάκι είναι μια παραλλαγή του σκακιού χωρίς όρια, είναι πολύ μεγαλύτερο παιχνίδι από το κανονικό σκάκι στη γνωστή σκακιέρα 8x8. Η βασίλισσα, οι πύργοι και οι αξιωματικοί δεν έχουν <em>κανένα όριο</em> για το μήκος μιας κίνησης. Διαλέξτε οποιονδήποτε φυσικό αριθμό έως το άπειρο!\",\n\"Χωρίς όρια, υπάρχουν θέσεις όπου η απόσταση της παρτίδας από το ματ αναπαρίσταται από το πρώτο άπειρο τακτικό αριθμό, το <strong>ωμέγα</strong>. Μάλιστα, ερευνητές έχουν δείξει ότι <strong>οποιοσδήποτε</strong> αριθμήσιμος τακτικός αριθμός μπορεί να επιτευχθεί!\",\n\"Όπως μπορείτε να φανταστείτε, υπάρχουν άπειρες δυνατές αρχικές θέσεις, πολλές από τις οποίες μπορείτε να παίξετε ανταγωνιστικά εδώ! Ο τελικός στόχος του παιχνιδιού παραμένει το ματ, που απαιτεί νέες τακτικές αφού δεν υπάρχουν τοίχοι για την παγίδευση του αντίπαλου βασιλιά. Τα παιχνίδια συνήθως δεν διαρκούν πολύ παραπάνω από κανονικά παιχνίδια σκακιού. Τα πιόνια εξακολουθούν να προάγονται στις γραμμές 1 και 8!\",\n]\nhow_to_title = \"Πώς μπορώ να παίξω;\"\nhow_to_paragraph = [\"Η τρέχουσα έκδοση είναι η 1.10 στη σελίδα \",\"Παίξτε\",\"!\"]\nabout_title = \"Σχετικά με το Έργο\"\nabout_paragraphs = [\n\"Είμαι ο Naviary. Από τότε που ανακάλυψα το Άπειρο Σκάκι (η ιδέα υπήρχε πολύ πριν από αυτόν τον ιστότοπο), με έχει συναρπάξει με τις δυνατότητές του! Μέχρι πρόσφατα, το παιχνίδι ήταν αρκετά απρόσβατο, απαιτώντας από τους παίκτες να δημιουργούν εικόνες της τρέχουσας σκακιέρας και να τις στέλνουν μπρος-πίσω για κάθε κίνηση. Εξαιτίας αυτού, λίγοι γνώριζαν το παιχνίδι ή μπορούσαν να το παίξουν.\",\n[\"Στόχος μου είναι να δημιουργήσω έναν ιστότοπο ώστε να είναι εύκολα προσβάσιμο σε όλους και να αναπτυχθεί μια κοινότητα γύρω από το άπειρο σκάκι. Έχω αφιερώσει αμέτρητες ώρες από τον προσωπικό μου χρόνο στον ιστότοπο, στη συντήρηση και την ανάπτυξη του παιχνιδιού. Έχω πολλές ακόμα ιδέες που θα με απασχολήσουν για καιρό. Παρότι θέλω να παραμείνει δωρεάν η ιστοσελίδα, η ζωή έχει απαιτήσεις. Για να με υποστηρίξετε οικονομικά, σκεφτείτε να γίνετε μέλος στο \", \"Patreon\", \" μου.\"] # Patreon receives a hyperlink, here\n]\npatreon_title = \"Υποστηρικτές Patreon\"\ngithub_title = \"Συνεισφέροντες Github\"\n\n[index.javascript]\ncontribution_count_singular = [\"\", \" συνεισφορά\"] # A number is inserted between these 2 strings.\ncontribution_count_plural = [\"\", \" συνεισφορές\"]\n\n[credits]\ntitle = \"Συντελεστές\"\ncopyright = \"Οτιδήποτε στον ιστότοπο που δεν αναφέρεται παρακάτω αποτελεί πνευματική ιδιοκτησία του www.InfiniteChess.org\"\nvariants_heading = \"Αρχικές θέσεις\"\nvariants_credits = [\n\"Core σχεδιασμός από τον Andreas Tsevas.\",\n\"Space σχεδιασμένο από τον Andreas Tsevas.\",\n\"Space Classic σχεδιασμένο από τον Andreas Tsevas.\",\n\"Coaip (Σκάκι σε Άπειρο Επίπεδο) σχεδιασμένο από τον V. Reinhart.\",\n\"Pawn Horde σχεδιασμένο από τον Inaccessible Cardinal.\",\n\"Abundance σχεδιασμένο από τον Clicktuck Suskriberz.\",\n\"Pawndard από τον SexyLexi.\",\n\"Κλασικό+ από τον SexyLexi.\",\n\"Knightline από τον Inaccessible Cardinal.\",\n\"Knighted Chess από τον cycy98.\",\n\"σχεδιασμένο από τους Cory Evans και Joel Hamkins.\",\n\"σχεδιασμένο από τον Andreas Tsevas.\",\n\"σχεδιασμένο από τους Cory Evans και Joel Hamkins.\",\n\"σχεδιασμένο από τους Cory Evans, Joel Hamkins και Norman Lewis Perlmutter.\",\n\"Σκάκι σε Άπειρο Επίπεδο - Επιλογή Huygens από τον V. Reinhart.\",\n\"Περιορισμένο Κλασικό από τον Andreas Tsevas.\",\n\"4x4x4x4 Chess από τον Andreas Tsevas.\",\n\"5D Chess από τον Jace.\",\n]\ntextures_heading = \"Υφές\"\ntextures_licensed_under = \"υφές με άδεια βάσει της\"\nsounds_heading = \"Ήχοι\"\nsounds_credits = [\n[\"Ορισμένοι ήχοι παρέχονται από το\", \"έργο υπό την\"],\n\"Άλλοι ήχοι δημιουργήθηκαν από τον Naviary.\",\n]\ncode_heading = \"Κώδικας\"\ncode_credits = [\n\"από τους Brandon Jones και Colin MacKenzie IV.\",\n\"από τους Andreas Tsevas και Naviary.\",\n]\nlanguage_heading = \"Μεταφράσεις Γλωσσών\"\nlanguage_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded.\n\t\"Γαλλικά από \", \"Life Enjoyer\", \" και \", \"cycy98\", \".\",\n\t\"Απλοποιημένα Κινέζικα από \", \"Heinrich Xiao\", \".\",\n\t\"Παραδοσιακά Κινέζικα από \", \"Heinrich Xiao\", \".\",\n\t\"Πολωνικά από \", \"Tymon Becella\", \".\", # Apsurt\n\t\"Πορτογαλικά από \", \"Emerson P. Machado\", \".\", # The_Skeleton on discord\n\t\"Ισπανικά από \", \"xa31er\", \".\",\n\t\"Γερμανικά από \", \"Estetique\", \".\"\n]\n\n[member]\ntitle = \"Μέλος\" # The tab name\nverify_message = \"Παρακαλούμε ελέγξτε το email σας για να επιβεβαιώσετε τον λογαριασμό σας. Οι μη επιβεβαιωμένοι λογαριασμοί διαγράφονται μετά από 3 ημέρες.\"\nresend_message = [\"Δεν το λάβατε; Ελέγξτε τον φάκελο ανεπιθύμητων. Επίσης, \", \"στείλτε το ξανά.\", \" Αν ακόμα δεν το βρίσκετε, \", \"επικοινωνήστε μαζί μας.\"]\nverify_confirm = \"Ευχαριστούμε! Ο λογαριασμός σας επιβεβαιώθηκε.\"\njoined = \"Εγγραφή:\"\nseen = [\"Τελευταία εμφάνιση πριν: \", \"\"]\npractice_progress = \"Πρόοδος Εξάσκησης:\"\nranked_elo = \"Βαθμολογία:\"\ninfinity_leaderboard_position = \"Παγκόσμια Κατάταξη:\"\ninfinity_leaderboard_rating_deviation = \"Απόκλιση Βαθμολογίας:\"\nreveal_info = \"Εμφάνιση Πληροφοριών Λογαριασμού\"\naccount_info_heading = \"Πληροφορίες Λογαριασμού\"\nemail = \"Email:\"\ndelete_account = \"Διαγραφή λογαριασμού\"\n\n[member.badge-tooltips]\ncheckmate_bronze = \"Βετεράνος Ματ: Ολοκληρώστε 50% όλων των ασκήσεων ματ.\"\ncheckmate_silver = \"Επαγγελματίας Ματ: Ολοκληρώστε 75% όλων των ασκήσεων ματ.\"\ncheckmate_gold = \"Δάσκαλος Ματ: Ολοκληρώστε 100% όλων των ασκήσεων ματ.\"\n\n[create-account]\ntitle = \"Δημιουργία Λογαριασμού\" # The tab name\nusername = \"Όνομα χρήστη:\"\nemail = \"Email:\"\npassword = \"Κωδικός πρόσβασης:\"\ncreate_button = \"Δημιουργία Λογαριασμού\"\nagreement = [\"Συμφωνώ με τους \", \"Όρους Χρήσης\", \".\"]  # the middle entry is a hyperlink, the others are not\n\n[create-account.javascript]\njs-username_tooshort = \"Το όνομα χρήστη πρέπει να έχει τουλάχιστον 3 χαρακτήρες\"\njs-username_wrongenc = \"Το όνομα χρήστη πρέπει να περιέχει μόνο γράμματα A-Z και αριθμούς 0-9\"\njs-email_invalid = \"Αυτό δεν είναι έγκυρο email\"\njs-email_too_long = \"Το email είναι πολύ μεγάλο\"\njs-email_inuse = \"Αυτό το email χρησιμοποιείται ήδη\"\n\n[reset-password.javascript]\njs-pwd_no_match = \"Οι κωδικοί πρόσβασης δεν ταιριάζουν.\"\nreset-password = \"Επαναφορά Κωδικού\"\nprocessing = \"Επεξεργασία...\"\nnetwork-error = \"Παρουσιάστηκε σφάλμα δικτύου. Παρακαλώ δοκιμάστε ξανά.\"\n\n[password-validation]\njs-pwd_too_short = \"Ο κωδικός πρόσβασης πρέπει να έχει 6+ χαρακτήρες\"\njs-pwd_too_long = \"Ο κωδικός πρόσβασης δεν μπορεί να ξεπερνά τους 72 χαρακτήρες\"\njs-pwd_not_pwd = \"Ο κωδικός πρόσβασης δεν πρέπει να είναι 'password'\"\n\n[leaderboard]\ntitle = \"Κατάταξη\"\ninactive_players = [\"Οι ανενεργοί παίκτες με απόκλιση βαθμολογίας πάνω από \", \" εξαιρούνται από την κατάταξη.\"] # A number is inserted between these two quotes\nyour_global_ranking = \"Η Παγκόσμια Κατάταξή σας:\"\nshow_more = \"Εμφάνιση περισσότερων...\"\n\n[play]\ntitle = \"Άπειρο Σκάκι - Παίξτε\" # The tab title\nloading = \"ΦΟΡΤΩΣΗ\"\nerror = \"ΣΦΑΛΜΑ\"\n\n[play.main-menu]\ncredits = \"Συντελεστές\"\nplay = \"Παιχνίδι\"\npractice = \"Εξάσκηση\"\nguide = \"Οδηγός\"\neditor = \"Επεξεργαστής Σκακιέρας\"\n\n[play.guide]\ntitle = \"Οδηγός\"\nrules = \"Κανόνες\"\nrules_paragraphs = [\n\"Οι κανόνες του Άπειρου Σκακιού είναι σχεδόν ίδιοι με του κλασικού σκακιού, με τη διαφορά ότι η σκακιέρα είναι άπειρη προς όλες τις κατευθύνσεις! Αυτές είναι οι μόνες αλλαγές που πρέπει να γνωρίζετε:\",\n\"Τα κομμάτια με ολισθαίνουσες κινήσεις, όπως οι πύργοι, οι αξιωματικοί και η βασίλισσα, δεν έχουν όριο στο πόσο μακριά μπορούν να κινηθούν σε έναν γύρο! Όσο η διαδρομή τους δεν εμποδίζεται, μπορείτε να κινηθείτε εκατομμύρια τετράγωνα!\",\n[\"Στην προεπιλεγμένη αρχική θέση \\\"Κλασικό\\\", τα λευκά πιόνια προάγονται στη γραμμή 8 και τα μαύρα στη γραμμή 1. Σε αυτή την εικόνα, αυτό υποδεικνύεται με τις λεπτές μαύρες γραμμές — είναι αχνές, δείτε αν μπορείτε να τις εντοπίσετε! Τα πιόνια χρειάζεται μόνο να φτάσουν στην απέναντι γραμμή για να προαχθούν, \", \"όχι\", \" να την περάσουν.\"],\n\"Τα τετράγωνα δεν περιγράφονται πλέον με γράμμα και αριθμό γραμμής (π.χ. a1)· αντίθετα, κάθε τετράγωνο ορίζεται από ένα ζεύγος συντεταγμένων x και y. Το τετράγωνο a1 έχει γίνει (1,1) και το h8 έχει γίνει (8,8). Σε υπολογιστές, η συντεταγμένη του ποντικιού εμφανίζεται στο επάνω μέρος της οθόνης.\",\n\"Όλοι οι υπόλοιποι κανόνες είναι ίδιοι με το κλασικό σκάκι, όπως ματ, πατ, επανάληψη τριών φορών, ο κανόνας των 50 κινήσεων, ροκέ, en passant κτλ.!\"\n]\ncareful_heading = \"Προσοχή!\"\ncareful_paragraphs = [\n\"Η ανοιχτότητα της άπειρης σκακιέρας σημαίνει ότι είναι πολύ εύκολο να εκμεταλλευτείτε πιρούνια, καρφώματα και σουβλιές. H πίσω γραμμή σας είναι συχνά πολύ ευάλωτh. Προσέξτε τέτοιες τακτικές! Να είστε δημιουργικοί στη δημιουργία άμυνας για τον βασιλιά και τους πύργους σας! Η στρατηγική του ανοίγματος είναι πολύ διαφορετική απότι στο κλασικό σκάκι.\",\n\"Πολλές άλλες αρχικές θέσεις έχουν δημιουργηθεί με στόχο την ενίσχυση της πίσω γραμμής σας.\"\n]\ncontrols_heading = \"Χειρισμός\"\ncontrols_paragraph = \"Κάντε κλικ και σύρετε τη σκακιέρα για να μετακινηθείτε. Κάντε κύλιση για μεγέθυνση και σμίκρυνση. Κάντε κλικ σε οποιοδήποτε κομμάτι, συμπεριλαμβανομένων των κομματιών του αντιπάλου, για να δείτε τις νόμιμες κινήσεις του οποιαδήποτε στιγμή! Επιπλέον χειρισμοί είναι:\"\nkeybinds = [\n\" για μετακίνηση.\",\n[\"Space\", \" και \", \"Shift\", \" για μεγέθυνση και σμίκρυνση.\"],\n[\"Escape\", \" για παύση του παιχνιδιού.\"],\n[\"Tab\", \" ενεργοποιεί/απενεργοποιεί τους δείκτες βελών στις άκρες της οθόνης που δείχνουν κομμάτια εκτός οθόνης. Από προεπιλογή, αυτή η λειτουργία είναι ρυθμισμένη σε \\\"Άμυνα\\\", που εμφανίζει βέλη προς όλα τα κομμάτια που μπορούν να κινηθούν προς την οθόνη σας σύμφωνα με την κατεύθυνση κίνησής τους. Όμως το \", \"Tab\", \" μπορεί να αλλάξει αυτή τη λειτουργία σε \\\"Όλα\\\" ή \\\"Απενεργοποιημένο\\\"· το \\\"Όλα\\\" εμφανίζει βέλη για όλα τα κομμάτια, ανεξάρτητα από το αν μπορούν να κινηθούν προς την οθόνη σας. Αυτή η ρύθμιση μπορεί επίσης να αλλάξει από το μενού παύσης. Κάνοντας κλικ σε ένα βέλος θα μεταφερθείτε στο κομμάτι στο οποίο δείχνει.\"],\n[\"Control\", \" σύρει τη σκακιέρα αντί να σύρει ένα κομμάτι, αν το σύρσιμο είναι ενεργοποιημένο στις ρυθμίσεις.\"],\n\" ενεργοποιήσει τη \\\"Λειτουργία Επεξεργασίας\\\" σε τοπικά παιχνίδια. Αυτό σας επιτρέπει να μετακινείτε οποιοδήποτε κομμάτι οπουδήποτε στη σκακιέρα! Πολύ χρήσιμο για ανάλυση.\"\n]\ncontrols_paragraph2 = \"Αυτοί είναι οι βασικοί χειρισμοί που πρέπει να γνωρίζετε. Όμως υπάρχουν και μερικά επιπλέον αν ποτέ τους χρειαστείτε!\"\nkeybinds_extra = [\n\" επαναφέρει την απόδοση των κομματιών. Αυτό είναι χρήσιμο αν γίνουν αόρατα. Αυτό το σφάλμα μπορεί να συμβεί αν μετακινηθείτε σε ακραίες αποστάσεις (όπως 1e21).\",\n\" ενεργοποιεί/απενεργοποιεί την απόδοση των γραμμών πλοήγησης και των πληροφοριών παιχνιδιού, κάτι που μπορεί να είναι χρήσιμο για streaming και βιντεοσκόπηση. Το streaming και η δημιουργία βίντεο για το παιχνίδι είναι ευπρόσδεκτα!\",\n\" ενεργοποιεί/απενεργοποιεί τον μετρητή FPS. Αυτός εμφανίζει πόσες φορές το παιχνίδι ενημερώνεται ανά δευτερόλεπτο, όχι πάντα τον αριθμό των καρέ που αποδίδονται, καθώς το παιχνίδι παραλείπει την απόδοση όταν δεν αλλάζει κάτι ορατό για αύξηση της απόδοσης.\",\n\" ενεργοποιεί/απενεργοποιεί την απόδοση εικονιδίων. Αυτά είναι τα μικρά εικονίδια των κομματιών που μπορείτε να κάνετε κλικ όταν κάνετε πολύ μεγάλη σμίκρυνση. Σε εισαγόμενα παιχνίδια με πάνω από 50.000 κομμάτια αυτό απενεργοποιείται αυτόματα, καθώς μειώνει δραστικά την απόδοση, αλλά μπορεί να ενεργοποιηθεί ξανά με \",\n[\" (backtick, στο ίδιο πλήκτρο με το \", \") ενεργοποιεί/απενεργοποιεί τη Λειτουργία Εντοπισμού Σφαλμάτων.\"],\n]\nfairy_heading = \"Παραμυθένια Κομμάτια\"\nfairy_paragraph = \"Γνωρίζετε ήδη όσα χρειάζεστε για να παίξετε την προεπιλεγμένη αρχική θέση \\\"Κλασικό\\\". Τα παραμυθένια σκακιστικά κομμάτια δεν χρησιμοποιούνται στο συμβατικό σκάκι, αλλά υπάρχουν σε άλλες αρχικές θέσεις! Αν βρεθείτε σε μια αρχική θέση με κομμάτια που δεν έχετε ξαναδεί, μάθετε εδώ πώς λειτουργούν!\"\nediting_heading = \"Επεξεργασία Σκακιέρας\"\nediting_paragraphs = [\n[\"Υπάρχει ένας εξωτερικός \", \"επεξεργαστής σκακιέρας\", \" διαθέσιμος αυτή τη στιγμή σε ένα δημόσιο Google Sheet! Περιλαμβάνει οδηγίες χρήσης. Αυτό απαιτεί βασικές γνώσεις Google Sheets. Μετά την εγκατάσταση, θα μπορείτε να δημιουργείτε και να εισάγετε προσαρμοσμένες θέσεις στο παιχνίδι μέσω του κουμπιού \\\"Επικόλληση Παιχνιδιού\\\" στο μενού επιλογών!\"],\n\"Για να Παίξτετε μια προσαρμοσμένη θέση με έναν φίλο, ζητήστε του να συνδεθεί σε μια ιδιωτική πρόσκληση και στη συνέχεια και οι δύο μπορείτε να επικολλήσετε τον κωδικό παιχνιδιού για να ξεκινήσετε!\",\n\"Ένας ενσωματωμένος επεξεργαστής σκακιέρας στο παιχνίδι βρίσκεται ακόμα υπό σχεδιασμό.\",\n]\nback = \"Πίσω\"\n\n[play.guide.pieces]\nchancellor = {name=\"Καγκελάριος\", description=\"Κινείται σαν πύργος και ίππος μαζί.\"}\narchbishop = {name=\"Αρχιεπίσκοπος\", description=\"Κινείται σαν αξιωματικός και ίππος μαζί.\"}\namazon = {name=\"Αμαζόνα\", description=\"Κινείται σαν βασίλισσα και ίππος μαζί. Είναι το ισχυρότερο κομμάτι στο παιχνίδι!\"}\nguard = {name=\"Φρουρός\", description=\"Κινείται σαν βασιλιάς, αλλά δεν υπόκειται σε σαχ ή σαχ ματ.\"}\nhawk = {name=\"Γεράκι\", description=\"Πηδά ακριβώς 2 ή 3 τετράγωνα προς οποιαδήποτε κατεύθυνση.\"}\ncentaur = {name=\"Κένταυρος\", description=\"Κινείται σαν ίππος και φρουρός μαζί.\"}\nknightrider = {name=\"Ιπποδρόμος\", description=\"Πηδά σαν ίππος άπειρες φορές προς μία κατεύθυνση, μέχρι να εμποδιστεί.\"}\nhuygen = {name=\"Huygen\", description=\"Πηδά άπειρα προς μία από τις τέσσερις κύριες κατευθύνσεις, επισκεπτόμενος μόνο τετράγωνα με πρώτο αριθμό απόστασης από το αρχικό του τετράγωνο, μέχρι να εμποδιστεί.\"}\nrose = {name=\"Ρόδο\", description=\"Κυκλικός ιπποδρόμος. Κινείται σε δεξιόστροφες και αριστερόστροφες κυκλικές τροχιές πηδώντας σαν ίππος και στρέφοντας κατά 45 μοίρες μετά από κάθε άλμα. Μπορεί να μπλοκαριστεί από άλλα κομμάτια, γι’ αυτό και το κόκκινο τετράγωνο στην εικόνα δεν είναι προσβάσιμο για το ρόδο.\"}\nobstacle = {name=\"Εμπόδιο\", description=\"Ουδέτερο κομμάτι (δεν ελέγχεται από κανέναν παίκτη) που μπλοκάρει την κίνηση, αλλά μπορεί να αιχμαλωτιστεί.\"}\nvoid = {name=\"Κενό\", description=\"Ουδέτερο κομμάτι (δεν ελέγχεται από κανέναν παίκτη) που αναπαριστά την απουσία σκακιέρας. Τα κομμάτια δεν μπορούν να κινηθούν μέσα από αυτό ή επάνω του.\"}\n\n[play.practice-menu]\ntitle = \"Εξάσκηση - Ματ\"\nplay = \"Παίξτε\"\nback = \"Πίσω\"\ndifficulty = \"Δυσκολία\"\n\n[play.play-menu]\ntitle = \"Παίξτε - Διαδικτυακά\"\ncolors = \"Χρώματα\"\nonline = \"Διαδικτυακό\"\nlocal = \"Τοπικό\"\ncomputer = \"Υπολογιστής\"\nvariant = \"Αρχική Θέση\"\nClassical = \"Κλασικό\"\nConfined_Classical = \"Περιορισμένο Κλασικό\"\nClassical_Plus = \"Κλασικό+\"\nCoaIP = \"Σκάκι σε Άπειρο Επίπεδο\"\nPawndard = \"Pawndard\"\nKnighted_Chess = \"Knighted Chess\"\nPalace = \"Palace\"\nKnightline = \"Knightline\"\nCore = \"Core\"\nStandarch = \"Standarch\"\nPawn_Horde = \"Pawn Horde\"\nSpace_Classic = \"Space Classic\"\nSpace = \"Space\"\nObstocean = \"Obstocean\"\nAbundance = \"Abundance\"\nAmazon_Chandelier = \"Amazon Chandelier\"\nContainment = \"Containment\"\nClassical_Limit_7 = \"Κλασικό - Όριο 7\"\nCoaIP_Limit_7 = \"Σκάκι σε Άπειρο Επίπεδο - Όριο 7\"\nChess = \"Σκάκι\"\nClassical_KOTH = \"Πειραματικό: Κλασικό - King of the Hill\"\nCoaIP_KOTH = \"Πειραματικό: Coaip - King of the Hill\"\nCoaIP_HO = \"Σκάκι σε Άπειρο Επίπεδο - Επιλογή Huygens\"\nCoaIP_RO = \"Σκάκι σε Άπειρο Επίπεδο - Επιλογή Ρόδων\"\nCoaIP_NO = \"Σκάκι σε Άπειρο Επίπεδο - Επιλογή Ιπποδρόμων\"\nOmega = \"Παρουσίαση: Ωμέγα\"\nOmega_Squared = \"Παρουσίαση: Ωμέγα²\"\nOmega_Cubed = \"Παρουσίαση: Ωμέγα³\"\nOmega_Fourth = \"Παρουσίαση: Ωμέγα⁴\"\n4x4x4x4_Chess = \"Σκάκι 4×4×4×4\"\n5D_Chess = \"Σκάκι 5D\"\nno_clock = \"Χωρίς Ρολόι\"\nclock = \"Ρολόι\"\nminutes = \"λ\"\nseconds = \"δ\"\ninfinite_time = \"Άπειρος Χρόνος\"\ncolor = \"Χρώμα\"\npiece_colors = [\"Τυχαίο\", \"Λευκό\", \"Μαύρο\"]\nprivate = \"Ιδιωτικό\"\nno = \"Όχι\"\nyes = \"Ναι\"\nrated = \"Βαθμολογία\"\ncasual = \"Φιλικό\"\neasy = \"Εύκολο\"\nmedium = \"Μέτριο\"\nhard = \"Δύσκολο\"\njoin_games = \"Σύνδεση σε Υπάρχοντα - Ενεργά Παιχνίδια:\"\nprivate_invite = \"Ιδιωτική Πρόσκληση:\"\nyour_invite = \"Ο Κωδικός Πρόσκλησής σας:\"\ncreate_invite = \"Δημιουργία Πρόσκλησης\"\njoin = \"Σύνδεση\"\ncopy = \"Αντιγραφή\"\nback = \"Πίσω\"\ncode = \"Κωδικός\"\n\n[play.gamebuttontooltips]\nundo_transition = \"Αναίρεση μετάβασης\"\nexpand_fit_all = \"Επέκταση\"\nrecenter = \"Επανακεντράρισμα\"\nannotations = \"Σχεδίαση σημειώσεων\"\nerase = \"Διαγραφή σημειώσεων\"\ncollapse = \"Σύμπτυξη σημειώσεων\"\nrewind_move = \"Προηγούμενη κίνηση\"\nforward_move = \"Επόμενη κίνηση\"\nundo_edit = \"Αναίρεση επεξεργασίας (Ctrl+Z)\" # Board editor\nredo_edit = \"Επανάληψη επεξεργασίας (Ctrl+Y)\" # Board editor\npause = \"Παύση\"\nundo = \"Αναίρεση κίνησης\" # Checkmate practice game\nrestart = \"Επανεκκίνηση παιχνιδιού\" # Checkmate practice game\n\n[play.pause]\ntitle = \"Σε Παύση\"\nresume = \"Συνέχιση\"\narrows = \"Βέλη: Άμυνα\"\nperspective = \"Προοπτική: Ανενεργή\"\ncopy = \"Αντιγραφή Παιχνιδιού\"\npaste = \"Επικόλληση Παιχνιδιού\"\noffer_draw = \"Προσφορά Ισοπαλίας\"\npractice_menu = \"Μενού Εξάσκησης\"\nmain_menu = \"Κύριο Μενού\"\n\n[play.drawoffer] # The draw offer UI that appears on the bottom bar\nquestion = \"Αποδοχή προσφοράς ισοπαλίας;\"\n\n[play.javascript] # Not text that's included in the html, but text that scripts use!\nguest_indicator = \"(Επισκέπτης)\"\nyou_indicator = \"(Εσύ)\"\nengine_indicator = \"Μηχανή\"\nplayer_name_white_generic = \"Λευκά\"\nplayer_name_black_generic = \"Μαύρα\"\nwhite_to_move = \"Σειρά των λευκών\"\nblack_to_move = \"Σειρά των μαύρων\"\nyour_move = \"Η σειρά σας\"\ntheir_move = \"Η σειρά του αντιπάλου\"\nlost_network = \"Χάθηκε η σύνδεση δικτύου.\"\nfailed_to_load = \"Αποτυχία φόρτωσης ενός ή περισσότερων πόρων. Παρακαλώ ανανεώστε.\"\nplanned_feature = \"Αυτή η λειτουργία είναι προγραμματισμένη!\"\nmain_menu = \"Κύριο Μενού\"\nresign_game = \"Παραίτηση\"\nabort_game = \"Ακύρωση παιχνιδιού\"\noffer_draw = \"Προσφορά ισοπαλίας\" # Offer draw button text in the pause menu\naccept_draw = \"Αποδοχή ισοπαλίας\" # Offer draw button text in the pause menu\narrows_off = \"Βέλη: Ανενεργά\"\narrows_defense = \"Βέλη: Άμυνα\"\narrows_all = \"Βέλη: Όλα\"\narrows_all_hippogonals = \"Βέλη: Όλα (με ιππογώνιες)\"\ntoggled = \"Εναλλάχθηκε\"\nmenu_online = \"Παίξτε - Διαδικτυακά\"\nmenu_local = \"Παίξτε - Τοπικά\"\nmenu_computer = \"Παίξτε - Υπολογιστής\"\ninvite_error_digits = \"Ο κωδικός πρόσκλησης πρέπει να αποτελείται από 5 σύμβολα.\"\ninvite_copied = \"Ο κωδικός πρόσκλησης αντιγράφηκε στο πρόχειρο.\"\nmove_counter = \"Κίνηση:\"\nconstructing_mesh = \"Κατασκευή πλέγματος\"\nrotating_mesh = \"Περιστροφή πλέγματος\"\nlost_connection = \"Χάθηκε η σύνδεση.\"\nplease_wait = \"Παρακαλώ περιμένετε λίγο για να εκτελέσετε αυτή την ενέργεια.\"\nwebgl_unsupported = \"Παρακαλώ αναβαθμίστε τον φυλλομετρητή σας! Δεν υποστηρίζει WebGL2.\"\nbigints_unsupported = \"Δεν υποστηρίζονται BigInts. Παρακαλώ αναβαθμίστε τον φυλλομετρητή σας.\\nΤα BigInts είναι απαραίτητα για να είναι η σκακιέρα άπειρη.\"\n# Checkmate Practice\nversus = \"vs\"\neasy = \"Εύκολο\"\nmedium = \"Μέτριο\"\nhard = \"Δύσκολο\"\ninsane = \"Παράλογο\"\ncheckmate_logged_out = \"Πρέπει να είστε συνδεδεμένοι για να κερδίσετε εμβλήματα.\"\ncheckmate_bronze = \"Βετεράνος του Ματ: Ολοκληρώστε το 50% όλων των ασκήσεων ματ.\"\ncheckmate_silver = \"Επαγγελματίας του Ματ: Ολοκληρώστε το 75% όλων των ασκήσεων ματ.\"\ncheckmate_gold = \"Μαέστρος του Ματ: Ολοκληρώστε το 100% όλων των ασκήσεων ματ.\"\ncheckmate_bronze_unearned = \"Ολοκληρώστε το 50% όλων των ασκήσεων ματ για να κερδίσετε αυτό το έμβλημα.\"\ncheckmate_silver_unearned = \"Ολοκληρώστε το 75% όλων των ασκήσεων ματ για να κερδίσετε αυτό το έμβλημα.\"\ncheckmate_gold_unearned = \"Ολοκληρώστε το 100% όλων των ασκήσεων ματ για να κερδίσετε αυτό το έμβλημα.\"\ncoords-invalid = \"Μη έγκυρη μορφή συντεταγμένων. Παρακαλώ εισαγάγετε ακέραιους ή εκθετική γραφή (π.χ. 1.23e4).\"\ncoords-exceeded = \"Δεν μπορείτε να τηλεμεταφερθείτε τόσο μακριά! Θα ήταν πολύ εύκολο ;)\"\n\n[play.javascript.piecenames] # The string representations of each raw piece type, as found in typeutil.strtypes\nvoid = \"Κενό\"\nobstacle = \"Εμπόδιο\"\nking = \"Βασιλιάς\"\ngiraffe = \"Καμηλοπάρδαλη\"\ncamel = \"Καμήλα\"\nzebra = \"Ζέβρα\"\nknightrider = \"Ιπποδρόμος\"\namazon = \"Αμαζόνα\"\nqueen = \"Βασίλισσα\"\nroyalQueen = \"Βασιλική βασίλισσα\"\nhawk = \"Γεράκι\"\nchancellor = \"Καγκελάριος\"\narchbishop = \"Αρχιεπίσκοπος\"\ncentaur = \"Κένταυρος\"\nroyalCentaur = \"Βασιλικός κένταυρος\"\nrose = \"Ρόδο\"\nknight = \"Ίππος\"\nguard = \"Φρουρός\"\nhuygen = \"Huygen\"\nrook = \"Πύργος\"\nbishop = \"Αξιωματικός\"\npawn = \"Πιόνι\"\n\n[play.javascript.copypaste]\ncopied_game = \"Το παιχνίδι αντιγράφηκε στο πρόχειρο!\"\ncannot_paste_in_public = \"Δεν είναι δυνατή η επικόλληση παιχνιδιού σε δημόσιο αγώνα!\"\ncannot_paste_in_rated = \"Δεν είναι δυνατή η επικόλληση παιχνιδιού σε βαθμολογούμενο αγώνα!\"\ncannot_paste_in_engine = \"Δεν είναι δυνατή η επικόλληση παιχνιδιού σε αγώνα μηχανής!\"\ncannot_paste_after_moves = \"Δεν είναι δυνατή η επικόλληση παιχνιδιού αφού έχουν γίνει κινήσεις!\"\nclipboard_denied = \"Απορρίφθηκε η άδεια πρόσβασης στο πρόχειρο. Πιθανότατα φταίει ο φυλλομετρητής σας.\"\nclipboard_invalid = \"Το πρόχειρο δεν είναι σε έγκυρη σημειογραφία ICN.\"\ngame_needs_to_specify = \"Το παιχνίδι πρέπει να καθορίζει είτε τα μεταδεδομένα 'Variant' είτε την ιδιότητα 'position'.\"\ninvalid_wincon = \"Ο παίκτης έχει μη έγκυρη συνθήκη νίκης\"\npasting_game = \"Επικόλληση παιχνιδιού...\"\npasting_in_private = \"Η επικόλληση παιχνιδιού σε ιδιωτικό αγώνα θα προκαλέσει αποσυγχρονισμό αν ο αντίπαλος δεν κάνει το ίδιο!\"\npiece_count = \"Αριθμός κομματιών\"\nexceeded = \"υπέρβαση\"\nchanged_wincon = \"Οι συνθήκη νίκης ματ μετατράπηκε σε βασιλική αιχμαλωσία και η απόδοση εικονιδίων απενεργοποιήθηκε. Πατήστε 'P' για επανενεργοποίηση (δεν συνιστάται).\"\nloaded_from_clipboard = \"Το παιχνίδι φορτώθηκε από το πρόχειρο!\"\ncopied_position = \"Η θέση αντιγράφηκε στο πρόχειρο!\"\nloaded_position_from_clipboard = \"Η θέση φορτώθηκε από το πρόχειρο!\"\nreset_position = \"Η θέση επαναφέρθηκε!\"\nclear_position = \"Η θέση εκκαθαρίστηκε!\"\n\n[play.javascript.rendering]\non = \"Ενεργή\"\noff = \"Ανενεργή\"\nicon_rendering_off = \"Η απόδοση εικονιδίων απενεργοποιήθηκε.\"\nicon_rendering_on = \"Η απόδοση εικονιδίων ενεργοποιήθηκε.\"\nperspective = \"Προοπτική\"\nperspective_mode_on_desktop = \"Η λειτουργία προοπτικής είναι διαθέσιμη σε επιτραπέζιους υπολογιστές!\"\nmovement_tutorial = \"WASD για μετακίνηση. Space & shift για μεγέθυνση.\"\nregenerated_pieces = \"Τα κομμάτια αναδημιουργήθηκαν.\"\n\n[play.javascript.invites]\nmove_mouse = \"Μετακινήστε το ποντίκι για επανασύνδεση.\"\ncannot_cancel = \"Δεν είναι δυνατή η ακύρωση πρόσκλησης με μη καθορισμένο ID.\"\nyou_are_white = \"Είστε: Λευκός\"\nyou_are_black = \"Είστε: Μαύρος\"\nrandom = \"Τυχαίο\"\naccept = \"Αποδοχή\"\ncancel = \"Ακύρωση\"\ncreate_invite = \"Δημιουργία Πρόσκλησης\"\ncancel_invite = \"Ακύρωση Πρόσκλησης\"\nstart_game = \"Έναρξη Παιχνιδιού\"\njoin_existing_active_games = \"Σύνδεση σε Υπάρχοντα - Ενεργά Παιχνίδια:\"\n\n[play.javascript.onlinegame]\nafk_warning = \"Είστε AFK.\"\nopponent_afk = \"Ο αντίπαλος είναι AFK.\"\nopponent_disconnected = \"Ο αντίπαλος αποσυνδέθηκε.\"\nopponent_lost_connection = \"Ο αντίπαλος έχασε τη σύνδεση.\"\nauto_resigning_in = \"Αυτόματη παραίτηση σε\"\nauto_aborting_in = \"Αυτόματη ακύρωση σε\"\nnot_logged_in = \"Δεν είστε συνδεδεμένοι. Παρακαλώ συνδεθείτε για να επανασυνδεθείτε σε αυτό το παιχνίδι.\"\ngame_no_longer_exists = \"Το παιχνίδι δεν υπάρχει πλέον.\"\nanother_window_connected = \"Ένα άλλο παράθυρο έχει συνδεθεί.\"\nserver_restarting = \"Ο διακομιστής επανεκκινείται σύντομα...\"\nserver_restarting_in = \"Ο διακομιστής επανεκκινείται σε\"\nminute = \"λεπτό\"\nminutes = \"λεπτά\"\n\n[play.javascript.websocket]\nno_connection = \"Χωρίς σύνδεση.\"\nreconnected = \"Επανασυνδέθηκε.\"\nunable_to_identify_ip = \"Αδυναμία αναγνώρισης IP.\"\nonline_play_disabled = \"Το διαδικτυακό παιχνίδι είναι απενεργοποιημένο. Τα cookies δεν υποστηρίζονται. Δοκιμάστε διαφορετικό φυλλομετρητή.\"\ntoo_many_requests = \"Πάρα πολλά αιτήματα. Δοκιμάστε ξανά σύντομα.\"\nmessage_too_big = \"Το μήνυμα είναι πολύ μεγάλο.\"\ntoo_many_sockets = \"Πάρα πολλές συνδέσεις\"\norigin_error = \"Σφάλμα προέλευσης.\"\nconnection_closed = \"Η σύνδεση έκλεισε απροσδόκητα. Μήνυμα διακομιστή:\"\nplease_report_bug = \"Αυτό δεν θα έπρεπε να συμβεί ποτέ, παρακαλώ αναφέρετε αυτό το σφάλμα!\"\n\n[play.javascript.termination] # What caused the termination of the game, in spoken language\ncheckmate = \"Σαχ ματ\"\nstalemate = \"Πατ\"\nrepetition = \"Τριπλή επανάληψη\"\nmoverule = [\"Kανόνας \", \" κίνησεων\"]  # The game inserts a number inbetween these two strings\ninsuffmat = \"Ανεπαρκές υλικό\"\nroyalcapture = \"Βασιλική αιχμαλωσία\"\nallroyalscaptured = \"Όλοι οι βασιλιάδες αιχμαλωτίστηκαν\"\nallpiecescaptured = \"Όλα τα κομμάτια αιχμαλωτίστηκαν\"\nkoth = \"Βασιλιάς του λόφου\"\nresignation = \"Παραίτηση\"\nagreement = \"Συμφωνία\" \ntime = \"Λήξη χρόνου\"\naborted = \"Ακυρώθηκε\" # Game was cancelled (no elo exchanged)\ndisconnect = \"Εγκαταλείφθηκε\" # A player left\n\n[play.javascript.results]\nyou_checkmate = \"Κερδίσατε με σαχ ματ!\"\nyou_time = \"Κερδίσατε λόγω χρόνου!\"\nyou_resignation = \"Κερδίσατε λόγω παραίτησης!\"\nyou_disconnect = \"Κερδίσατε λόγω εγκατάλειψης!\"\nyou_royalcapture = \"Κερδίσατε με βασιλική αιχμαλωσία!\"\nyou_allroyalscaptured = \"Κερδίσατε με αιχμαλωσία όλων των βασιλιάδων!\"\nyou_allpiecescaptured = \"Κερδίσατε με αιχμαλωσία όλων των κομματιών!\"\nyou_koth = \"Κερδίσατε λόγω βασιλιά του λόφου!\"\nyou_generic = \"Κερδίσατε!\"\ndraw_stalemate = \"Ισοπαλία λόγω πατ!\"\ndraw_repetition = \"Ισοπαλία λόγω επανάληψης!\"\ndraw_moverule = [\"Ισοπαλία λόγω του κανόνα των \", \" κινήσεων!\"] # The game inserts a number inbetween these two strings\ndraw_insuffmat = \"Ισοπαλία λόγω ανεπαρκούς υλικού!\"\ndraw_agreement = \"Ισοπαλία κατόπιν συμφωνίας!\"\ndraw_generic = \"Ισοπαλία!\"\naborted = \"Το παιχνίδι ακυρώθηκε.\"\nopponent_checkmate = \"Χάσατε με σαχ ματ!\"\nopponent_time = \"Χάσατε λόγω χρόνου!\"\nopponent_resignation = \"Χάσατε λόγω παραίτησης!\"\nopponent_disconnect = \"Χάσατε λόγω εγκατάλειψης!\"\nopponent_royalcapture = \"Χάσατε με βασιλική αιχμαλωσία!\"\nopponent_allroyalscaptured = \"Χάσατε με αιχμαλωσία όλων των βασιλιάδων!\"\nopponent_allpiecescaptured = \"Χάσατε με αιχμαλωσία όλων των κομματιών!\"\nopponent_koth = \"Χάσατε λόγω βασιλιά του λόφου!\"\nopponent_generic = \"Χάσατε!\"\nwhite_checkmate = \"Τα λευκά κερδίζουν με σαχ ματ!\"\nblack_checkmate = \"Τα μαύρα κερδίζουν με σαχ ματ!\"\nwhite_time = \"Τα λευκά κερδίζουν λόγω χρόνου!\"\nblack_time = \"Τα μαύρα κερδίζουν λόγω χρόνου!\"\nwhite_resignation = \"Τα λευκά κερδίζουν λόγω παραίτησης!\"\nblack_resignation = \"Τα μαύρα κερδίζουν λόγω παραίτησης!\"\nwhite_disconnect = \"Τα λευκά κερδίζουν λόγω αποσύνδεσης!\"\nblack_disconnect = \"Τα μαύρα κερδίζουν λόγω αποσύνδεσης!\"\nwhite_royalcapture = \"Τα λευκά κερδίζουν με βασιλική αιχμαλωσία!\"\nblack_royalcapture = \"Τα μαύρα κερδίζουν με βασιλική αιχμαλωσία!\"\nwhite_allroyalscaptured = \"Τα λευκά κερδίζουν με αιχμαλωσία όλων των βασιλιάδων!\"\nblack_allroyalscaptured = \"Τα μαύρα κερδίζουν με αιχμαλωσία όλων των βασιλιάδων!\"\nwhite_allpiecescaptured = \"Τα λευκά κερδίζουν με αιχμαλωσία όλων των κομματιών!\"\nblack_allpiecescaptured = \"Τα μαύρα κερδίζουν με αιχμαλωσία όλων των κομματιών!\"\nwhite_koth = \"Τα λευκά κερδίζουν με βασιλιά του λόφου!\"\nblack_koth = \"Τα μαύρα κερδίζουν με βασιλιά του λόφου!\"\nbug_generic = \"Αυτό είναι σφάλμα, παρακαλώ αναφέρετέ το!\"\n\n[terms]\ntitle = \"Όροι Χρήσης\"\nwarning = [\"ΤΟ ΠΑΡΟΝ ΕΓΓΡΑΦΟ ΔΕΝ ΕΙΝΑΙ ΝΟΜΙΚΑ ΔΕΣΜΕΥΤΙΚΟ. Φέρουμε ευθύνη μόνο για την αγγλική έκδοση του παρόντος εγγράφου. Αυτή η μετάφραση παρέχεται αποκλειστικά για γενικούς ενημερωτικούς σκοπούς. Μπορείτε να αποκτήσετε πρόσβαση στην επίσημη αγγλική έκδοση \", \"εδώ\", \".\"]\nconsent = \"Χρησιμοποιώντας αυτόν τον ιστότοπο, συμφωνείτε να τηρείτε τους ακόλουθους όρους. Αν δεν συμφωνείτε, πρέπει να σταματήσετε αμέσως τη χρήση του ιστότοπου.\"\nguardian_consent = \"Αν είστε κάτω των 18 ετών, πρέπει να λάβετε συγκατάθεση από γονέα ή νόμιμο κηδεμόνα για να χρησιμοποιήσετε αυτόν τον ιστότοπο και να δημιουργήσετε λογαριασμό.\"\nparents_header = \"Γονείς\"\nparents_paragraphs = [\n\"Υπάρχει αλγόριθμος που αποτρέπει τους χρήστες από το να ορίζουν όνομα που περιέχει συνηθισμένες βωμολοχίες. Προς το παρόν δεν υπάρχει τρόπος επικοινωνίας μεταξύ των μελών στον ιστότοπο.\",\n\"Αυτή τη στιγμή, τα μέλη δεν μπορούν να ορίσουν τη δική τους εικόνα προφίλ. Υπάρχει σχέδιο να επιτραπεί αυτή η λειτουργία. Τότε θα κάνουμε ό,τι μπορούμε για να αποτρέψουμε ακατάλληλες εικόνες προφίλ.\",\n]\nfair_play_header = \"Τίμιο Παιχνίδι\"\nfair_play_paragraph1 = [\"Δεν μπορείτε να έχετε περισσότερους από έναν λογαριασμούς.\"]\nfair_play_paragraph2 = \"Για να διατηρείται το παιχνίδι διασκεδαστικό και δίκαιο για όλους, ΔΕΝ πρέπει:\"\nfair_play_rules = [\n\"Να τροποποιείτε ή να χειραγωγείτε τον κώδικα με οποιονδήποτε τρόπο, συμπεριλαμβανομένων ενδεικτικά: χρήσης εντολών κονσόλας, τοπικών παρακάμψεων, προσαρμοσμένων scripts, τροποποίησης αιτημάτων HTTP, μηνυμάτων websocket κ.λπ. Αυτό μπορεί να γίνει για να σπάσει σκόπιμα το παιχνίδι, να παιχτούν παράνομες κινήσεις ή να αποκτήσετε πλεονέκτημα.\",\n\"Να καταχράστε σφάλματα ή δυσλειτουργίες για να ακυρώσετε το παιχνίδι ή να αποκτήσετε πλεονέκτημα.\",\n\"Σε παιχνίδια κατάταξης, να λαμβάνετε βοήθεια/συμβουλές από άλλο άτομο ή πρόγραμμα σχετικά με το τι να Παίξτετε. (Η δημιουργία μηχανής είναι επιτρεπτή και ενθαρρύνεται, αλλά πρέπει να περιορίζεται σε μη βαθμολογούμενα, φιλικά παιχνίδια)\",\n\"Να ανταλλάσσετε πόντους elo με άλλους παίκτες χάνοντας σκόπιμα για να αυξήσετε τη βαθμολογία του αντιπάλου ή λαμβάνοντας πόντους elo από αντίπαλο που σκοπεύει να χάσει για να αυξήσει τη δική σας βαθμολογία. Αυτό καταχράται το σύστημα και δημιουργεί ανακριβείς βαθμολογίες σε σχέση με το επίπεδο δεξιοτήτων σας.\"\n]\ncleanliness_header = \"Καθαρότητα\"\ncleanliness_rules = [\n\"Σε όλη τη γλώσσα που χρησιμοποιείτε στον ιστότοπο, πρέπει να παραμένετε κόσμιοι, χωρίς χυδαιότητα ή βωμολοχίες. Δεν μπορείτε να εκφοβίζετε, να παρενοχλείτε ή να απειλείτε κανέναν, ούτε να κάνετε οτιδήποτε παράνομο. Δεν μπορείτε να σπαμάρετε άλλους χρήστες ή φόρουμ.\",\n\"Δεν μπορείτε να ανεβάζετε στο προφίλ σας εικόνες που είναι ακατάλληλες, προκλητικές ή βίαιες. Κάτι τέτοιο μπορεί να οδηγήσει σε αποκλεισμό ή τερματισμό του λογαριασμού σας.\"\n]\nprivacy_header = \"Ιδιωτικότητα\"\nprivacy_rules = [\n\"Προς το παρόν, η μόνη προσωπική πληροφορία που συλλέγουμε είναι το email σας. Αυτό γίνεται για την επαλήθευση των λογαριασμών των χρηστών και για να παρέχεται τρόπος απόδειξης της ταυτότητάς τους όταν ζητούν επαναφορά κωδικού. Δεν αποστέλλουμε προωθητικά email ή προσφορές. Δεν κοινοποιούμε το email κανενός χρήστη σε τρίτους.\",\n\"Το InfiniteChess.org ενδέχεται να συλλέγει δεδομένα σχετικά με τη χρήση σας στον ιστότοπο, συμπεριλαμβανομένης της διεύθυνσης IP σας. Αυτό γίνεται για την αποτροπή επιθέσεων από bots και άλλες ανεπιθύμητες οντότητες και για τη διατήρηση ακριβών στατιστικών στη βάση δεδομένων. Αυτό ΔΕΝ είναι η διεύθυνση κατοικίας σας.\",\n\"Όλα τα παιχνίδια που παίζετε σε αυτόν τον ιστότοπο αποτελούν δημόσια πληροφορία. Αν επιθυμείτε να παραμείνετε ανώνυμοι, μην κοινοποιείτε το όνομα χρήστη σας σε φίλους ή οικογένεια. Αν αυτός είναι ο στόχος σας, είναι δική σας ευθύνη να διασφαλίσετε ότι κανείς δεν θα συνδέσει το όνομα χρήστη σας με την πραγματική σας ταυτότητα.\",\n\"Η κατάσταση σύνδεσης του λογαριασμού σας και ο κατά προσέγγιση τελευταίος χρόνος δραστηριότητάς σας στον ιστότοπο αποτελούν επίσης δημόσια πληροφορία.\",\n[\"Παρότι το InfiniteChess.org θα καταβάλει κάθε δυνατή προσπάθεια για να διατηρήσει ασφαλείς τους λογαριασμούς και τις προσωπικές πληροφορίες όλων, σε περίπτωση παραβίασης ή διαρροής δεδομένων δεν μπορείτε να ασκήσετε νομικές αξιώσεις εναντίον μας. Αν συμβεί ποτέ διαρροή δεδομένων, οι χρήστες θα ειδοποιηθούν στη σελίδα \", \"Νέα\", \".\"],\n\"Δεν υπάρχει περιεχόμενο προς αγορά στον ιστότοπο. Καμία άλλη προσωπική πληροφορία δεν συλλέγεται.\",\n\"Για να διαγραφούν οι ιδιωτικές σας πληροφορίες από τους διακομιστές μας, μπορείτε να διαγράψετε τον λογαριασμό σας μέσω της σελίδας προφίλ. Το μόνο στοιχείο που ΔΕΝ διαγράφουμε και συνδέεται με το όνομα χρήστη σας είναι το ιστορικό παιχνιδιών σας, καθώς όλα τα παιχνίδια είναι δημόσια πληροφορία.\",\n]\ncookie_header = \"Πολιτική Cookies\"\ncookie_paragraphs = [\n\"Αυτός ο ιστότοπος χρησιμοποιεί cookies, τα οποία είναι μικρά αρχεία κειμένου που αποθηκεύονται στον φυλλομετρητή σας και αποστέλλονται στον διακομιστή όταν πραγματοποιούνται συνδέσεις. Ο σκοπός αυτών των cookies είναι: η επικύρωση της συνεδρίας σύνδεσής σας, η επικύρωση ότι ο φυλλομετρητής σας ανήκει στο σκακιστικό παιχνίδι στο οποίο αναφέρει ότι βρίσκεται και η αποθήκευση προτιμήσεων παιχνιδιού ώστε να διατηρούνται όταν επισκέπτεστε ξανά τον ιστότοπο. Ο ιστότοπος δεν χρησιμοποιεί cookies τρίτων και τα cookies δεν κοινοποιούνται σε εξωτερικά μέρη.\",\n\"Τα cookies είναι απαραίτητα για τη σωστή λειτουργία του ιστότοπου και του παιχνιδιού. Αν δεν θέλετε ο ιστότοπος να αποθηκεύει cookies, πρέπει να σταματήσετε να τον χρησιμοποιείτε. Μπορείτε να μεταβείτε στις ρυθμίσεις του φυλλομετρητή σας για να διαγράψετε υπάρχοντα cookies. Συνεχίζοντας τη χρήση του ιστότοπου, συναινείτε στη χρήση cookies.\"\n]\nconclusion_header = \"Συμπέρασμα\"\nconclusion_paragraphs = [\n\"Οποιαδήποτε παραβίαση αυτών των όρων μπορεί να οδηγήσει σε αποκλεισμό ή τερματισμό του λογαριασμού σας. Το InfiniteChess.org θέλει να δίνει σε όλους την ευκαιρία να παίζουν και να διασκεδάζουν! Ωστόσο, διατηρούμε το δικαίωμα να αποκλείουμε ή να τερματίζουμε λογαριασμούς χρηστών οποιαδήποτε στιγμή, για λόγους που δεν απαιτείται να γνωστοποιηθούν. Δεν μπορούν να ασκηθούν νομικές αξιώσεις εναντίον μας.\",\n[\"Αυτοί οι όροι χρήσης ενδέχεται να τροποποιηθούν οποιαδήποτε στιγμή. Είναι ΔΙΚΗ ΣΑΣ ευθύνη να διασφαλίζετε ότι είστε ενημερωμένοι για τις τελευταίες αλλαγές! Όταν οι όροι χρήσης ενημερώνονται, η σχετική πληροφορία θα δημοσιεύεται στη σελίδα \", \"Νέα\", \". Αν, τη στιγμή ενημέρωσης των όρων, δεν συμφωνείτε με τους νέους όρους, πρέπει να σταματήσετε άμεσα τη χρήση του ιστότοπου. Μπορείτε να διαγράψετε τον λογαριασμό σας από τη σελίδα προφίλ. Αν διαγράψετε τον λογαριασμό σας, όλες οι ιδιωτικές πληροφορίες και τα δεδομένα λογαριασμού σας θα διαγραφούν, ΕΚΤΟΣ από το ιστορικό παιχνιδιών που συνδέεται με το όνομα χρήστη σας, το οποίο είναι δημόσια πληροφορία.\"],\n[\"Αυτός ο ιστότοπος είναι ανοιχτού κώδικα. Μπορείτε να αντιγράψετε ή να διανείμετε οτιδήποτε σε αυτόν τον ιστότοπο εφόσον τηρείτε τους όρους που περιγράφονται στους \", \"όρους άδειας\", \"! Αν αυτός ο σύνδεσμος δεν λειτουργεί, είναι δική σας ευθύνη να βρείτε τους όρους.\"],\n\"Δεν μπορούμε να εγγυηθούμε ότι ο ιστότοπος θα λειτουργεί 100% του χρόνου. Επίσης δεν μπορούμε να εγγυηθούμε ότι τα δεδομένα δεν θα υποστούν ποτέ αλλοίωση.\",\n\"Δεν επιτρέπεται να πραγματοποιείτε οποιαδήποτε παράνομη δραστηριότητα στον ιστότοπο.\",\n[\"Αν έχετε οποιαδήποτε ερώτηση σχετικά με αυτούς τους όρους ή οποιαδήποτε άλλη απορία για τον ιστότοπο, \", \"στείλτε μας email!\"]\n]\nthanks = \"Σας ευχαριστούμε!\"\n\n[login]\ntitle = \"Σύνδεση\" # The tab name\nusername = \"Όνομα χρήστη:\"\npassword = \"Κωδικός πρόσβασης:\"\nlogin_button = \"Σύνδεση\"\nsend_reset_link = \"Αποστολή Συνδέσμου Επαναφοράς\"\nforgot_question = \"Ξεχάσατε τον κωδικό σας;\"\nback_to_login = \"Επιστροφή στη Σύνδεση\"\nforgot_instruction = \"Παρακαλώ εισαγάγετε τη διεύθυνση email που είναι συνδεδεμένη με τον λογαριασμό σας.\"\n\n[login.javascript]\nnetwork-error = \"Παρουσιάστηκε σφάλμα δικτύου. Παρακαλώ δοκιμάστε ξανά.\"\n\n[reset_password]\ntitle = \"Επαναφορά Κωδικού Πρόσβασης\"\ninstruction = \"Παρακαλώ εισαγάγετε και επιβεβαιώστε τον νέο σας κωδικό πρόσβασης.\"\nnew_password = \"Νέος Κωδικός Πρόσβασης\"\nconfirm_password = \"Επιβεβαίωση Κωδικού Πρόσβασης\"\nsubmit_button = \"Επαναφορά Κωδικού Πρόσβασης\"\n\n[error-pages] # Messages shown on some error pages explaining what went wrong\n400_message = \"Ελήφθησαν μη έγκυρες παράμετροι.\"\n409_message = [\"Ενδέχεται να υπάρχει σύγκρουση ονόματος χρήστη ή email. Παρακαλώ \", \"ανανεώστε\", \" τη σελίδα.\"]\n500_message = \"Αυτό δεν θα έπρεπε να συμβεί. Απαιτείται αποσφαλμάτωση!\"\n\n[news]\ntitle = \"Νέα\" # The tab name\nmore_dev_logs = [\"Περισσότερα αρχεία ανάπτυξης δημοσιεύονται στο \", \"επίσημο discord\", \", και στα \", \"φόρουμ του chess.com!\"]\n\n[server.javascript]\nws-invalid_username = \"Το όνομα χρήστη δεν είναι έγκυρο\"\nws-incorrect_password = \"Ο κωδικός πρόσβασης είναι λανθασμένος\"\nws-login_failure_retry_in = \"Αποτυχία σύνδεσης, δοκιμάστε ξανά σε\"\nws-seconds = \"δευτερόλεπτα\" # unit of time\nws-second = \"δευτερόλεπτο\" # unit of time\nws-username_length = \"Το όνομα χρήστη πρέπει να έχει από 3 έως 20 χαρακτήρες\"\nws-username_letters = \"Το όνομα χρήστη πρέπει να περιέχει μόνο γράμματα A-Z και αριθμούς 0-9\"\nws-username_taken = \"Αυτό το όνομα χρήστη χρησιμοποιείται ήδη\"\nws-username_bad_word = \"Αυτό το όνομα χρήστη περιέχει μη επιτρεπόμενη λέξη\"\nws-username_reserved = \"Αυτό το όνομα χρήστη είναι δεσμευμένο\"\nws-email_too_long = \"Το email σας είναι υπερβολικά μακρύ.\"\nws-email_invalid = \"Αυτό δεν είναι έγκυρο email\"\nws-email_in_use = \"Αυτό το email χρησιμοποιείται ήδη\"\nws-email_domain_invalid = \"Μη έγκυρος τομέας.\"\nws-email_blacklisted = \"Το email σας έχει αποκλειστεί.\"\nws-password_length = \"Ο κωδικός πρόσβασης πρέπει να έχει από 6 έως 72 χαρακτήρες\"\nws-password_password = \"Ο κωδικός πρόσβασης δεν πρέπει να είναι 'password'\"\nws-password-reset-link-sent = \"Αν υπάρχει λογαριασμός με αυτό το email, έχει σταλεί σύνδεσμος επαναφοράς κωδικού.\"\nws-password-change-success = \"Ο κωδικός πρόσβασης επαναφέρθηκε επιτυχώς. Θα μεταφερθείτε σύντομα στη σελίδα σύνδεσης.\"\nws-password-reset-token-invalid = \"Το διακριτικό επαναφοράς κωδικού είναι μη έγκυρο ή έχει λήξει.\"\nws-forbidden_wrong_account = \"Απαγορεύεται. Αυτός δεν είναι ο λογαριασμός σας.\"\nws-deleting_account_not_found = \"Αποτυχία διαγραφής λογαριασμού. Ο λογαριασμός δεν βρέθηκε.\"\nws-deleting_account_in_game = \"Δεν μπορείτε να διαγράψετε τον λογαριασμό σας ενώ είστε ακόμα συνδεδεμένοι σε διαδικτυακό παιχνίδι.\"\nws-server_error = \"Συγγνώμη, παρουσιάστηκε σφάλμα διακομιστή! Παρακαλώ επιστρέψτε.\"\nws-not_found = \"404 Δεν Βρέθηκε\"\nws-forbidden = \"Απαγορεύεται.\"\nws-already_in_game = \"Βρίσκεστε ήδη σε παιχνίδι.\"\nws-server_restarting = \"Ο διακομιστής επανεκκινείται σε\" # The server inserts a number immediately after this, followed by the correct plurality of minutes.\nws-server_under_maintenance = \"Ο διακομιστής βρίσκεται υπό συντήρηση. Ελέγξτε ξανά σύντομα!\" # Can be changed at will to change the display message.\nws-minutes = \"λεπτά\" # unit of time\nws-minute = \"λεπτό\" # unit of time\nws-you_cheated = \"Ωχ! Παίξατε κάτι παράνομο. Το παιχνίδι ακυρώθηκε. Αν πρόκειται για λάθος, παρακαλώ αναφέρετε αυτό το σφάλμα!\"\nws-opponent_cheated = \"Εντοπίσαμε ότι ο αντίπαλός σας έπαιξε κάτι παράνομο. Το παιχνίδι ακυρώθηκε.\"\nws-cannot_resign_finished_game = \"Δεν μπορείτε να παραιτηθείτε από παιχνίδι που έχει ήδη ολοκληρωθεί.\"\nws-invalid_code = \"Μη έγκυρος κωδικός!\" # Invite code doesn't match any existing invites\nws-game_aborted = \"Το παιχνίδι ακυρώθηκε.\" # Invite was cancelled as you clicked on it\nws-rated_invite_verification_needed = \"Για να Παίξτετε παιχνίδι κατάταξης, πρέπει να είστε συνδεδεμένοι με επαληθευμένο λογαριασμό.\"\n\n[rate-limiting]\ngeneric = \"Έχετε πραγματοποιήσει πάρα πολλά αιτήματα, παρακαλώ δοκιμάστε ξανά αργότερα.\"\n"
  },
  {
    "path": "translation/en-US.toml",
    "content": "name = \"English\" # Name of language\nenglish_name = \"English\"\ndirection = \"ltr\" # Change to \"rtl\" for right to left languages\nversion = \"99\"\nmaintainer = \"Naviary\"\n\n[header]\nhome = \"Infinite Chess\"\nplay = \"Play\"\nnews = \"News\"\nlogin = \"Log In\"\nprofile = \"Profile\"\ncreateaccount = \"Create Account\"\nlogout = \"Log Out\"\nleaderboard = \"Leaderboard\"\n\n[header.settings]\nlanguage = \"Language\"\nappearance = \"Appearance\" # Board color/theme and visual effects\nappearance-theme = \"Theme\"\nappearance-coordinates = \"Coordinates\" # File/rank coordinate labels on the board edges\nappearance-starfield = \"Starfield\" # The Starfield space animation underneath void\nappearance-advanced-effects = \"Advanced Effects\" # Post processing and board tile effects at extreme distances\nlegalmoves = \"Legal Moves\" # Legal moves shape\nlegalmoves-squares = \"Squares\"\nlegalmoves-dots = \"Dots\" # Dots and 4 corner triangles\ngameplay = \"Gameplay\"\ngameplay-drag = \"Dragging pieces\"\ngameplay-premove = \"Premoves\"\ngameplay-animations = \"Animations\"\ngameplay-fast_transitions = \"Fast Transitions\"\ngameplay-lingering_annotations = \"Lingering Annotations\"\nperspective = \"Perspective\" # Perspective-mode\nperspective-mouse-sensitivity = \"Mouse Sensitivity\"\nperspective-fov = \"Field of View\"\nsound = \"Sound\"\nsound-master-volume = \"Master Volume\"\nsound-ambience = \"Ambience\"\nping = [\"Ping\", \"ms\"] # A number is inserted between these 2 strings.\nreset-to-default = \"Reset to default\"\n\n[footer]\ncontact = \"Contact us\"\nterms_of_service = \"Terms of Service\"\nsource_code = \"Source Code\"\nlanguage = \"Language\"\n\n[member.javascript]\njs-confirm_delete = \"Are you sure you want to delete your account? This CANNOT be undone! Click OK to enter your password.\"\njs-enter_password = \"Enter your password to PERMANENTLY delete your account:\"\n\n[leaderboard.javascript]\nsupported_variants = \"This leaderboard is used for the following variants:\"\nrank = \"Rank\"\nplayer = \"Player\"\nrating = \"Rating\"\n\n[index]\ntitle = \"Infinite Chess | Home - The Official Website\" # The tab title\nsecondary_title = \"The official website for playing live!\"\nwhat_is_it_title = \"What is it?\"\nwhat_is_it_pargaraphs = [\n\"Infinite Chess is a variant of chess in which there are no borders, much larger than your familiar 8x8 board. The queen, rooks, and bishops have <em>no limit</em> to how far they can move per turn. Pick any natural number up to infinity!\",\n\"With no limit to how far you can move, there are positions possible where the doomsday clock, or checkmate-in-<em>blank</em>, number is represented by the first infinite ordinal, <strong>omega ω</strong>. In fact, researchers have discovered that <strong>any</strong> countable ordinal is achievable for the checkmate clock!\",\n\"As you can imagine, there are infinite possibilities for starting configurations, many of which you can play competitively! Your end goal is still checkmate, which requires new tactics seeing as there are no walls to trap the enemy king against. Games don't typically last much longer than normal chess games. Pawns also still promote at ranks 1 & 8!\",\n]\nhow_to_title = \"How can I play?\"\nhow_to_paragraph = [\"The current version release is 1.10 on the \",\"Play\",\" page!\"]\nabout_title = \"About the Project\"\nabout_paragraphs = [\n\"I am Naviary. Since I first discovered Infinite Chess (the concept existed long before this website), I have been very intrigued by it and its possibilities! Up to just recently, playing has been quite difficult, requiring players to create images of the current board and send them back and forth for every move played. Due to this, not many people know about or have been able to play this.\",\n[\"It is my goal to build a way to make this easily playable for everyone and grow a community surrounding it. I have spent countless hours of my own time on this website, upkeeping it, and developing the game. I have many more ideas that will keep me occupied for some time. While I wish to keep this free to play, life has requirements. To help support me financially please consider joining my \", \"Patreon\", \".\"] # Patreon receives a hyperlink, here\n]\npatreon_title = \"Patreon Supporters\"\ngithub_title = \"Github Contributors\"\n\n[index.javascript]\ncontribution_count_singular = [\"\", \" contribution\"] # A number is inserted between these 2 strings.\ncontribution_count_plural = [\"\", \" contributions\"]\n\n[credits]\ntitle = \"Credits\"\ncopyright = \"Anything on the website that is not listed below is copyright of www.InfiniteChess.org\"\nvariants_heading = \"Variants\"\nvariants_credits = [\n\"Core designed by Andreas Tsevas.\",\n\"Space designed by Andreas Tsevas.\",\n\"Space Classic designed by Andreas Tsevas.\",\n\"Coaip (Chess on an Infinite Plane) designed by V. Reinhart.\",\n\"Pawn Horde designed by Inaccessible Cardinal.\",\n\"Abundance designed by Clicktuck Suskriberz.\",\n\"Pawndard by SexyLexi.\",\n\"Classical+ by SexyLexi.\",\n\"Knightline by Inaccessible Cardinal.\",\n\"Knighted Chess by cycy98.\",\n\"designed by Cory Evans and Joel Hamkins.\",\n\"designed by Andreas Tsevas.\",\n\"designed by Cory Evans and Joel Hamkins.\",\n\"designed by Cory Evans, Joel Hamkins, and Norman Lewis Perlmutter.\",\n\"Chess on an Infinite Plane - Huygens Options by V. Reinhart.\",\n\"Confined Classical by Andreas Tsevas.\",\n\"4x4x4x4 Chess by Andreas Tsevas.\",\n\"5D Chess by Jace.\",\n]\ntextures_heading = \"Textures\"\ntextures_licensed_under = \"textures licensed under the\"\nsounds_heading = \"Sounds\"\nsounds_credits = [\n[\"Some sounds are provided by the\", \"project under the\"],\n\"Other sounds created by Naviary.\",\n]\ncode_heading = \"Code\"\ncode_credits = [\n\"by Brandon Jones and Colin MacKenzie IV.\",\n\"by Andreas Tsevas and Naviary.\",\n\"by FirePlank.\",\n]\nlanguage_heading = \"Language Translations\"\nlanguage_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded.\n\t\"French by \", \"Life Enjoyer\", \" and \", \"cycy98\", \".\",\n\t\"Simplified Chinese by \", \"Heinrich Xiao\", \".\",\n\t\"Traditional Chinese by \", \"Heinrich Xiao\", \".\",\n\t\"Polish by \", \"Tymon Becella\", \".\", # Apsurt\n\t\"Português by \", \"Emerson P. Machado\", \".\", # The_Skeleton on discord\n\t\"Spanish by \", \"xa31er\", \".\",\n\t\"German by \", \"Estetique\", \".\"\n]\n\n[member]\ntitle = \"Member\" # The tab name\nverify_message = \"Please check your email to verify your account. Unverified accounts are deleted after 3 days.\"\nresend_message = [\"Didn't get one? Check your spam folder. Also, \", \"send it again.\", \" If you still can't find it, \", \"message us.\"]\nverify_confirm = \"Thank you! Your account has been verified.\"\njoined = \"Joined:\"\nseen = \"Seen:\" # Last seen: ____\npractice_progress = \"Practice Mode Progress:\"\nranked_elo = \"Rating:\"\ninfinity_leaderboard_position = \"Global Ranking:\"\ninfinity_leaderboard_rating_deviation = \"Rating Deviation:\"\nreveal_info = \"Show Account Info\"\naccount_info_heading = \"Account Info\"\nemail = \"Email:\"\ndelete_account = \"Delete account\"\n\n[member.badge-tooltips]\ncheckmate_bronze = \"Checkmate Veteran: Complete 50% of all practice checkmates.\"\ncheckmate_silver = \"Checkmate Pro: Complete 75% of all practice checkmates.\"\ncheckmate_gold = \"Checkmate Master: Complete 100% of all practice checkmates.\"\n\n[create-account]\ntitle = \"Create Account\" # The tab name\nusername = \"Username:\"\nemail = \"Email:\"\npassword = \"Password:\"\ncreate_button = \"Create Account\"\nagreement = [\"I agree to the \", \"Terms of Service\", \".\"]  # the middle entry is a hyperlink, the others are not\n\n[create-account.javascript]\njs-username_reserved = \"That username is reserved\"\njs-username_length = \"Username must be between 3-20 characters\"\njs-username_tooshort = \"Username must be at least 3 characters long\"\njs-username_wrongenc = \"Username must only contain letters A-Z and numbers 0-9\"\njs-email_invalid = \"This is not a valid email\"\njs-email_too_long = \"The email is too long\"\njs-email_inuse = \"This email is already in use\"\n\n[reset-password.javascript]\njs-pwd_no_match = \"Passwords do not match.\"\nreset-password = \"Reset Password\"\nprocessing = \"Processing...\"\nnetwork-error = \"A network error occurred. Please try again.\"\n\n[password-validation]\njs-pwd_too_short = \"Password must be 6+ characters long\"\njs-pwd_too_long = \"Password can't be over 72 characters long\"\njs-pwd_not_pwd = \"Password must not be 'password'\"\n\n[leaderboard]\ntitle = \"Leaderboard\"\ninactive_players = [\"Inactive players with a rating deviation above \", \" are excluded from the leaderboard.\"] # A number is inserted between these two quotes\nyour_global_ranking = \"Your Global Ranking:\"\nshow_more = \"Show more...\"\n\n[play]\ntitle = \"Infinite Chess - Play\" # The tab title\nloading = \"LOADING\"\nerror = \"ERROR\"\n\n[play.main-menu]\ncredits = \"Credits\"\nplay = \"Play\"\npractice = \"Practice\"\nguide = \"Guide\"\neditor = \"Board Editor\"\n\n[play.editor]\n# Sidebar section headers\nposition = \"Position\"\ntools = \"Tools\"\nselection = \"Selection\"\npalette = \"Palette\"\ncolor = \"Color\"\n# Sidebar button tooltips\ntooltip_reset = \"Reset position\"\ntooltip_clear = \"Clear position\"\ntooltip_load = \"Load position\"\ntooltip_save_as = \"Save position as\"\ntooltip_save = \"Save position\"\ntooltip_copy_notation = \"Copy notation\"\ntooltip_paste_notation = \"Paste notation\"\ntooltip_gamerules = \"Game rules\"\ntooltip_start_local = \"Start local game from position\"\ntooltip_start_engine = \"Start engine game from position\"\n# Tool tooltips\ntooltip_normal = \"Normal (F)\"\ntooltip_eraser = \"Eraser (G)\"\ntooltip_selection_tool = \"Selection (H)\"\ntooltip_specialrights = \"Special rights toggle (J)\"\n# Selection tooltips\ntooltip_select_all = \"Select all (Ctrl+A)\"\ntooltip_clear_selection = \"Clear selection (Del)\"\ntooltip_copy_selection = \"Copy selection (Ctrl+C)\"\ntooltip_paste_selection = \"Paste selection (Ctrl+V)\"\ntooltip_invert_color = \"Invert selection color\"\ntooltip_rotate_left = \"Rotate selection left\"\ntooltip_rotate_right = \"Rotate selection right\"\ntooltip_flip_horizontal = \"Flip selection horizontally\"\ntooltip_flip_vertical = \"Flip selection vertically\"\n# Reset Position window\nreset_header = \"Reset position\"\nreset_message = \"Do you want to reset the board and create a new position? Unsaved changes will be lost.\"\n# Clear Position window\nclear_header = \"Clear position\"\nclear_message = \"Do you want to clear the board and create a new position? Unsaved changes will be lost.\"\n# Load Position window\nenter_position_name = \"Enter position name:\"\nsave_button = \"Save\"\nname_header = \"Name\"\npieces_header = \"Pieces\" # Represents piece count\ndate_header = \"Date\" # Represents date last modified\nno_saves = \"No saved positions.\"\n# Game Rules window\ngamerules_header = \"Game Rules\"\nplayer_to_move = \"Player to move:\"\nwhite = \"White\"\nblack = \"Black\"\nen_passant = \"En passant square:\"\nmove_rule = \"Move rule state:\"\npromotion_ranks_white = \"Promotion ranks (White):\"\npromotion_ranks_black = \"Promotion ranks (Black):\"\npromotion_pieces = \"Promotion pieces:\"\nglobal_special_rights = \"Global special rights:\"\npawn_double_push = \"Pawn double push\"\ncastling_label = \"Castling\"\nwin_conditions = \"Win conditions:\"\ncheckmate = \"Checkmate\"\nroyal_capture = \"Royal capture\"\nall_royals_captured = \"All royals captured\"\nall_pieces_captured = \"All pieces captured\"\nworld_border = \"World Border:\"\n# Start Local Game window\nstart_local_game = \"Start local game\"\nstart_local_game_message = \"Do you want to leave the board editor and start a local game from this position? Changes will be saved.\"\n# Start Engine Game window\nstart_engine_game = \"Start engine game\"\nplay_as = \"Play as:\"\ntime_control = \"Time Control (leave blank for unlimited time):\"\nengine_difficulty = \"Engine difficulty:\"\neasy = \"Easy\"\nmedium = \"Medium\"\nhard = \"Hard\"\nuse_default_border = \"Use default engine world border:\"\nstart_engine_game_message = \"Do you want to leave the board editor and start an engine game from this position? Changes will be saved.\"\n# Common\nyes = \"Yes\"\nno = \"No\"\n\n[play.guide]\ntitle = \"Guide\"\nrules = \"Rules\"\nrules_paragraphs = [\n\"The rules to Infinite Chess are almost identical to classical chess, except that the board is infinite in all directions! These are the only notes and changes you need to be aware of:\",\n\"Pieces with sliding moves, such as rooks, bishops, and the queen, have no limit to how far they can move in one turn! As long as their path is unobstructed, you can move millions of squares!\",\n[\"In the \\\"Classical\\\" default variant, white pawns promote at rank 8, and black pawns at rank 1. In this image, this is indicated by the thin black lines—they are faint, see if you can spot them! Pawns only need to reach the opposite line to promote, \", \"not\", \" cross it.\"],\n\"Squares are no longer described by their letter and rank number (e.g., a1); rather, each square is defined by a pair of x and y coordinates. The a1 square has become (1,1), and the h8 square has become (8,8). On desktop devices, the coordinate your mouse is over is displayed at the top of the screen.\",\n\"All other rules are the same as in classical chess, such as checkmate, stalemate, 3-fold repetition, the 50-move rule, castling, en passant, etc.!\"\n]\ncareful_heading = \"Be Careful!\"\ncareful_paragraphs = [\n\"The openness of the infinite board means it is very easy to exploit forks, pins, and skewers. Your backside is often very vulnerable. Watch out for tactics like this! Be creative about forming protection for your king and rooks! Opening strategy is very different from classical chess.\",\n\"Many other variants have been created with the aim of strengthening your backside.\"\n]\ncontrols_heading = \"Controls\"\ncontrols_paragraph = \"Click and drag the board to move around. Scroll to zoom in and out. Click any piece, including your opponent's pieces, to view their legal moves at any point! Additional controls are:\"\nkeybinds = [\n\" to move around.\",\n[\"Space\", \" and \", \"Shift\", \" to zoom in and out.\"],\n[\"Escape\", \" to pause the game.\"],\n[\"Tab\", \" toggles the arrow indicators on the edges of the screen pointing to pieces off-screen. By default, this mode is set to \\\"Defense\\\", which displays arrows pointing to all pieces that can move to your screen along their direction of movement. But \", \"Tab\", \" can switch this mode to \\\"All\\\", or \\\"Off\\\"; \\\"All\\\" displays arrows for all pieces, regardless of if they can move to your screen. This setting can also be toggled in the pause menu. Clicking an arrow will teleport you to the piece it points to.\"],\n[\"Control\", \" will force-drag the board instead of dragging a piece, if dragging is enabled in the settings.\"],\n\" will toggle \\\"Edit Mode\\\" in local games. This allows you to move any piece anywhere else on the board! Very useful for analyzing.\"\n]\ncontrols_paragraph2 = \"Those are the major controls you need to know. But here are some extras if you ever find yourself needing them!\"\nkeybinds_extra = [\n\" will reset the rendering of the pieces. This is useful if they turn invisible. This glitch can happen if you move extreme distances (like 1e21).\",\n\" will toggle the rendering of the navigation and game info bars, which can be useful for recording. Streaming and making videos on the game is welcome!\",\n\" will toggle your FPS meter. This displays the number of times the game is updating per second, not always the number of frames rendered, as the game skips rendering when nothing visible has changed to increase performance.\",\n\" will toggle icon rendering. These are the clickable mini-pictures of the pieces when you zoom out far enough. In imported games with over 50,000 pieces this is automatically toggled off, as it drastically lowers performance, but they can be toggled back on with \",\n[\" (backtick, on the same key as \", \") will toggle Debug mode.\"],\n]\nfairy_heading = \"Fairy Pieces\"\nfairy_paragraph = \"You already know what you need to know to play the default \\\"Classical\\\" variant. Fairy chess pieces are not used in conventional chess, but are incorporated into other variants! If you find yourself in a variant with some pieces you haven't seen before, learn how they work here!\"\nback = \"Back\"\n\n[play.guide.pieces]\nchancellor = {name=\"Chancellor\", description=\"Moves like a rook and a knight combined.\"}\narchbishop = {name=\"Archbishop\", description=\"Moves like a bishop and a knight combined.\"}\namazon = {name=\"Amazon\", description=\"Moves like a queen and a knight combined. This is the strongest piece in the game!\"}\nguard = {name=\"Guard\", description=\"Moves like a king, except it is not susceptible to check or checkmate.\"}\nhawk = {name=\"Hawk\", description=\"Leaps exactly 2 or 3 squares in any direction.\"}\ncentaur = {name=\"Centaur\", description=\"Moves like a knight and a guard combined.\"}\nknightrider = {name=\"Knightrider\", description=\"Hops like a knight infinitely in one direction, until obstructed.\"}\nhuygen = {name=\"Huygen\", description=\"Hops infinitely in one of the four cardinal directions, visiting only squares with a prime numbered distance from its start square, until obstructed.\"}\nrose = {name=\"Rose\", description=\"Circular knightrider. It moves along clockwise and anticlockwise circular trajectories by hopping like a knight and turning 45 degrees after every hop. It can be blocked by other pieces, which is why the red square in the image is unreachable for the rose.\"}\nobstacle = {name=\"Obstacle\", description=\"A neutral piece (not controlled by either player) that blocks movement, but can be captured.\"}\nvoid = {name=\"Void\", description=\"A neutral piece (not controlled by either player) that represents the absence of board. Pieces may not move through or on top of it.\"}\n\n[play.practice-menu]\ntitle = \"Practice - Checkmates\"\nplay = \"Play\"\nback = \"Back\"\ndifficulty = \"Difficulty\"\n\n[play.play-menu]\ntitle = \"Play - Online\"\ncolors = \"Colors\"\nonline = \"Online\"\nlocal = \"Local\"\ncomputer = \"Computer\"\nvariant = \"Variant\"\nClassical = \"Classical\"\nConfined_Classical = \"Confined Classical\"\nClassical_Plus = \"Classical+\"\nCoaIP = \"Chess on an Infinite Plane\"\nPawndard = \"Pawndard\"\nKnighted_Chess = \"Knighted Chess\"\nPalace = \"Palace\"\nKnightline = \"Knightline\"\nCore = \"Core\"\nStandarch = \"Standarch\"\nPawn_Horde = \"Pawn Horde\"\nSpace_Classic = \"Space Classic\"\nSpace = \"Space\"\nObstocean = \"Obstocean\"\nAbundance = \"Abundance\"\nAmazon_Chandelier = \"Amazon Chandelier\"\nContainment = \"Containment\"\nClassical_Limit_7 = \"Classical - Limit 7\"\nCoaIP_Limit_7 = \"Coaip - Limit 7\"\nChess = \"Chess\"\nClassical_KOTH = \"Experimental: Classical - KOTH\"\nCoaIP_KOTH = \"Experimental: Coaip - KOTH\"\nCoaIP_HO = \"Chess on an Infinite Plane - Huygens Option\"\nCoaIP_RO = \"Chess on an Infinite Plane - Roses Option\"\nCoaIP_NO = \"Chess on an Infinite Plane - Knightriders Option\"\nOmega = \"Showcase: Omega\"\nOmega_Squared = \"Showcase: Omega^2\"\nOmega_Cubed = \"Showcase: Omega^3\"\nOmega_Fourth = \"Showcase: Omega^4\"\n4x4x4x4_Chess = \"4×4×4×4 Chess\"\n5D_Chess = \"5D Chess\"\nno_clock = \"No Clock\"\nclock = \"Clock\"\nminutes = \"m\"\nseconds = \"s\"\ninfinite_time = \"Infinite Time\"\ncolor = \"Color\"\npiece_colors = [\"Random\", \"White\", \"Black\"]\nprivate = \"Private\"\nno = \"No\"\nyes = \"Yes\"\nrated = \"Rated\"\ncasual = \"Casual\"\neasy = \"Easy\"\nmedium = \"Medium\"\nhard = \"Hard\"\njoin_games = \"Join Existing - Active Games:\"\nprivate_invite = \"Private Invite:\"\nyour_invite = \"Your Invite Code:\"\ncreate_invite = \"Create Invite\"\njoin = \"Join\"\ncopy = \"Copy\"\nback = \"Back\"\ncode = \"Code\"\n\n[play.gamebuttontooltips]\nundo_transition = \"Undo transition\"\nexpand_fit_all = \"Expand to fit all\"\nrecenter = \"Recenter\"\nannotations = \"Draw annotations\"\nerase = \"Erase annotations\"\ncollapse = \"Collapse annotations\"\nrewind_move = \"Rewind move\"\nforward_move = \"Forward move\"\nundo_edit = \"Undo edit (Ctrl+Z)\" # Board editor\nredo_edit = \"Redo edit (Ctrl+Y)\" # Board editor\npause = \"Pause\"\nundo = \"Undo move\" # Checkmate practice game\nrestart = \"Restart game\" # Checkmate practice game\n\n[play.pause]\ntitle = \"Paused\"\nresume = \"Resume\"\narrows = \"Arrows: Defense\"\nperspective = \"Perspective: Off\"\ncopy = \"Copy Game\"\npaste = \"Paste Game\"\noffer_draw = \"Offer Draw\"\npractice_menu = \"Practice Menu\"\nmain_menu = \"Main Menu\"\n\n[play.drawoffer] # The draw offer UI that appears on the bottom bar\nquestion = \"Accept draw offer?\"\n\n[play.javascript] # Not text that's included in the html, but text that scripts use!\nguest_indicator = \"(Guest)\"\nyou_indicator = \"(You)\"\nengine_indicator = \"Engine\"\nplayer_name_white_generic = \"White\"\nplayer_name_black_generic = \"Black\"\nwhite_to_move = \"White to move\"\nblack_to_move = \"Black to move\"\nyour_move = \"Your move\"\ntheir_move = \"Their move\"\nlost_network = \"Lost network.\"\nfailed_to_load = \"One or more resources failed to load. Please refresh.\"\nplanned_feature = \"This feature is planned!\"\nmain_menu = \"Main Menu\"\nresign_game = \"Resign Game\"\nabort_game = \"Abort Game\"\noffer_draw = \"Offer Draw\" # Offer draw button text in the pause menu\naccept_draw = \"Accept Draw\" # Offer draw button text in the pause menu\narrows_off = \"Arrows: Off\"\narrows_defense = \"Arrows: Defense\"\narrows_all = \"Arrows: All\"\narrows_all_hippogonals = \"Arrows: All (with hippogonals)\"\ntoggled = \"Toggled\"\nmenu_online = \"Play - Online\"\nmenu_local = \"Play - Local\"\nmenu_computer = \"Play - Computer\"\ninvite_error_digits = \"Invite code needs to be 5 digits.\"\ninvite_copied = \"Copied invite code to clipboard.\"\nmove_counter = \"Move:\"\nconstructing_mesh = \"Constructing mesh\"\nrotating_mesh = \"Rotating mesh\"\nlost_connection = \"Lost connection.\"\nplease_wait = \"Please wait a moment to perform this task.\"\nwebgl_unsupported = \"Please upgrade your browser! It does not support WebGL2.\"\nbigints_unsupported = \"BigInts are not supported. Please upgrade your browser.\\nBigInts are needed to make the board infinite.\"\n# Checkmate Practice\nversus = \"vs\"\neasy = \"Easy\"\nmedium = \"Medium\"\nhard = \"Hard\"\ninsane = \"Insane\"\ncheckmate_logged_out = \"You must be logged in to earn badges.\"\ncheckmate_bronze = \"Checkmate Veteran: Complete 50% of all practice checkmates.\"\ncheckmate_silver = \"Checkmate Pro: Complete 75% of all practice checkmates.\"\ncheckmate_gold = \"Checkmate Master: Complete 100% of all practice checkmates.\"\ncheckmate_bronze_unearned = \"Complete 50% of all practice checkmates to earn this badge.\"\ncheckmate_silver_unearned = \"Complete 75% of all practice checkmates to earn this badge.\"\ncheckmate_gold_unearned = \"Complete 100% of all practice checkmates to earn this badge.\"\ncoords-invalid = \"Invalid coordinate format. Please enter integers or e-notation (e.g., 1.23e4).\"\ncoords-exceeded = \"You can't teleport that far! That would be too easy ;)\"\n\n[play.javascript.piecenames] # The string representations of each raw piece type, as found in typeutil.strtypes\nvoid = \"Void\"\nobstacle = \"Obstacle\"\nking = \"King\"\ngiraffe = \"Giraffe\"\ncamel = \"Camel\"\nzebra = \"Zebra\"\nknightrider = \"Knightrider\"\namazon = \"Amazon\"\nqueen = \"Queen\"\nroyalQueen = \"Royal queen\"\nhawk = \"Hawk\"\nchancellor = \"Chancellor\"\narchbishop = \"Archbishop\"\ncentaur = \"Centaur\"\nroyalCentaur = \"Royal centaur\"\nrose = \"Rose\"\nknight = \"Knight\"\nguard = \"Guard\"\nhuygen = \"Huygen\"\nrook = \"Rook\"\nbishop = \"Bishop\"\npawn = \"Pawn\"\n\n[play.javascript.copypaste]\ncopied_game = \"Copied game to clipboard!\"\ncannot_paste_in_public = \"Cannot paste game in a public match!\"\ncannot_paste_in_rated = \"Cannot paste game in a rated match!\"\ncannot_paste_in_engine = \"Cannot paste game in an engine match!\"\ncannot_paste_after_moves = \"Cannot paste game after moves are made!\"\nclipboard_denied = \"Clipboard permission denied. This might be your browser.\"\nclipboard_invalid = \"Clipboard is not in valid ICN notation.\"\ngame_needs_to_specify = \"Game needs to specify either the 'Variant' metadata, or 'position' property.\"\npasting_game = \"Pasting game...\"\npasting_in_private = \"Pasting a game in a private match will cause a desync if your opponent doesn't do the same!\"\npiece_count = \"Piece count\"\nexceeded = \"exceeded\"\nchanged_wincon = \"Changed checkmate win conditions to royalcapture, and toggled off icon rendering. Hit 'P' to re-enable (not recommended).\"\nloaded_from_clipboard = \"Loaded game from clipboard!\"\ncopied_position = \"Copied position to clipboard!\"\nloaded_position_from_clipboard = \"Loaded position from clipboard!\"\nreset_position = \"Position was reset!\"\nclear_position = \"Position was cleared!\"\n\n[play.javascript.rendering]\non = \"On\"\noff = \"Off\"\nicon_rendering_off = \"Toggled off icon rendering.\"\nicon_rendering_on = \"Toggled on icon rendering.\"\nperspective = \"Perspective\"\nperspective_mode_on_desktop = \"Perspective mode is available on desktop!\"\nmovement_tutorial = \"WASD to move. Space & shift to zoom.\"\nregenerated_pieces = \"Regenerated pieces.\"\n\n[play.javascript.invites]\nmove_mouse = \"Move the mouse to reconnect.\"\ncannot_cancel = \"Cannot cancel invite of undefined ID.\"\nyou_are_white = \"You're: White\"\nyou_are_black = \"You're: Black\"\nrandom = \"Random\"\naccept = \"Accept\"\ncancel = \"Cancel\"\ncreate_invite = \"Create Invite\"\ncancel_invite = \"Cancel Invite\"\nstart_game = \"Start Game\"\njoin_existing_active_games = \"Join Existing - Active Games:\"\n\n[play.javascript.onlinegame]\nafk_warning = \"You are AFK.\"\nopponent_afk = \"Opponent is AFK.\"\nopponent_disconnected = \"Opponent has disconnected.\"\nopponent_lost_connection = \"Opponent has lost connection.\"\nauto_resigning_in = \"Auto-resigning in\"\nauto_aborting_in = \"Auto-aborting in\"\nnot_logged_in = \"You are not logged in. Please login to reconnect to this game.\"\ngame_no_longer_exists = \"Game no longer exists.\"\nanother_window_connected = \"Another window has connected.\"\n\n[play.javascript.websocket]\nno_connection = \"No connection.\"\nreconnected = \"Reconnected.\"\nunable_to_identify_ip = \"Unable to identify IP.\"\nonline_play_disabled = \"Online play disabled. Cookies not supported. Try a different browser.\"\ntoo_many_requests = \"Too many requests. Try again soon.\"\nmessage_too_big = \"Message too big.\"\ntoo_many_sockets = \"Too many sockets\"\norigin_error = \"Origin error.\"\nconnection_closed = \"Connection closed unexpectedly. Server message:\"\nplease_report_bug = \"This should never happen, please report this bug!\"\nmalformed_message = \"Received unexpected websocket message. Please report this bug!\"\n\n[play.javascript.results]\nyou_checkmate = \"You win by checkmate!\"\nyou_time = \"You win on time!\"\nyou_resignation = \"You win by resignation!\"\nyou_disconnect = \"You win by abandonment!\"\nyou_royalcapture = \"You win by royal capture!\"\nyou_allroyalscaptured = \"You win by all royals captured!\"\nyou_allpiecescaptured = \"You win by all pieces captured!\"\nyou_koth = \"You win by king of the hill!\"\nyou_generic = \"You win!\"\ndraw_stalemate = \"Draw by stalemate!\"\ndraw_repetition = \"Draw by repetition!\"\ndraw_moverule = [\"Draw by the \", \"-move-rule!\"] # The game inserts a number inbetween these two strings\ndraw_insuffmat = \"Draw by insufficient material!\"\ndraw_agreement = \"Draw by agreement!\"\ndraw_generic = \"Draw!\"\naborted = \"Game aborted.\"\nopponent_checkmate = \"You lose by checkmate!\"\nopponent_time = \"You lose on time!\"\nopponent_resignation = \"You lose by resignation!\"\nopponent_disconnect = \"You lose by abandonment!\"\nopponent_royalcapture = \"You lose by royal capture!\"\nopponent_allroyalscaptured = \"You lose by all royals captured!\"\nopponent_allpiecescaptured = \"You lose by all pieces captured!\"\nopponent_koth = \"You lose by king of the hill!\"\nopponent_generic = \"You lose!\"\nwhite_checkmate = \"White wins by checkmate!\"\nblack_checkmate = \"Black wins by checkmate!\"\nwhite_time = \"White wins on time!\"\nblack_time = \"Black wins on time!\"\nwhite_resignation = \"White wins by resignation!\"\nblack_resignation = \"Black wins by resignation!\"\nwhite_disconnect = \"White wins by disconnection!\"\nblack_disconnect = \"Black wins by disconnection!\"\nwhite_royalcapture = \"White wins by royal capture!\"\nblack_royalcapture = \"Black wins by royal capture!\"\nwhite_allroyalscaptured = \"White wins by all royals captured!\"\nblack_allroyalscaptured = \"Black wins by all royals captured!\"\nwhite_allpiecescaptured = \"White wins by all pieces captured!\"\nblack_allpiecescaptured = \"Black wins by all pieces captured!\"\nwhite_koth = \"White wins by king of the hill!\"\nblack_koth = \"Black wins by king of the hill!\"\nbug_generic = \"This is a bug, please report!\"\n\n[play.javascript.editor]\n# Sidebar toggle\nexpand_sidebar = \"Expand sidebar\"\ncollapse_sidebar = \"Collapse sidebar\"\n# Position header\nnew_position = \"New position\"\n# Load/Save Position window headers\nload_position_header = \"Load Position\"\nsave_position_as_header = \"Save Position As\"\n# Confirmation modal\ndelete_title = \"Delete position?\"\ndelete_message = [\"Are you sure that you want to delete position \\\"\", \"\\\"? This cannot be undone.\"]\nload_title = \"Load position?\"\nload_message = [\"Are you sure that you want to load position \\\"\", \"\\\"? Unsaved changes to the current position will be lost.\"]\noverwrite_title = \"Overwrite position?\"\noverwrite_message = [\"Are you sure that you want to overwrite position \\\"\", \"\\\"? This cannot be undone.\"]\n# Save list row tooltips\ntooltip_load_position = \"Load position\"\ntooltip_save_to_cloud = \"Save to cloud\"\ntooltip_remove_from_cloud = \"Remove from cloud\" # Transfer position from cloud to local browser\ntooltip_delete_position = \"Delete position\"\n# Toast messages\nposition_loaded = \"Position successfully loaded.\"\ncannot_start_local_empty = \"Cannot start local game from empty position!\"\ncannot_start_engine_empty = \"Cannot start engine game from empty position!\"\nposition_not_supported = \"Position is not supported for reason:\"\nillegal_position_king_capture = \"Illegal position: King capture possible on turn 1.\"\nsaved_in_browser = \"Position saved in browser.\"\nposition_corrupted = \"The position was corrupted.\"\nfailed_to_load = \"Failed to load position:\"\nfailed_to_convert_icn = \"Failed to convert position to ICN for cloud upload.\"\ntoo_large_for_cloud = \"Position is too large to save to the cloud.\"\nfailed_to_upload = \"Failed to upload position to cloud:\"\nsaved_to_cloud = \"Position saved to cloud.\"\nno_changes = \"No changes made.\"\nfailed_to_load_cloud = \"Failed to load position from the cloud:\"\nfailed_to_delete_cloud = \"Failed to delete position from the cloud:\"\nfailed_to_remove_cloud = \"Failed to remove position from cloud:\" # Transfers position from cloud to local browser\nsaved_locally = \"Position saved locally.\"\nfailed_to_fetch_cloud = \"Failed to fetch cloud saves:\"\n\n[terms]\ntitle = \"Terms of Service\"\nwarning = [\"THIS DOCUMENT IS NOT LEGALLY BINDING. We are only accountable for the English version of this document. This translation is provided solely for general informational purposes. You can access the official English version \", \"here\", \".\"]\nconsent = \"By using this site, you agree to abide by the following terms. If you do not agree, you must immediately stop using the site.\"\nguardian_consent = \"If you are under 18, you must receive consent from a parent or legal guardian to use this site and to create an account.\"\nparents_header = \"Parents\"\nparents_paragraphs = [\n\"There is an algorithm in place for prohibiting users setting their name to common cuss words. At this time there is no method of communication between members on the site.\",\n\"Currently, members cannot set their own profile picture. There is a plan to allow this feature. At that time we will do our best to prevent innapropriate profile pictures.\",\n]\nfair_play_header = \"Fair Play\"\nfair_play_paragraph1 = [\"You cannot have more than one account.\"]\nfair_play_paragraph2 = \"To keep play fun and fair for everyone, you must NOT:\"\nfair_play_rules = [\n\"Modify or manipulate the code in any way, including but not limited to: Using console commands, local overrides, custom scripts, modifying http requests, websocket messages, etc. This can be done to intentionally break the game, play otherwise illegal moves, or to give you an advantage.\",\n\"Abuse bugs or glitches in order to abort the game, or give you an advantage.\",\n\"In rated games, receive help/advice from another person or program as to what you should play. (Creating an engine is ok and encouraged, but you must limit its use to unrated, casual, games)\",\n\"Trade elo points with other people by purposefully losing with intent to boost the elo of your opponent, or by receiving elo points from an opponent that intends to lose to boost your own rating. This abuses the system and creates unaccurate ratings according to your level of skill.\"\n]\ncleanliness_header = \"Cleanliness\"\ncleanliness_rules = [\n\"In all your language on the site, you must remain clean, no vulgarity or cursing. You cannot bully, harass, or threaten anyone, or do anything that is illegal. You cannot spam other users or forums.\",\n\"You cannot upload imagery to your profile that is inappropriate, suggestive, or gory. Doing so may result in a ban or termination of your account.\"\n]\nprivacy_header = \"Privacy\"\nprivacy_rules = [\n\"Currently, the only personal information we collect is your email. This is with intent to verify users' accounts, and provide a means of proving who they are when they request a password reset. We do not send any promotional emails or offers. We do not share any user's email address with anyone else.\",\n\"InfiniteChess.org may collect data about your usage on the site, including your ip address. This is intended to help prevent attacks from bots and other unwanted entities, and to keep accurate statistics in the database. This is NOT your home address.\",\n\"All games you play on this website become public information. If you wish to remain anonymous, do not share your username with friends or family. If this is your desire, it is your responsibility to make sure no one finds out your username is associated with your human identity.\",\n\"Your account online status, and the approximate last time you were active on the website, is also public information.\",\n[\"While InfiniteChess.org will strive to keep everyone's account and personal information safe to the best of our ability, in the event of a hack or data leak, you may not press charges on us. If a data leak ever happens, users will be notified on the \", \"News\", \"page.\"],\n\"There is no content available on the site for purchase. Any other personal information is not collected.\",\n\"To have your private information deleted from our servers, you may delete your account through your profile page. The only thing with ties to your username that we will NOT delete, is your game history, because all games are public information.\",\n]\ncookie_header = \"Cookie Policy\"\ncookie_paragraphs = [\n\"This site uses cookies, which are small text files that are stored in your browser, and sent to the server when connections are made. The purpose of these cookies are to: Validate your login session, validate your browser belongs to the chess game it says it's in, and to store user game preferences so they can keep their preferences when they re-visit the site. The site does not use 3rd-party cookies, cookies are not shared with external parties.\",\n\"Cookies are required for this site and game to function correctly. If you do not want the site to store cookies, you must stop using the site. You can navigate to your browsers preferences to delete existing cookies. By continuing to use this site, you are consenting to the use of cookies.\"\n]\nconclusion_header = \"Conclusion\"\nconclusion_paragraphs = [\n\"Any violations of these terms may result in a ban or termination of your account. InfiniteChess.org wants to be able to give everyone the opportunity to play and have fun! But, we reserve the right to, at any time, ban or terminate the accounts of any users, for reasons that need not to be disclosed. Charges may not be pressed against us.\",\n[\"These terms of service may be modified at any point. It is YOUR responsibility to make sure you stay updated on the latest changes! When these terms of service receive an update, that information will be posted on the\", \"News\", \"page. If, at the time of a terms-of-service update, you do not agree with the new terms, you must immediately stop using the website. You may delete your account from your profile page. If you delete your account, all your private information and account data will be deleted, EXCEPT we do not delete your game history associated with your username, that is public information.\"],\n[\"This site is open source. You may copy or distribute anything on this website as long as you follow the conditions outlined in\", \"the license terms\", \"! If this link is broken, it is your responsibility to find the terms.\"],\n\"We cannot guarantee the site will be running 100% of the time. We also cannot guarantee that data will never be corrupted.\",\n\"You may not perform any illegal activity on the site.\",\n[\"If you have any questions regarding these terms, or any other question about the site,\", \"email us!\"]\n]\nthanks = \"Thank you!\"\n\n[login]\ntitle = \"Log In\" # The tab name\nusername = \"Username:\"\npassword = \"Password:\"\nlogin_button = \"Log In\"\nsend_reset_link = \"Send Reset Link\"\nforgot_question = \"Forgot Password?\"\nback_to_login = \"Back to Login\"\nforgot_instruction = \"Please enter the email address associated with your account.\"\n\n[login.javascript]\nnetwork-error = \"A network error occurred. Please try again.\"\n\n[reset_password]\ntitle = \"Reset Your Password\"\ninstruction = \"Please enter and confirm your new password.\"\nnew_password = \"New Password\"\nconfirm_password = \"Confirm Password\"\nsubmit_button = \"Reset Password\"\n\n[error-pages] # Messages shown on some error pages explaining what went wrong\n400_message = \"Invalid parameters were received.\"\n409_message = [\"There may have been a clashing username or email. Please \", \"reload\", \", the page.\"]\n500_message = \"This isn't supposed to happen. There is some debugging to be done!\"\n\n[news]\ntitle = \"News\" # The tab name\nmore_dev_logs = [\"More dev logs are posted on the \", \"official discord\", \", and on the \", \"chess.com forums!\"]\n\n[server.javascript]\nws-invalid_username = \"Username is invalid\"\nws-incorrect_password = \"Password is incorrect\"\nws-login_failure_retry_in = \"Failed to login, try again in\"\nws-seconds = \"seconds\" # unit of time\nws-second = \"second\" # unit of time\nws-username_letters = \"Username must only contain letters A-Z and numbers 0-9\"\nws-username_taken = \"That username is taken\"\nws-username_bad_word = \"That username contains a word that is not allowed\"\nws-email_too_long = \"Your email is too looooooong.\"\nws-email_invalid = \"This is not a valid email\"\nws-email_in_use = \"This email is already in use\"\nws-email_domain_invalid = \"Invalid domain.\"\nws-email_blacklisted = \"Your email is blacklisted.\"\nws-password_length = \"Password must be 6-72 characters long\"\nws-password_password = \"Password must not be 'password'\"\nws-password-reset-link-sent = \"If an account with that email exists, a password reset link has been sent.\"\nws-password-change-success = \"Password has been reset successfully. You will be redirected to the login page shortly.\"\nws-password-reset-token-invalid = \"Password reset token is invalid or has expired.\"\nws-forbidden_wrong_account = \"Forbidden. This is not your account.\"\nws-deleting_account_not_found = \"Failed to delete account. Account not found.\"\nws-deleting_account_in_game = \"You cannot delete your account while you are still connected to an online game.\"\nws-server_error = \"Sorry, there was a server error! Please go back.\"\nws-not_found = \"404 Not Found\"\nws-forbidden = \"Forbidden.\"\nws-already_in_game = \"You are already in a game.\"\nws-you_cheated = \"Oops! You played something illegal. Game has been aborted. If this was a mistake, please report this bug!\"\nws-opponent_cheated = \"We caught your opponent playing something illegal. Game has been aborted.\"\nws-cannot_resign_finished_game = \"Can't resign game, it's already over.\"\nws-invalid_code = \"Invalid code!\" # Invite code doesn't match any existing invites\nws-game_aborted = \"Game aborted.\" # Invite was cancelled as you clicked on it\nws-rated_invite_verification_needed = \"To play ranked, you need to be signed in with a verified account.\"\n\n[rate-limiting]\ngeneric = \"You have made too many requests, please try again later.\"\nerror = \"Too many requests\""
  },
  {
    "path": "translation/es-ES.toml",
    "content": "name = \"Español\" # Name of language\nenglish_name = \"Spanish\"\ndirection = \"ltr\" # Change to \"rtl\" for right to left languages\nversion = \"83\"\nmaintainer = \"xa31\"\n\n[header]\nhome = \"Infinite Chess\"\nplay = \"Jugar\"\nnews = \"Noticias\"\nlogin = \"Iniciar Sesión\"\nprofile = \"Perfíl\"\ncreateaccount = \"Crear Cuenta\"\nlogout = \"Cerrar sesión\"\nleaderboard = \"Clasificaciónes\"\n\n[header.settings]\nlanguage = \"Idioma\"\nappearance = \"Apariencia\" # Board color/theme and visual effects\nappearance-theme = \"Tema\"\nappearance-starfield = \"Campo de Estrellas\" # The Starfield space animation underneath void\nappearance-advanced-effects = \"Efectos Avanzados\" # Post processing and board tile effects at extreme distances\nlegalmoves = \"Jugadas Legales\" # Legal moves shape\nlegalmoves-squares = \"Casillas\"\nlegalmoves-dots = \"Puntos\" # Dots and 4 corner triangles\nselection = \"Selección\"\nselection-drag = \"Arrastrar piezas\"\nselection-premove = \"Premovimientos (Premoves)\"\nselection-animations = \"Animaciones\"\nselection-lingering_annotations = \"Anotaciones duraderas\"\nperspective = \"Perspectiva\" # Perspective-mode\nperspective-mouse-sensitivity = \"Sensibilidad del ratón\"\nperspective-fov = \"Campo de visión (FOV)\"\nsound = \"Sonido\"\nsound-master-volume = \"Volúmen General\"\nsound-ambience = \"Ambientación\"\nping = [\"Latencia\", \"ms\"] # A number is inserted between these 2 strings.\nreset-to-default = \"Restablecer valores\"\n\n[footer]\ncontact = \"Contacte con nosotros\"\nterms_of_service = \"Términos de Servicio\"\nsource_code = \"Código Fuente\"\nlanguage = \"Idioma\"\n\n[member.javascript]\njs-confirm_delete = \"¿Esta seguro que desea eliminar su cuenta? !Esta acción NO puede ser deshecha! Haga clic en OK para introducir su contraseña.\"\njs-enter_password = \"Introduzca su contraseña para eliminar su cuenta PERMANENTEMENTE:\"\n\n[leaderboard.javascript]\nsupported_variants = \"Esta tabla se usa para las siguientes variantes: \"\nrank = \"Clasificación\"\nplayer = \"Jugador\"\nrating = \"Puntuación\"\n\n[index]\ntitle = \"Infinite Chess | Página principal – La web oficial\" # The tab title\nsecondary_title = \"¡La página web oficial para jugar!\"\nwhat_is_it_title = \"¿Qué es?\"\nwhat_is_it_pargaraphs = [\n\"Infinite Chess es una variante de ajedrez en la cual no hay bordes, y es mucho mas grande que el típico tablero de 8x8. La dama, torres y alfiles <em>no tienen ningún límite</em> a como de lejos pueden moverse cada turno. ¡Escoge cualquier número natural hasta el infinito!\",\n\"Sin límite a como de lejos puedes moverte, hay posiciones en las que el reloj de mate, o mate en <em>x</em>, adquiere el primer ordinal infinito, <strong>omega ω</strong>. De hecho, estudios han demostrado que <strong>cualquier</strong> número ordinal contable, ¡Es valido para el reloj de mate!\",\n\"Como puedes imaginar, hay infinitas posibilidades para la posición de inicio, ¡Muchas de las cuales puedes jugar competitivamente! Tu objetivo final sigue siendo el jaque mate, que requiere nuevas estrategias, dado que ya no hay muros para atrapar al rey rival. Las partidas no suelen durar mas que las de ajedrez normal. ¡Los Peones siguen coronando en las filas 1 y 8!\",\n]\nhow_to_title = \"¿Como Puedo Jugar?\"\nhow_to_paragraph = [\"¡La actual versión es la 1.10, y está disponible en la pagina \",\"Jugar\",\"!\"]\nabout_title = \"Sobre el proyecto\"\nabout_paragraphs = [\n\"Soy Naviary. Desde que descubrí Infinte Chess (el concepto existe desde hace mucho antes que esta web), ¡Me han intrigado sus posibilidades! Hasta hace muy poco, jugar ha sido bastante complicado, usuarios de chess.com tenían que crear imágenes del tablero actual y enviarlas entre ellos cada jugada. Debido a esto, no mucha gente conoce o ha sido capaz de jugar a esto.\",\n[\"Es mi objetivo crear una manera de hacerlo fácilmente jugable por todo el mundo, y crear una comunidad rodeándolo. He invertido incontables horas de mi tiempo en esta web, manteniendo y desarrollando el juego. Aún tengo muchas más ideas que me mantendrán ocupado durante un tiempo. Aunque deseo mantener este proyecto gratis, la vida tiene requerimientos, y para ayudarme financieramente por favor considera unirte a mi \", \"Patreon\", \".\"] # Patreon receives a hyperlink, here\n]\npatreon_title = \"Contribuidores en Patreon\"\ngithub_title = \"Contribuidores en Github\"\n\n[index.javascript]\ncontribution_count_singular = [\"\", \" contribución\"] # A number is inserted between these 2 strings.\ncontribution_count_plural = [\"\", \" contribuciones\"]\n\n[credits]\ntitle = \"Créditos\"\ncopyright = \"Cualquier cosa presente en la web que no este en la siguiente lista tiene copyright de www.InfiniteChess.org\"\nvariants_heading = \"Variantes\"\nvariants_credits = [\n\"Core diseñado por Andreas Tsevas.\",\n\"Space diseñado por Andreas Tsevas.\",\n\"Space Classic diseñado por Andreas Tsevas.\",\n\"Aeupi (Ajedrez en un plano infinito) diseñado por V. Reinhart.\",\n\"Pawn Horde diseñado por Inaccessible Cardinal.\",\n\"Abundance diseñado por Clicktuck Suskriberz.\",\n\"Pawndard hecho por SexyLexi.\",\n\"Classical+ hecho por SexyLexi.\",\n\"Knightline hecho por Inaccessible Cardinal.\",\n\"Knighted Chess hecho por cycy98.\",\n\"diseñado por Cory Evans y Joel Hamkins.\",\n\"diseñado por Andreas Tsevas.\",\n\"diseñado por Cory Evans y Joel Hamkins.\",\n\"diseñado por Cory Evans, Joel Hamkins, y Norman Lewis Perlmutter.\",\n\"Ajedrez en un plano infinito - Opciones Huygens por V. Reinhart.\",\n\"Trappist-1 por V. Reinhart\",\n\"Ajedrez 4x4x4x4 por Andreas Tsevas.\",\n\"Ajedrez 5D por Jace.\",\n]\ntextures_heading = \"Texturas\"\ntextures_licensed_under = \"texturas bajo la licencia\"\nsounds_heading = \"Sonidos\"\nsounds_credits = [\n[\"Algunos sonidos han sido proporcionados por el provecto\", \"bajo la licencia\"],\n\"Otros sonidos creados por Naviary.\",\n]\ncode_heading = \"Codigo\"\ncode_credits = [\n\"por Brandon Jones y Colin MacKenzie IV.\",\n\"por Andreas Tsevas y Naviary.\",\n]\nlanguage_heading = \"Traducciones\"\nlanguage_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded.\n\t\"Francés por \", \"Life Enjoyer\", \" y \", \"cycy98\", \".\",\n\t\"Chino simplificado por \", \"Heinrich Xiao\", \".\",\n\t\"Chino tradicional por \", \"Heinrich Xiao\", \".\",\n\t\"Polaco por \", \"Tymon Becella\", \".\", # Apsurt\n\t\"Portugués por \", \"Emerson P. Machado\", \".\", # The_Skeleton on discord\n\t\"Español por \", \"xa31er\", \".\",\n\t\"Alemán por \", \"Estetique\", \".\"\n]\n\n[member]\ntitle = \"Miembro\" # The tab name\nverify_message = \"Por favor comprueba tu e-mail para verificar tu cuenta. Las cuentas sin verificar se eliminan tras 3 días.\"\nresend_message = [\"¿El e-mail no ha llegado? Comprueba la carpeta de spam. También puedes, \", \"mandarlo otra vez.\", \"Si no puedes encontrarlo, \", \"mandanos un mensaje.\"]\nverify_confirm = \"¡Gracias! Tu cuenta ha sido verificada.\"\njoined = \"Se unió:\"\nseen = [\"Visto hace:\", \"\"]\npractice_progress = \"Progreso del modo práctica\"\nranked_elo = \"Puntuación:\"\ninfinity_leaderboard_position = \"Clasificación global:\"\ninfinity_leaderboard_rating_deviation = \"Desviación de la puntuación:\"\nreveal_info = \"Mostrar información de la Cuenta\"\naccount_info_heading = \"Información de la Cuenta\"\nemail = \"E-mail:\"\ndelete_account = \"Eliminar cuenta\"\n\n[member.badge-tooltips]\ncheckmate_bronze = \"Veterano del jaque mate: Completa el 50% de los mates de práctica.\"\ncheckmate_silver = \"Pro del jaque mate: Completa el 75% de los mates de práctica.\"\ncheckmate_gold = \"Maestro del jaque mate: Completa el 100% de los mates de práctica.\"\n\n[create-account]\ntitle = \"Crear Cuenta\" # The tab name\nusername = \"Nombre de Usuario:\"\nemail = \"E-mail:\"\npassword = \"contraseña:\"\ncreate_button = \"Crear Cuenta\"\nagreement = [\"Acepto los \", \"Términos de Servicio\", \".\"]  # the middle entry is a hyperlink, the others are not\n\n[create-account.javascript]\njs-username_tooshort = \"El nombre de usuario debe tener 3 caracteres como mínimo\"\njs-username_wrongenc = \"El nombre de usuario debe contener solo los caracteres A-Z y 0-9\"\njs-email_invalid = \"No es un e-mail válido\"\njs-email_too_long = \"El email es demasiado largo.\"\njs-email_inuse = \"Este e-mail ya esta registrado con otra cuenta\"\n\n[reset-password.javascript]\njs-pwd_no_match = \"Las contraseñas no coinciden\"\nreset-password = \"Reiniciar contraseña\"\nprocessing = \"Procesando...\"\nnetwork-error = \"Un error de red ha ocurrido. Pro favor prueba otra vez\"\n\n[password-validation]\njs-pwd_too_short = \"La contraseña debe tener 6 o más caracteres\"\njs-pwd_too_long = \"La contraseña no puede tener mas de 72 caracteres\"\njs-pwd_not_pwd = \"La contraseña no debe ser 'password'\"\n\n[leaderboard]\ntitle = \"Tabla de clasificaciones\"\ninactive_players = [\"Los jugadores inactivos con una incertidumbre de puntuación por encima de \", \" son excluídos de la la tabla de clasificaciónes.\"] # A number is inserted between these two quotes\nyour_global_ranking = \"Tu clasificación global:\"\nshow_more = \"Mostrar más...\"\n\n[play]\ntitle = \"Infinite Chess - Jugar\" # The tab title\nloading = \"CARGANDO\"\nerror = \"ERROR\"\n\n[play.main-menu]\ncredits = \"Créditos\"\nplay = \"Jugar\"\npractice = \"Practicar\"\nguide = \"Guía\"\neditor = \"Editor de tablero\"\n\n[play.guide]\ntitle = \"Guía\"\nrules = \"Reglas\"\nrules_paragraphs = [\n\"Las reglas del ajedrez infinito son casi idénticas a las del ajedrez clásico, ¡Excepto que el tablero es infinito en todas las direcciones! Estas son las únicas notas y cambios que necesitas saber:\",\n\"Las piezas que tienen movimientos deslizantes, como las torres, alfiles y damas, ¡No tienen ningún límite a como de lejos se pueden mover en un turno! Mientras su camino no esté obstruido, ¡Pueden moverse millones de casillas!\",\n[\"En variante por defecto \\\"Clásica\\\", los peones blancos coronan en la fila 8, y los negras en la fila 1. En esta imagen, esto se indica con las finas lineas negras, son difíciles de ver, ¡Fíjate a ver si puedes encontrarlas! Los Peones solo necesitan llegar a la línea opuesta para coronar\", \"no\", \" cruzarla.\"],\n\"La notación de las casillas ya no se describe por la letra y numero de fila, (ej. a1), en cambio, cada casilla se identifica por una pareja de coordenadas x e y. La casilla a1 se convierte en (1,1), y la casilla h8 en (8,8). En dispositivos de escritorio, la coordenada que el puntero de tu ratón esté encima se muestra en la parte superior de la pantalla\",\n\"¡Todas las demás reglas son iguales que en el ajedrez clásico, como el jaque mate, tablas, triple repetición, la regla de los 50 movimientos, enroque, en passant, etc.!\"\n]\ncareful_heading = \"¡Ten cuidado!\"\ncareful_paragraphs = [\n\"La libertad del tablero infinito significa que es muy fácil explotar ataques dobles y clavadas. Tu lado trasero es a menudo una parte muy vulnerable. ¡Ten cuidado con tácticas como esta! ¡Sé creativo al formar protección para tu rey y tus torres! La estrategia de apertura es muy distinta a la del ajedrez clásico.\",\n\"Muchas otras variantes han sido creadas con el objetivo de fortalecer tu defensa trasera.\"\n]\ncontrols_heading = \"Controles\"\ncontrols_paragraph = \"Haz clic y arrastra para mover la cámara. Mueve la rueda del ratón para hacer zoom. Haz clic en cualquiera pieza, incluidas las de tus oponentes, ¡Para ver sus movimientos legales en cualquier momento! Otros controles son:\"\nkeybinds = [\n\" para mover la cámara.\",\n[\"Espacio\", \" y \", \"Mayus Izq.\", \" para hacer zoom.\"],\n[\"Esc\", \" para pausar el juego.\"],\n[\"Tab\", \" activa o desactiva las flechas indicadoras en los bordes de la pantalla que apuntan a las piezas que están fuera de plano. Por defecto, están en modo \\\"Defensa\\\", que muestra una flecha para las piezas que pueden moverse a tu localización desde donde están. Pero \", \"tab\", \" puede cambiar al modo \\\"Todos\\\", o \\\"Ninguno\\\", de los cuales \\\"Todos\\\" revela todas las piezas en estas ortogonales y diagonales, sin importar que puedan moverse de esa manera. Esta opción también puede cambiarse en el menú de pausa. Hacer clic en las flechas te lleva a la pieza que están apuntando.\"],\n[\"Control\", \" fuerza el arrastre del tablero en vez de las piezas, si el arrastre está activado en las opciones.\"],\n\" activa o desactiva \\\"Modo Edición\\\" en partidas locales. ¡Esto te permite mover cualquier pieza a cualquier lugar del tablero! Muy útil para analizar posiciones.\"\n]\ncontrols_paragraph2 = \"Esos son los principales controles que necesitas saber.\"\nkeybinds_extra = [\n\" reinicia el renderizado de las piezas. Esto es útil si la pieza se vuelve invisible. Este error puede ocurrir si mueves las piezas a distancias extremas (como 1e12).\",\n\" activa o desactiva el renderizado de las barras de navegación e información, que puede ser útil para grabar ¡Hacer streaming y vídeos en el juego es bienvenido!\",\n\" activa o desactiva el medidor de FPS. Esto muestra el número de veces que se actualiza el juego por segundo, no siempre el numero de FPS, ya que el juego salta el renderizado cuando nada visible haya cambiado, para ahorrar tiempo de computación.\",\n\" activa o desactiva el renderizado por iconos. Los iconos son unas mini-imágenes clicables de las piezas cuando haces el suficiente zoom hacia fuera. En partidas importadas con más de 50.000 piezas esto se desactiva automáticamente, al ser costoso computacionalmente, pero puede volverse a activar con \",\n[\" (tilde invertida, la misma tecla que \", \") activará o desactivará el modo Debug\"],\n]\nfairy_heading = \"Piezas Fairy\"\nfairy_paragraph = \"Ya sabes todo lo que necesitas saber para jugar a la variante por defecto \\\"Clásica\\\". Las piezas de ajedrez Fairy no se usan en ajedrez convencional, ¡Pero están incorporadas en otras variantes! Si te encuentras en una variante con algunas piezas que no has visto antes, ¡Vamos a aprender como funcionan aquí!\"\nediting_heading = \"Edición de tablero\"\nediting_paragraphs = [\n[\"Hay un \", \"editor de tablero externo\", \" ¡Disponible ahora mismo en una hoja de Google sheets pública! Incluye instrucciones sobre como usarla. Requiere unos conocimientos básicos sobre Google Sheets. Después de la preparación, ¡Podrás crear y importar posiciones personalizadas en el juego a través de el botón \\\"Pegar Juego\\\" en el menu opciones!\"],\n\"Para jugar una posición personalizada con un amigo, mándale una invitación privada, después los dos pegáis el código de juego ¡Antes de empezar a jugar!\",\n\"Un editor de tablero integrado está planeado.\",\n]\nback = \"Atrás\"\n\n[play.guide.pieces]\nchancellor = {name=\"Canciller\", description=\"Se mueve como una torre y un caballo combinados.\"}\narchbishop = {name=\"Arzobispo\", description=\"Se mueve como un alfil y un caballo combinados.\"}\namazon = {name=\"Amazona\", description=\"Se mueve como una dama y un caballo combinados. ¡Esta es la pieza más potente del juego!\"}\nguard = {name=\"Guardia\", description=\"Se mueve como un rey, pero no es susceptible al jaque o el jaque mate.\"}\nhawk = {name=\"Halcón\", description=\"Salta exactamente 2 o 3 casillas en cualquier dirección.\"}\ncentaur = {name=\"Centauro\", description=\"Se mueve como un caballo y un guardia combinados.\"}\nknightrider = {name=\"Jinete\", description=\"Se mueve como un caballo infinitamente en una dirección, hasta que esté bloqueado.\"}\nhuygen = {name=\"Huygen\", description=\"Salta infinitamente en una de las direcciones cardinales, visitando solo los cuadrados primos desde la casilla de salida, hasta que se vea obstruido.\"}\nrose = {name=\"Rosa\", description=\"Jinete circular. Se mueve en trayectorias circulares horarias y antihorarias, saltando como un caballo, pero girando 45 grados tras cada salto. Puede ser bloqueada por otras piezas, que es la razón por la que el cuadrado rojo en la imágen no es accesible para la rosa.\"}\nobstacle = {name=\"Obstáculo\", description=\"Una pieza neutral (Que no es controlada por ningún jugador) que bloquea el movimiento, pero puede ser capturada.\"}\nvoid = {name=\"Vacío\", description=\"Una pieza neutral (Que no es controlada por ningún jugador) que representa la ausencia de tablero. Las demás piezas no pueden moverse a través o encima de él.\"}\n\n[play.practice-menu]\ntitle = \"Práctica - Mates\"\nplay = \"Jugar\"\nback = \"Atrás\"\ndifficulty = \"Dificultad\"\n\n[play.play-menu]\ntitle = \"Jugar - Online\"\ncolors = \"Colores\"\nonline = \"Online\"\nlocal = \"Local\"\ncomputer = \"Ordenador\"\nvariant = \"Variante\"\nClassical = \"Clásica\"\nConfined_Classical = \"Clásica confinada\"\nClassical_Plus = \"Clásica+\"\nCoaIP = \"Ajedrez en un plano infinito (Aeupi)\"\nPawndard = \"Pawndard\"\nKnighted_Chess = \"Knighted Chess\"\nPalace = \"Palacio\"\nKnightline = \"Knightline\"\nCore = \"Core\"\nStandarch = \"Standarch\"\nPawn_Horde = \"Horda de peones\"\nSpace_Classic = \"Clásica Espacio\"\nSpace = \"Espacio\"\nObstocean = \"Obstocean\"\nAbundance = \"Abundancia\"\nAmazon_Chandelier = \"Candelabro de Amazona\"\nContainment = \"Contención\"\nClassical_Limit_7 = \"Clásica - Límite 7\"\nCoaIP_Limit_7 = \"Aeupi - Límite 7\"\nChess = \"Ajedrez\"\nClassical_KOTH = \"Experimental: Clásica - KOTH\"\nCoaIP_KOTH = \"Experimental: Aeupi - KOTH\"\nCoaIP_HO = \"Ajedrez en un plano infinito - Huygens\"\nCoaIP_RO = \"Ajedrez en un plano infinito - Rosas\"\nCoaIP_NO = \"Ajedrez en un plano infinito - Jinetes\"\nOmega = \"Demostración: Omega\"\nOmega_Squared = \"Demostración: Omega^2\"\nOmega_Cubed = \"Demostración: Omega^3\"\nOmega_Fourth = \"Demostración: Omega^4\"\n4x4x4x4_Chess = \"Ajedrez 4×4×4×4\"\n5D_Chess = \"Ajedrez 5D\"\nno_clock = \"Sin Reloj\"\nclock = \"Reloj\"\nminutes = \"m\"\nseconds = \"s\"\ninfinite_time = \"Tiempo Infinito\"\ncolor = \"Color\"\npiece_colors = [\"Aleatorio\", \"Blancas\", \"Negras\"]\nprivate = \"Privado\"\nno = \"No\"\nyes = \"Sí\"\nrated = \"Por puntos\"\ncasual = \"Casual\"\neasy = \"Facil\"\nmedium = \"Normal\"\nhard = \"Difícil\"\njoin_games = \"Unirse a una partida existente - Partidas Activas:\"\nprivate_invite = \"Invitación privada:\"\nyour_invite = \"Tu código de invitación:\"\ncreate_invite = \"Crear invitación\"\njoin = \"Unirse\"\ncopy = \"Copiar\"\nback = \"Atrás\"\ncode = \"Codigo Fuente\"\n\n[play.gamebuttontooltips]\nundo_transition = \"Deshacer transición\"\nexpand_fit_all = \"Expandir para ver todo\"\nrecenter = \"Recentrar\"\nannotations = \"Dibujar anotaciones\"\nerase = \"Borrar anotaciones\"\ncollapse = \"Ocultar anotaciones\"\nrewind_move = \"Deshacer movimiento\"\nforward_move = \"Rehacer movimiento\"\nundo_edit = \"Deshacer (Crtl+Z)\" # Board editor\nredo_edit = \"Rehacer (Ctrl+Y)\" # Board editor\npause = \"Pausa\"\nundo = \"Deshacer movimiento\" # Checkmate practice game\nrestart = \"Reiniciar posición\" # Checkmate practice game\n\n[play.pause]\ntitle = \"Pausa\"\nresume = \"Resumir\"\narrows = \"Flechas: Defensa\"\nperspective = \"Perspectiva: Apagada\"\ncopy = \"Copiar Partida\"\npaste = \"Pegar Partida\"\noffer_draw = \"Ofrecer Tablas\"\npractice_menu = \"Menú de práctica\"\nmain_menu = \"Menú principal\"\n\n[play.drawoffer] # The draw offer UI that appears on the bottom bar\nquestion = \"Aceptar tablas?\"\n\n[play.javascript] # Not text that's included in the html, but text that scripts use!\nguest_indicator = \"(Invitado)\"\nyou_indicator = \"(Tú)\"\nengine_indicator = \"Ordenador\"\nplayer_name_white_generic = \"Blancas\"\nplayer_name_black_generic = \"Negras\"\nwhite_to_move = \"Juegan blancas\"\nblack_to_move = \"Juegan negras\"\nyour_move = \"Tu turno\"\ntheir_move = \"Su turno\"\nlost_network = \"Conexión perdida\"\nfailed_to_load = \"Uno o más recursos fallaron al cargar. Por favor refresca la página.\"\nplanned_feature = \"¡Esta función esta planeada!\"\nmain_menu = \"Menú principal\"\nresign_game = \"Rendirse\"\nabort_game = \"Abortar partida\"\noffer_draw = \"Ofrecer tablas\" # Offer draw button text in the pause menu\naccept_draw = \"Aceptar tablas\" # Offer draw button text in the pause menu\narrows_off = \"Flechas: Apagadas\"\narrows_defense = \"Flechas: Defensa\"\narrows_all = \"Flechas: Todas\"\narrows_all_hippogonals = \"Flechas: Todas (Con hippogonales)\"\ntoggled = \"Activado / Desactivado\"\nmenu_online = \"Jugar - Online\"\nmenu_local = \"Jugar -Local\"\ninvite_error_digits = \"El código de invitación tiene que tener 5 dígitos.\"\ninvite_copied = \"Código de invitación copiado al portapapeles.\"\nmove_counter = \"Jugada:\"\nconstructing_mesh = \"Construyendo malla\"\nrotating_mesh = \"Rotando malla\"\nlost_connection = \"Conexión perdida.\"\nplease_wait = \"Por favor espera un momento para realizar esta acción.\"\nwebgl_unsupported = \"¡Por favor, actualiza tu navegador! No es compatible con WebGL2.\"\nbigints_unsupported = \"Las BigInts no tienen soporte. Por favor actualiza tu navegador.\\nLas BigInts son necesarias para hacer que el tablero sea infinito.\"\n# Checkmate Practice\nversus = \"vs\"\neasy = \"Facil\"\nmedium = \"Media\"\nhard = \"Dificil\"\ninsane = \"Insana\"\ncheckmate_logged_out = \"Debes iniciar sesión para conseguir insignias\"\ncheckmate_bronze = \"Veterano del jaque mate: Completa el 50% de los mates de práctica.\"\ncheckmate_silver = \"Pro del jaque mate: Completa el 75% de los mates de práctica.\"\ncheckmate_gold = \"Maestro del jaque mate: Completa el 100% de los mates de práctica.\"\ncheckmate_bronze_unearned = \"Completa el 50% de los mates de práctica.\"\ncheckmate_silver_unearned = \"Completa el 75% de los mates de práctica.\"\ncheckmate_gold_unearned = \"Completa el 100% de los mates de práctica.\"\ncoords-invalid = \"Formato de coordenadas no válido. Por favor introduze números enteros o notación 'e' (ej. 1.23e4)\"\ncoords-exceeded = \"¡No puedes ir tan lejos! Eso sería muy facil ;)\"\n\n[play.javascript.piecenames] # The string representations of each raw piece type, as found in typeutil.strtypes\nvoid = \"Vacio\"\nobstacle = \"Obstáculo\"\nking = \"Rey\"\ngiraffe = \"Girafa\"\ncamel = \"Camello\"\nzebra = \"Cebra\"\nknightrider = \"Jinete\"\namazon = \"Amazona\"\nqueen = \"Dama\"\nroyalQueen = \"Dama real\"\nhawk = \"Halcón\"\nchancellor = \"Canciller\"\narchbishop = \"Arzobispo\"\ncentaur = \"Centauro\"\nroyalCentaur = \"Centauro real\"\nrose = \"Rosa\"\nknight = \"Caballo\"\nguard = \"Guardia\"\nhuygen = \"Huygen\"\nrook = \"Torre\"\nbishop = \"Alfil\"\npawn = \"Peón\"\n\n[play.javascript.copypaste]\ncopied_game = \"¡Posición copiada en el portapapeles!\"\ncannot_paste_in_public = \"¡No se pueden pegar posiciones en una partida pública\"\ncannot_paste_in_rated = \"¡No se pueden pegar posiciones en una partida por puntos!\"\ncannot_paste_in_engine = \"¡No se pueden pegar posiciones en una partida con el ordenador!\"\ncannot_paste_after_moves = \"¡No se pueden pegar posiciones si ya se han hecho movimientos!\"\nclipboard_denied = \"Permiso de portapapeles denegado. Esto puede ser tu navegador.\"\nclipboard_invalid = \"El portapapeles no está en notación ICN valida.\"\ngame_needs_to_specify = \"LA posición debe especificar el metadato 'Variant', o la propiedad 'position'.\"\ninvalid_wincon = \"El jugador tiene una condición de victoria no válida\"\npasting_game = \"Pegando posición...\"\npasting_in_private = \"Pegar una posición en una partida privada ¡Causará una desicronización si tu oponente no hace lo mismo!\"\npiece_count = \"Numero de piezas\"\nexceeded = \"excedido\"\nchanged_wincon = \"Cambiada la condición de victoria por jaque mate a captura real, y desactivado el renderizado de iconos. Pulsa 'P' para re-activarlo (no recomendado).\"\nloaded_from_clipboard = \"Partida cargada desde el portapapeles\"\ncopied_position = \"¡Posición copiada al portapapeles!\"\nloaded_position_from_clipboard = \"¡Posición cargada desde el portapapeles!\"\nreset_position = \"¡Posición reiniciada!\"\nclear_position = \"¡Posición limpiada!\"\n\n[play.javascript.rendering]\non = \"Activado\"\noff = \"Desactivado\"\nicon_rendering_off = \"Desactivado el renderizado de iconos.\"\nicon_rendering_on = \"Activado el renderizado de iconos.\"\nperspective = \"Perspectiva\"\nperspective_mode_on_desktop = \"¡El modo perspectiva está disponible en dispositivos de escritorio!\"\nmovement_tutorial = \"WASD para moverse. Espacio y Mayus Izq. para hacer zoom.\"\nregenerated_pieces = \"Piezas regeneradas.\"\n\n[play.javascript.invites]\nmove_mouse = \"Mueve el ratón para reconectarte\"\ncannot_cancel = \"No se puede cancelar una invitación de ID indefinido.\"\nyou_are_white = \"Eres: Blancas\"\nyou_are_black = \"Eres: Negras\"\nrandom = \"Aleatorio\"\naccept = \"Aceptar\"\ncancel = \"Cancelar\"\ncreate_invite = \"Crear Invitación\"\ncancel_invite = \"Cancelar Invitación\"\nstart_game = \"Empezar partida\"\njoin_existing_active_games = \"Unirse a una partida existente - Partidas Activas:\"\n\n[play.javascript.onlinegame]\nafk_warning = \"Estás AFK.\"\nopponent_afk = \"Tu oponente está AFK.\"\nopponent_disconnected = \"Tu oponente se ha desconectado.\"\nopponent_lost_connection = \"Tu oponente ha perdido conexión.\"\nauto_resigning_in = \"Auto-rindiéndose en\"\nauto_aborting_in = \"Auto-abortando en\"\nnot_logged_in = \"No has iniciado sesión. Por favor inicia sesión para reconectarse a esta partida.\"\ngame_no_longer_exists = \"Esta partida ya no existe\"\nanother_window_connected = \"Otra ventana se ha conectado.\"\nserver_restarting = \"Servidor reiniciándose inminentemente...\"\nserver_restarting_in = \"Servidor reiniciándose en\"\nminute = \"minuto\"\nminutes = \"minutos\"\n\n[play.javascript.websocket]\nno_connection = \"Sin conexión.\"\nreconnected = \"Reconectado.\"\nunable_to_identify_ip = \"Incapaz de identificar IP.\"\nonline_play_disabled = \"Juego Online desactivado. Las Cookies no están soportadas. Prueba con otro buscador.\"\ntoo_many_requests = \"Demasiadas conexiones. Inténtalo otra vez dentro de un rato.\"\nmessage_too_big = \"Mensaje demasiado grande.\"\ntoo_many_sockets = \"Demasiados sockets\"\norigin_error = \"Error de origen.\"\nconnection_closed = \"La conexión ha sido cerrada inesperadamente. Mensaje del servidor:\"\nplease_report_bug = \"Esto no debería pasar, ¡Por favor reporta este bug!\"\n\n[play.javascript.termination] # What caused the termination of the game, in spoken language\ncheckmate = \"Jaque mate\"\nstalemate = \"Tablas\"\nrepetition = \"Triple repetición\"\nmoverule = [\"Regla de los \", \" movimientos\"]  # The game inserts a number inbetween these two strings\ninsuffmat = \"Material insuficiente\"\nroyalcapture = \"Captura real\"\nallroyalscaptured = \"Todas las piezas reales capturadas\"\nallpiecescaptured = \"Todas las piezas capturadas\"\nkoth = \"King of the hill\"\nresignation = \"Resignación\"\nagreement = \"Acuerdo\" \ntime = \"Perdió por tiempo.\"\naborted = \"Abortado\" # Game was cancelled (no elo exchanged)\ndisconnect = \"Abandonado\" # A player left\n\n[play.javascript.results]\nyou_checkmate = \"¡Ganas por jaque mate!\"\nyou_time = \"¡Ganas por tiempo!\"\nyou_resignation = \"¡Ganas por resignación!\"\nyou_disconnect = \"¡Ganas por abandono!\"\nyou_royalcapture = \"¡Ganas por captura real!\"\nyou_allroyalscaptured = \"¡Ganas al capturar todas las piezas reales!\"\nyou_allpiecescaptured = \"¡Ganas al capturar todas las piezas!\"\nyou_koth = \"¡Ganas por king of the hill!\"\nyou_generic = \"¡Ganas!\"\ndraw_stalemate = \"¡Tablas por ahogamiento!\"\ndraw_repetition = \"¡Tablas por repetición!\"\ndraw_moverule = [\"Tablas por la regla de los \", \"movimientos\"] # The game inserts a number inbetween these two strings\ndraw_insuffmat = \"¡Tablas por material insuficiente!\"\ndraw_agreement = \"¡Tablas por acuerdo!\"\ndraw_generic = \"¡Tablas!\"\naborted = \"Partida abortada.\"\nopponent_checkmate = \"¡Pierdes por jaque mate!\"\nopponent_time = \"¡Pierdes por tiempo!\"\nopponent_resignation = \"¡Pierdes por resignación!\"\nopponent_disconnect = \"¡Pierdes por abandono!\"\nopponent_royalcapture = \"¡Pierdes por captura real!\"\nopponent_allroyalscaptured = \"¡Pierdes por captura real total!\"\nopponent_allpiecescaptured = \"¡Pierdes por captura de todas las piezas!\"\nopponent_koth = \"¡Pierdes por king of the hill!\"\nopponent_generic = \"¡Pierdes!\"\nwhite_checkmate = \"¡Las blancas ganan por jaque mate!\"\nblack_checkmate = \"¡Las negras ganan por jaque mate!\"\nwhite_time = \"¡Las blancas ganan por tiempo!\"\nblack_time = \"¡Las negras ganan por tiempo!\"\nwhite_resignation = \"¡Las blancas ganan por abandono!\"\nblack_resignation = \"¡Las negras ganan por abandono!\"\nwhite_disconnect = \"¡Las blancas ganan por desconexión!\"\nblack_disconnect = \"¡Las negras ganan por desconexión!\"\nwhite_royalcapture = \"¡Las blancas ganan por captura real!\"\nblack_royalcapture = \"¡Las negras ganan por captura real!\"\nwhite_allroyalscaptured = \"¡Las blancas ganan por captura real total!\"\nblack_allroyalscaptured = \"¡Las negras ganan por captura real total!\"\nwhite_allpiecescaptured = \"¡Las blancas ganan por captura de todas las piezas!\"\nblack_allpiecescaptured = \"¡Las negras ganan por captura de todas las piezas!\"\nwhite_koth = \"¡Las blancas ganan por king of the hill!\"\nblack_koth = \"¡Las negras ganan por king of the hill!\"\nbug_generic = \"¡Esto es un bug, por favor repórtalo!\"\n\n[terms]\ntitle = \"Términos del Servicio\"\nwarning = [\"ESTE DOCUMENTO NO ES VINCULANTE JURÍDICAMENTE. Solo somos responsables de la versión en inglés de este documento. Esta traducción se proporciona solo con un propósito informativo. Puedes acceder a la versión inglesa oficial \", \"aquí\", \".\"]\nconsent = \"Al usar esta página web, aceptas los siguientes términos. Si no aceptas, debes dejar de usar la página inmediatamente\"\nguardian_consent = \"Si eres menor de 18, debes recibir consentimiento de un pariente o tutor legal para usar esta página web y para crear una cuenta.\"\nparents_header = \"Padres\"\nparents_paragraphs = [\n\"Hay un algoritmo funcionando para evitar que los usuarios establezcan su nombre de usuario a palabras malsonantes comunes. Por el momento no hay ningún método de comunicación entre usuarios.\",\n\"Por el momento, los miembros no pueden establecer su imagen de perfil. Hay planes para permitir esta función. En ese momento ahremos lo posible para prevenir imágenes inapropiadas.\",\n]\nfair_play_header = \"Juego limpio\"\nfair_play_paragraph1 = [\"No puedes crear mas de una cuenta. Si te gustaría cambiar la dirección de email asociada con tu cuenta, \", \"contacta con nosotros.\"]\nfair_play_paragraph2 = \"Para mantener el juego limpio y justo para todos, NO debes:\"\nfair_play_rules = [\n\"Modificar o manipular el código de cualquier manera, incluyendo, pero no solo: Utilizar comandos de consola, controles locales, scripts personales, modificar peticiones http, mandar mensajes de websocket, etc. Esto puede hacerse intencionalmente para romper el juego, jugar movimientos ilegales, o para darte una ventaja.\",\n\"Abusar errores para abortar la partida, conseguir una ventaja, o para darte una ventaja.\",\n\"En partidas por puntos, recibir ayuda/consejos de otra persona o programa sobre lo que deberías hacer. (Crear un bot está bien y te animamos a hacerlo, pero debes limitar su uso a partidas amistosas, sin puntos)\",\n\"Intercambiar puntos elo con otras personas al perder de manera intencionada para subir su nivel, o recibiendo puntos elo de un oponente que quiere perder para subir tu nivel. Esto abusa el sistema y crea niveles de puntuación poco precisos que no representan tu nivel de habilidad.\"\n]\ncleanliness_header = \"Lenguaje y imágenes\"\ncleanliness_rules = [\n\"En todo el lenguaje que uses en esta página, debes permanecer limpio y educado, sin vulgaridades o palabras malsonantes. No puedes molestar, insultar o amenazar a nadie, o hacer nada ilegal. No puedes hacer spam a otros usuarios o foros.\",\n\"No puedes subir imágenes a tu perfil. que sean inapropiadas, sugestivas o gore. Hacerlo resultará en un ban o terminación de tu cuenta.\"\n]\nprivacy_header = \"Privacidad\"\nprivacy_rules = [\n\"Actualmente, la única información personal que guardamos es tu dirección de email. Esto tiene el propósito de verificar las cuentas de usuario, y proporcionar una manera de demostrar quiénes son cuando pidan un reinicio de contraseña. No mandamos ningún email promocional o ofertas. No compartimos las direcciones de correo de los usuarios con nadie\",\n\"InfiniteChess.org puede guardar datos sobre tu uso del sitio, incluyendo tu dirección ip. Esto tiene la intención de prevenir ataques de bots y otras entidades no deseadas, y para mantener estadísticas precisas en la base de datos. Esto NO es tu dirección real.\",\n\"Todas las partidas que juegues en esta web se convierten en información pública. Si deseas mantener tu anonimidad, no compartas tu nombre de usuario con amigos o familia. Si ese es tu deseo, es tu responsabilidad asegurarte de que nadie descubre que tu nombre de usuario esta asociado con tu identidad humana.\",\n\"El estado de actividad de tu cuenta, y el tiempo aproximado desde la última vez que estuviste activo en la web, también en información pública.\",\n[\"Aunque InfiniteChess.org siempre intentará mantener la información personal y de la cuenta segura de todo el mundo el máximo posible, en el evento de un hackeo o filtración de datos, no podrás presentar cargos. Si alguna vez ocurriese una filtración, los usuarios serán notificados en la página \", \"Noticias\", \".\"],\n\"No hay contenido disponible para la compra en esta web. Cualquier otro tipo de información persona no se guarda.\",\n\"Para eliminar tu información personal de nuestros servidores, elimina tu cuenta desde tu página de perfil. La única cosa con relación a tu nombre de usuario que NO eliminaremos, son las partidas que juegues, porque son información pública.\",\n]\ncookie_header = \"Política de Cookies\"\ncookie_paragraphs = [\n\"Esta web utiliza cookies, que son pequeños ficheros de texto que se guardan en tu navegador, y mandadas a los servidores cuando se realiza una conexión. El propósito de estas cookies es: Validar tu inicio de sesión, validar que tu navegado pertenece a la partida en la que dice que está, y guardar preferencias de partida del usuario para que puedan guardarlas la próxima vez que visiten el sitio. Esta web no utiliza cookies de terceros, y las cookies no se comparten con grupos externos.\",\n\"Las cookies son necesarias para el correcto funcionamiento de esta web y del juego. Si no deseas que el sitio guarde cookies, debes parar de usarlo. Puedes navegar a las preferencias de tu navegador para eliminar cookies ya existentes. Al continuar usando este sitio, estas consintiendo al uso de cookies.\"\n]\nconclusion_header = \"Conclusión\"\nconclusion_paragraphs = [\n\"Cualquier violación de estos términos puede resultar en un ban o terminación de tu cuenta. ¡InfiniteChess.org quiere ser capaz de dar a todo el mundo la oportunidad de jugar y divertirse! Pero reservamos el derecho a, en cualquier momento, banear o terminar las cuentas de cualquier usuario, por razones que no necesitan ser hechas públicas. No podrás presentar cargos.\",\n[\"Estos términos de servicio pueden ser modificados en cualquier momento. ¡Es TU responsabilidad asegurarte de que te mantienes actualizado con los últimos cambios! Cuándo estos términos del servicio reciban una actualización, esa información sera publicada en la página \", \"Noticias\", \". Si en el momento de una actualización de los términos de servicio, no estás de acuerdo con los nuevos términos, debes dejar de usar la web inmediatamente. Puedes eliminar tu cuenta en la página de perfil. Si eliminas tu cuenta, toda tu información privada y datos de la cuenta serán eliminados, EXCEPTO el registro de partidas jugadas con tu cuenta, que es información pública.\"],\n[\"Esta web es de código abierto. Puedes copiar o distribuir cualquier cosa en esta web, ¡Siempre y cuando sigas las condiciones especificadas en\",\"los términos de la licencia\",\"! Si este link no funciona, es tu responsabilidad encontrar los términos.\"],\n\"No podemos garantizar que la página vaya a estar funcionando el 100% del tiempo. Tampoco podemos garantizar que los datos nunca estarán corruptos.\",\n\"No puedes realizar ninguna actividad ilegal en esta página web.\",\n[\"Si tienes alguna pregunta sobre estos términos, o cualquier otra pregunta sobre la página,\", \"¡Mándanos un correo!\"]\n]\nthanks = \"¡Gracias!\"\n\n[login]\ntitle = \"Inicio de Sesión\" # The tab name\nusername = \"Nombre de usuario:\"\npassword = \"Contraseña:\"\nlogin_button = \"Iniciar sesión\"\nsend_reset_link = \"Enviar link de reinicio\"\nforgot_question = \"Olvidaste tu contraseña?\"\nback_to_login = \"Volver a la pagina de inicio de sesión\"\nforgot_instruction = \"Por favor introduzca el correo electronico asociado a su cuenta.\"\n\n[login.javascript]\nnetwork-error = \"Un error de conexión ha ocurrido. Por favor intentalo otra vez.\"\n\n[reset_password]\ntitle = \"Reinicio de contraseña\"\ninstruction = \"Por favor introduze y confirma tu contraseña\"\nnew_password = \"Nueva contraseña\"\nconfirm_password = \"Confirma tu contraseña\"\nsubmit_button = \"Reiniciar contraseña\"\n\n[error-pages] # Messages shown on some error pages explaining what went wrong\n400_message = \"Parámetros no válidos recibidos\"\n409_message = [\"Puede que haya un nombre de usuario o correo conflictivo. Por favor \", \"recarga\", \" la página.\"]\n500_message = \"Esto no debería pasar ¡Hay algo de debugging por hacer!\"\n\n[news]\ntitle = \"Noticias\" # The tab name\nmore_dev_logs = [\"¡Mas devlogs se publican en \", \"el discord oficial\", \" y en los \", \"foros de chess.com!\"]\n\n[server.javascript]\nws-invalid_username = \"El nombre de usuario no es válido\"\nws-incorrect_password = \"La contraseña es incorrecta\"\nws-login_failure_retry_in = \"Inicio de sesión fallido, inténtalo de nuevo en\"\nws-seconds = \"segundos\" # unit of time\nws-second = \"segundo\" # unit of time\nws-username_length = \"El nombre de usuario debe tener entre 3 y 20 caracteres\"\nws-username_letters = \"El nombre de usuario debe contener solo las letras A-Z y los números 0-9\"\nws-username_taken = \"Ese nombre de usuario ya existe\"\nws-username_bad_word = \"Ese nombre de usuario contiene una palabra que no está permitida\"\nws-username_reserved = \"Ese nombre de usuario está reservado\"\nws-email_too_long = \"Tu email es demasiaaaaado largo\"\nws-email_invalid = \"Eso no es un email válido\"\nws-email_in_use = \"Ese email ya ha sido registrado\"\nws-email_domain_invalid = \"Dominio no válido\"\nws-email_blacklisted = \"Tu email está en la lista negra.\"\nws-password_length = \"La contraseña debe tener entre 6 y 72 caracteres\"\nws-password_password = \"La contraseña no debe ser 'password'\"\nws-password-reset-link-sent = \"Si una contraseña con ese email existe, un link de reinicio de contraseña ha sido enviado.\"\nws-password-change-success = \"La contraseña ha sido reiniciada correctamente. Serás redirigido a la página de inicio de sesión en breve.\"\nws-password-reset-token-invalid = \"El token de reinicio de contraseña no es válido o ha expirado.\"\nws-forbidden_wrong_account = \"Prohibido. Esta no es tu cuenta\"\nws-deleting_account_not_found = \"Error al eliminar la cuenta. No se ha encontrado la cuenta.\"\nws-deleting_account_in_game = \"No puedes eliminar tu cuenta mientras estás conectado a un a partida.\"\nws-server_error = \"Lo sentimos, ¡Ha habido un error de servidores! Por favor vuelve atrás.\"\nws-not_found = \"404 Página no encontrada\"\nws-forbidden = \"Prohibido.\"\nws-already_in_game = \"Ya estas en una partida.\"\nws-server_restarting = \"El servidor se reiniciará en\" # The server inserts a number immediately after this, followed by the correct plurality of minutes.\nws-server_under_maintenance = \"El servidor esta bajo mantenimiento. ¡Vuelve en un rato!\" # Can be changed at will to change the display message.\nws-minutes = \"minutos\" # unit of time\nws-minute = \"minuto\" # unit of time\nws-you_cheated = \"¡Ups! Has jugado algo ilegal. La partida ha sido abortada. ¡Si esto ha sido un error, por favor reportalo!\"\nws-opponent_cheated = \"Tu oponente ha hecho algo ilegal. La partida ha sido abortada.\"\nws-cannot_resign_finished_game = \"No se puede conceder la partida, ya ha acabado.\"\nws-invalid_code = \"¡Código invalido!\" # Invite code doesn't match any existing invites\nws-game_aborted = \"Partida abortada.\" # Invite was cancelled as you clicked on it\nws-rated_invite_verification_needed = \"Para jugar partidas por puntos, necesitas iniciar sesión con una cuenta verificada.\"\n\n[rate-limiting]\ngeneric = \"Has hecho muchas peticiones, por favor intentalo mas tarde.\""
  },
  {
    "path": "translation/fi-FI.toml",
    "content": "name = \"Suomi\" # Name of language\nenglish_name = \"Finnish\"\ndirection = \"ltr\" # Change to \"rtl\" for right to left languages\nversion = \"90\"\nmaintainer = \"ThisIsNotAvailable\"\n\n[header]\nhome = \"Koti\"\nplay = \"Pelaa\"\nnews = \"Uutiset\"\nlogin = \"Kirjaudu\"\nprofile = \"Profiili\"\ncreateaccount = \"Luo tili\"\nlogout = \"Kirjaudu ulos\"\nleaderboard = \"Tulostaulukko\"\n\n[header.settings]\nlanguage = \"Kieli\"\nappearance = \"Ulkonäkö\" # Board color/theme and visual effects\nappearance-theme = \"Teema\"\nappearance-starfield = \"Tähtikenttä\" # The Starfield space animation underneath void\nappearance-advanced-effects = \"Edistyneet tehosteet\" # Post processing and board tile effects at extreme distances\nlegalmoves = \"Sallitut siirrot\" # Legal moves shape\nlegalmoves-squares = \"Neliöt\"\nlegalmoves-dots = \"Pisteet\" # Dots and 4 corner triangles\nselection = \"Valitseminen\"\nselection-drag = \"Vetäminen\"\nselection-premove = \"Aikaissiirrot\"\nselection-animations = \"Animaatiot\"\nselection-lingering_annotations = \"Pysyvät merkinnät\"\nperspective = \"Perspektiivi\" # Perspective-mode\nperspective-mouse-sensitivity = \"Hiiren herkkyys\"\nperspective-fov = \"Näkökenttä\"\nsound = \"Ääni\"\nsound-master-volume = \"Äänenvoimakkuus\"\nsound-ambience = \"Tunnelmaääni\"\nping = [\"Ping\", \"ms\"] # A number is inserted between these 2 strings.\nreset-to-default = \"Palauta oletusasetuksiin\"\n\n[footer]\ncontact = \"Ota yhteyttä\"\nterms_of_service = \"Käyttöehdot\"\nsource_code = \"Lähdekoodi\"\nlanguage = \"Kieli\"\n\n[member.javascript]\njs-confirm_delete = \"Oletko varma, että haluat poistaa tilisi? Tätä EI VOI peruuttaa! Paina OK syöttääksesi salasanasi.\"\njs-enter_password = \"Syötä salasanasi poistaaksesi PYSYVÄSTI sinun tilisi:\"\n\n[leaderboard.javascript]\nsupported_variants = \"Tämä tulostaulukko on käytössä seuraaville varianteille:\"\nrank = \"Sijoitus\"\nplayer = \"Pelaaja\"\nrating = \"Luokitus\"\n\n[index]\ntitle = \"Infinite Chess | Koti - Virallinen Nettisivu\" # The tab title\nsecondary_title = \"Virallinen nettisivu livenä pelaamiseen!\"\nwhat_is_it_title = \"Mitä se on?\"\nwhat_is_it_pargaraphs = [\n\"Infinite Chess on shakkivariantti, jossa ei ole reunoja, mikä tekee siitä paljon suuremman kuin tuttu 8x8 shakkilauta. Kuningattarilla, torneilla, ja lähettiläillä <em>ei ole rajoja</em> niiden liikkumiseen yhden vuoron aikana. Valitse mikä tahansa kokonaisluku äärettömään asti!\",\n\"Ilman rajoituksia sille, kuinka pitkälle voit liikkua, on olemassa asemia, joissa tuomiopäivän kello, tai shakkimatti-<em>jossain</em>-siirrossa, numero on edustettuna ensimmäisellä äärettömällä järjestysluvulla, <strong>omegalla ω</strong>. Tutkijat ovat todistaneet, että <strong>mikä tahansa</strong> laskettava järjestysluku on saavutettavissa shakkimattikellossa!\",\n\"Kuten voit kuvitella, on olemassa äärettömästi mahdollisuuksia aloituskokoonpanoille, joista monta voi pelata kilpailullisesti! Pyrit edelleen shakkimattiin, mikä tarvitsee uusia taktiikoita, koska ei ole olemassa reunoja, joihin vastustajan kuninkaan voisi vangita. Pelit eivät yleensä kestä paljon pidempään kuin tavalliset shakkipelit. Sotilaat edelleen korontuvat riveillä 1 ja 8!\",\n]\nhow_to_title = \"Miten voin pelata?\"\nhow_to_paragraph = [\"Tämän hetkinen versio on 1.10 \",\"Pelaa\",\" sivulla!\"]\nabout_title = \"Tietoa projektista\"\nabout_paragraphs = [\n\"Minä olen Naviary. Siitä lähtien, kun löysin äärettömän shakin (konsepti oli olemassa kauan ennen tätä nettisivua), minua on kiehtonut sen mahdollisuudet! Viime aikoihin asti pelaaminen on ollut todella vaikeaa, vaatien chess.com jäsenien luomaan kuvia sen hetkisestä shakkilaudasta ja lähettämään ne toiselle pelaajalle jokaisella siirrolla. Sen takia harva tietää tästä tai on pelannut tätä.\",\n[\"Minun tavoitteeni on luoda tapa, joka tekee tästä helposti saavutettavan kaikille ja kasvattaa yleisöä sen ympärille. Olen käyttänyt lukemattomia tunteja omasta ajastani tämän nettisivun ylläpitämiseen ja sen kehittämiseen. Minulla on monen monta ideaa, jotka ovat pitäneet minut kiireellisinä. Vaikka haluankin pitää tämän ilmaisena, elämällä on vaatimuksensa. Tukeaksesi minua taloudellisesti harkitse liityväsi minun \", \"Patreoniin\"] # Patreon receives a hyperlink, here\n]\npatreon_title = \"Patreon tukijat\"\ngithub_title = \"Github avustajat\"\n\n[index.javascript]\ncontribution_count_singular = [\"\", \" avustus\"] # A number is inserted between these 2 strings.\ncontribution_count_plural = [\"\", \" avustusta\"]\n\n[credits]\ntitle = \"Krediitit\"\ncopyright = \"Kaikki tällä nettisivulla, jota ei alla mainita on www.InfiniteChess.org omaisuutta\"\nvariants_heading = \"Variantit\"\nvariants_credits = [\n\"Ytimen suunnitteli Andreas Tsevas.\",\n\"Avaruuden suunnitteli Andreas Tsevas.\",\n\"Avaruus klassisen suunnitteli Andreas Tsevas.\",\n\"Coaip (Shakkia äärettömällä alustalla) suunnitteli Vickalan.\",\n\"Sotilaslauman suunnitteli Inaccessible Cardinal.\",\n\"Runsauden suunnitteli Clicktuck Suskriberz.\",\n\"Panttimiehen suunnitteli SexyLexi.\",\n\"Klassinen+ suunnitteli SexyLexi.\",\n\"Heppalinjan suunnitteli Inaccessible Cardinal.\",\n\"Hepoitetun shakin suunnitteli cycy98.\",\n\"suunnitteli Cory Evans ja Joel Hamkins.\",\n\"suunnitteli Andreas Tsevas.\",\n\"suunnitteli Cory Evans ja Joel Hamkins.\",\n\"suunnitteli Cory Evans, Joel Hamkins, ja Norman Lewis Perlmutter.\",\n\"Shakkia äärettömällä alustalla - Huygen variantit suunnitteli V. Reinhart.\",\n\"Rajoitettu klassinen suunnitteli Andreas Tsevas.\",\n\"4x4x4x4 Shakki suunnitteli Andreas Tsevas.\",\n\"5D Shakki suunnitteli Jace.\",\n]\ntextures_heading = \"Tekstuurit\"\ntextures_licensed_under = \"tekstuurit on lisentoitu\"\nsounds_heading = \"Äänet\"\nsounds_credits = [\n[\"Jotkin äänet tarjoaa \", \"projekti lisenssillä\"],\n\"Muut äänet on luonut Naviary.\",\n]\ncode_heading = \"Koodi\"\ncode_credits = [\n\"ohjelmoi Brandon Jones ja Colin MacKenzie IV.\",\n\"ohjelmoi Andreas Tsevas ja Naviary.\",\n]\nlanguage_heading = \"Kääntäjät\"\nlanguage_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded.\n\t\"Ranskan käänsi \", \"Life Enjoyer\", \" ja \", \"cycy98\", \".\",\n\t\"Yksinkertaistetun kiinan käänsi \", \"Heinrich Xiao\", \".\",\n\t\"Perinteisen kiinan käänsi \", \"Heinrich Xiao\", \".\",\n\t\"Puolan käänsi \", \"Tymon Becella\", \".\", # Apsurt\n\t\"Portugalin käänsi \", \"The_Skeleton\", \".\", # The_Skeleton on discord\n\t\"Espanja käänsi \", \"xa31er\", \".\",\n\t\"Saksan käänsi \", \"Estetique\", \".\"\n]\n\n[member]\ntitle = \"Jäsen\" # The tab name\nverify_message = \"Tarkista sähköpostisi vahvistaaksesi tilisi. Vahvistamattomat tilit poistetaan 3 päivän jälkeen.\"\nresend_message = [\"Etkö saanut sähköpostia? Tarkista roskapostisi. Jos et löydä sitä sieltä, \", \"lähetä se uudelleen.\", \" Jos et vieläkään löydä sitä, \", \"ota meihin yhteyttä.\"]\nverify_confirm = \"Kiitos! Tilisi on vahvistettu.\"\njoined = \"Liityi:\"\nseen = \"Nähty:\" # Last seen: ____\npractice_progress = \"Harjoitustilan edistys:\"\nranked_elo = \"Luokitus:\"\ninfinity_leaderboard_position = \"Sijoitus:\"\ninfinity_leaderboard_rating_deviation = \"Epävarmuus:\"\nreveal_info = \"Näytä Tilitiedot\"\naccount_info_heading = \"Tilitiedot\"\nemail = \"Sähköpostiosoite:\"\ndelete_account = \"Poista tili\"\n\n[member.badge-tooltips]\ncheckmate_bronze = \"Shakkimatti veteraani: Läpäise 50% kaikista harjoitusshakkimateista.\"\ncheckmate_silver = \"Shakkimatti ammattilainen: Läpäise 75% kaikista harjoitusshakkimateista.\"\ncheckmate_gold = \"Shakkimatti mestari: Läpäise 100% kaikista harjoitusshakkimateista.\"\n\n[create-account]\ntitle = \"Luo tili\" # The tab name\nusername = \"Käyttäjänimi:\"\nemail = \"Sähköposti:\"\npassword = \"Salasana:\"\ncreate_button = \"Luo tili\"\nagreement = [\"Minä suostun \", \"Käyttöehtoihin\", \".\"]  # the middle entry is a hyperlink, the others are not\n\n[create-account.javascript]\njs-username_reserved = \"Tämä käyttäjänimi on varattu\"\njs-username_length = \"KKäyttäjänimen täytyy olla 3-20 kirjainta pitkä\"\njs-username_tooshort = \"Käyttäjänimen täytyy olla vähintään 3 kirjainta pitkä\"\njs-username_wrongenc = \"Käyttäjänimen täytyy sisältää vain kirjaimia A-Z ja numeroita 0-9\"\njs-email_invalid = \"Tämä ei ole oikea sähköpostiosoite\"\njs-email_too_long = \"Tämä sähköpostiosoite on liian pitkä\"\njs-email_inuse = \"Sähköpostiosoite on jo käytössä\"\n\n[reset-password.javascript]\njs-pwd_no_match = \"Salasanat eivät täsmää.\"\nreset-password = \"Palauta salasana\"\nprocessing = \"Käsitellään...\"\nnetwork-error = \"Verkkovirhe tapahtui. Yritä uudelleen.\"\n\n[password-validation]\njs-pwd_too_short = \"Salasana ei saa olla alle 6 kirjainta pitkä\"\njs-pwd_too_long = \"Salasana ei saa olla yli 72 kirjainta pitkä\"\njs-pwd_not_pwd = \"Salasana ei saa olla 'password'\"\n\n[leaderboard]\ntitle = \"Tulostaulukko\"\ninactive_players = [\"Passiiviset pelaajat, joilla on epävarmuus korkeampi kuin \", \", suljetaan pois tulostaulukosta.\"] # A number is inserted between these two quotes\nyour_global_ranking = \"Sinun sijoituksesi:\"\nshow_more = \"Näytä lisää...\"\n\n[play]\ntitle = \"Infinite Chess - Pelaa\" # The tab title\nloading = \"LATAUTUU\"\nerror = \"VIRHE\"\n\n[play.main-menu]\ncredits = \"Krediitit\"\nplay = \"Pelaa\"\npractice = \"Harjoittele\"\nguide = \"Opas\"\neditor = \"Laudan muokkaaja\"\n\n[play.editor]\n# Sidebar section headers\nposition = \"Asema\"\ntools = \"Työkalut\"\nselection = \"Valitseminen\"\npalette = \"Paletti\"\ncolor = \"Väri\"\n# Sidebar button tooltips\ntooltip_reset = \"Palauta asema\"\ntooltip_clear = \"Tyhjennä asema\"\ntooltip_load = \"Avaa asema\"\ntooltip_save_as = \"Tallenna asema nimellä\"\ntooltip_save = \"Tallenna asema\"\ntooltip_copy_notation = \"Kopioi merkintä\"\ntooltip_paste_notation = \"Liitä merkintä\"\ntooltip_gamerules = \"Pelisäännöt\"\ntooltip_start_local = \"Aloita paikallinen peli asemasta\"\ntooltip_start_engine = \"Aloita tietokonepeli asemasta\"\n# Tool tooltips\ntooltip_normal = \"Tavallinen (F)\"\ntooltip_eraser = \"Pyyhekumi (G)\"\ntooltip_selection_tool = \"Valitseminen (H)\"\ntooltip_specialrights = \"Erityisoikeuksien vaihto (J)\"\n# Selection tooltips\ntooltip_select_all = \"Valitse kaikki (Ctrl+A)\"\ntooltip_clear_selection = \"Tyhjennä valinta (Del)\"\ntooltip_copy_selection = \"Kopioi valinta (Ctrl+C)\"\ntooltip_paste_selection = \"Liitä valinta (Ctrl+V)\"\ntooltip_invert_color = \"Vaihda valinnan väri\"\ntooltip_rotate_left = \"Käännä valinta vasemmalle\"\ntooltip_rotate_right = \"Käännä valinta oikealla\"\ntooltip_flip_horizontal = \"Käännä valinta vaakasuorassa\"\ntooltip_flip_vertical = \"Käännä valinta pystysuunnassa\"\n# Reset Position window\nreset_header = \"Palauta asema\"\nreset_message = \"Haluatko palauttaa aseman ja luoda uuden? Tallentamattomat muutokset menetetään.\"\n# Clear Position window\nclear_header = \"Tyhjennä asema\"\nclear_message = \"Haluatko tyhjentää aseman ja luoda uuden? Tallentamattomat muutokset menetetään.\"\n# Load Position window\nenter_position_name = \"Syötä aseman nimi:\"\nsave_button = \"Tallenna\"\nname_header = \"Nimi\"\npieces_header = \"Nappulat\" # Represent piece count\ndate_header = \"Päiväys\" # Represents date last modified\n# Game Rules window\ngamerules_header = \"Pelisäännöt\"\nplayer_to_move = \"Aloittava pelaaja:\"\nwhite = \"Valkoinen\"\nblack = \"Musta\"\nen_passant = \"Ohestalyönti neliö:\"\nmove_rule = \"Siirtosäännön tila:\"\npromotion_ranks_white = \"Ylennysrivit (Valkoinen):\"\npromotion_ranks_black = \"Ylennysrivit (Musta):\"\npromotion_pieces = \"Ylennettävät nappulat:\"\nglobal_special_rights = \"Yleiset erityisoikeudet:\"\npawn_double_push = \"Sotilaan kaksoissiirto\"\ncastling_label = \"Linnoittuminen\"\nwin_conditions = \"Voittoehdot:\"\ncheckmate = \"Shakkimatti\"\nroyal_capture = \"Kuninkaallisen kaappaus\"\nall_royals_captured = \"Kaikkien kuninkaalisten syönti\"\nall_pieces_captured = \"Kaikkien nappuloiden syönti\"\nworld_border = \"Laudan reuna:\"\n# Start Local Game window\nstart_local_game = \"Aloita paikallinen peli\"\nstart_local_game_message = \"Haluatko poistua laudan muokkaajasta ja aloittaa paikallisen pelin tästä asemasta? Muutokset tallentuvat.\"\n# Start Engine Game window\nstart_engine_game = \"Aloita tietokonepeli\"\nplay_as = \"Pelaa:\"\ntime_control = \"Aika (tyhjä = ääretön aika):\"\nengine_difficulty = \"Tietokoneen taso:\"\neasy = \"helppo\"\nmedium = \"Keskivaikea\"\nhard = \"Vaikea\"\nuse_default_border = \"Käytä tavallista tietokoneen kentän kokoa:\"\nstart_engine_game_message = \"Haluatko poistua laudan muokkaajasta ja aloittaa tietokonepelin tästä asemasta? Muutokset tallentuvat.\"\n# Common\nyes = \"Kyllä\"\nno = \"Ei\"\n\n[play.guide]\ntitle = \"Opas\"\nrules = \"Säännöt\"\nrules_paragraphs = [\n\"Äärettömän shakin säännöt ovat melkein identtiset tavallisen shakin kanssa, paitsi että shakkilauta on ääretön kaikissa suunnissa! Nämä ovat ainoat huomiot ja muutokset, joista sinun pitää tietää:\",\n\"Nappuloilta, jotka liikkuvat suorassa linjassa, kuten tornit, lähetit, ja kuningatar, on poistettu niiden tavalliset etäisyysrajoitukset! Niin kauan, jun niiden reitty on esteetön, sinä voit liikkua miljoonia ruutuja!\",\n[\"\\\"Klassisessa\\\" variantissa, valkoiset sotilaat ylentyvät rivillä 8, ja mustat sotilaat rivillä 1. Tässä kuvassa, tämä hainnollistetaan ohuilla mustilla viivoilla. Ne ovat todella ohuesti piirretty, näetkö ne? Sotilaiden täytyy päästä vastakkaiselle viivalle ylentyäkseen, \", \"ei\", \" sen yli.\"],\n\"Ruutujen merkintätapa ei enää perustu kirjaimeen ja rivinumeroon (esim. a1), vaan jokainen ruutu määritellään x- ja y-koordinaattiparilla. a1-ruutu on nyt (1,1) ja h8-ruutu on nyt (8,8). Tietokoneella koordinaatti, jonka päällä hiiri on, näytetään näytön yläreunassa.\",\n\"Kaikki muut säännöt ovat samat kuin tavallisessa shakissa, kuten shakkimatti, tasapeli, kolminkertainen toisto, 50-siirron sääntö, linnoittuminen, en passant, jne.!\"\n]\ncareful_heading = \"Ole varovainen!\"\ncareful_paragraphs = [\n\"Äärettömän laudan avonaisuus tarkoitta sitä, että on todella helppoa hyväksikäytää haarukoita ja vartaita, koska sinun taaimmainen puoli on usein todella haavoittuvainen. Yritä huomata tälläiset taktiikat! Ole luova suojelun luomisessa sinun kuninkaalle ja torneille! Avausstrategia on todella erilainen kuin tavallisessa shakissa.\",\n\"Monet muut variantit on luotu sinun taaimmaisen puolen vahvistamiseen.\"\n]\ncontrols_heading = \"Ohjaimet\"\ncontrols_paragraph = \"Paina ja siirrä pelilautaa liikkuaksesi ympäriinsä. Scrollaa zoomataksesi sisään ja ulos. Paina mitä tahansa nappulaa, mukaan lukien vastustajan nappulat, nähdäksesi kaikki niiden mahdolliset siirrot! Ylimääräiset ohjaimet ovat:\"\nkeybinds = [\n\" liikkuaksesi ympäriinsä.\",\n[\"Välilyönti\", \" ja \", \"Vaihto\", \" zoomataksesi sisään ja ulos.\"],\n[\"Escape\", \" pysäyttääksesi pelin.\"],\n[\"Tabulaattori\", \" vaihtaa näytön reunoilla olevien nuolien tilan, jotka osoittavat ruudun ulkopuolella oleviin nappuloihin. Oletuksena tämä tila on \\\"Puolustus\\\", jolloin nuolet osoittavat kaikkiin nappuloihin, jotka voivat siirtyä sinun näytöllesi niiden liikesuunnassa. Mutta \", \"Tabulaattori\", \" voi vaihtaa tilan \\\"Kaikkiin\\\" tai \\\"Pois\\\"; \\\"Kaikki\\\" näyttää nuolet kaikille nappuloille, riippumatta siitä, voivatko ne siirtyä sinun näytöllesi. Tätä asetusta voi myös vaihtaa taukovalikossa. Nuolta klikkaamalla siirryt suoraan siihen nappulaan, johon nuoli osoittaa.\"],\n[\"Control\", \" pakottaa laudan siirtämisen nappulan siirtämisen sijaan, jos vetäminen on käytössä asetuksissa.\"],\n\" vaihtaa \\\"Muokkaustilan\\\" paikallisissa peleissä. Tämän avulla voit siirtää mitä tahansa nappulaa mihin tahansa laudalla! Hyödyllinen analysointiin.\"\n]\ncontrols_paragraph2 = \"Nämä ovat tärkeimmät ohjaimet, jotka sinun tulee tietää. Tässä vielä muutama lisävinkki, jos joskus tarvitset niitä!\"\nkeybinds_extra = [\n\" palauttaa nappuloiden renderöinnin. Tämä on hyödyllistä, jos ne muuttuvat näkymättömiksi. Tämä virhe voi tapahtua, jos liikutat äärimmäisiä etäisyyksiä (esim. 1e21).\",\n\" vaihtaa navigointi- ja pelitietopalkkien renderöinnin, mikä voi olla hyödyllistä tallennuksessa. Striimaaminen ja videoiden tekeminen pelistä on tervetullutta!\",\n\" vaihtaa FPS-mittarin. Tämä näyttää kuinka monta kertaa peli päivittyy sekunnissa, ei aina renderöityjen kuvien määrää, sillä peli ohittaa renderöinnin kun mikään näkyvä ei muutu, säästääkseen laskentatehoa.\",\n\" vaihtaa kuvake-renderöinnin. Nämä ovat klikattavia minikuvia nappuloista, kun zoomaat tarpeeksi kauas. Tuoduissa peleissä, joissa on yli 50 000 nappulaa, tämä kytketään automaattisesti pois päältä suorituskyvyn vuoksi, mutta sen voi kytkeä takaisin päälle \",\n[\" (käänteinen heittomerkki, eli sama näppäin kuin \", \") vaihtaa Debug-tilan.\"],\n]\nfairy_heading = \"Erikoisnappulat\"\nfairy_paragraph = \"Osaat jo pelata oletusvarianttia \\\"Klassinen\\\". Erikoisnappuloita ei käytetä tavallisessa shakissa, mutta ne ovat mukana muissa varianteissa! Jos kohtaat variantin, jossa on uusia nappuloita, opi miten ne toimivat täällä!\"\nback = \"Takaisin\"\n\n[play.guide.pieces]\nchancellor = {name=\"Kansleri\", description=\"Liikkuu kuin torni ja ratsu yhdistettynä.\"}\narchbishop = {name=\"Arkkipiispa\", description=\"Liikkuu kuin lähetti ja ratsu yhdistettynä.\"}\namazon = {name=\"Amazon\", description=\"Liikkuu kuin kuningatar ja ratsu yhdistettynä. Tämä on pelin vahvin nappula!\"}\nguard = {name=\"Vartija\", description=\"Liikkuu kuin kuningas, mutta ei voi joutua shakkiin tai shakkimattiin.\"}\nhawk = {name=\"Haukka\", description=\"Hyppää tarkalleen 2 tai 3 ruutua mihin tahansa suuntaan.\"}\ncentaur = {name=\"Centauri\", description=\"Liikkuu kuin ratsu ja vartija yhdistettynä.\"}\nknightrider = {name=\"Ratsuratsastaja\", description=\"Hyppää kuin ratsu äärettömästi yhteen suuntaan, kunnes este tulee vastaan.\"}\nhuygen = {name=\"Huygen\", description=\"Hyppää äärettömästi johonkin neljästä pääsuunnasta, käyden vain ruuduilla, joiden etäisyys aloitusruudusta on alkuluku, kunnes este tulee vastaan.\"}\nrose = {name=\"Ruusu\", description=\"Ympyrämäinen ratsuratsastaja. Liikkuu myötä- ja vastapäivään ympyränmuotoisia reittejä hyppäämällä kuin ratsu ja kääntymällä 45 astetta jokaisen hypyn jälkeen. Sen voi estää muut nappulat, minkä vuoksi punainen ruutu kuvassa on ruusulle saavuttamaton.\"}\nobstacle = {name=\"Este\", description=\"Neutraali nappula (ei kummankaan pelaajan ohjaama), joka estää liikkumisen, mutta voidaan kaapata.\"}\nvoid = {name=\"Tyhjyys\", description=\"Neutraali nappula (ei kummankaan pelaajan ohjaama), joka edustaa laudan puuttuvaa osaa. Nappulat eivät voi liikkua sen läpi tai sen päälle.\"}\n\n[play.practice-menu]\ntitle = \"Harjoittele - Shakkimatti\"\nplay = \"Pelaa\"\nback = \"Takaisin\"\ndifficulty = \"Vaikeus\"\n\n[play.play-menu]\ntitle = \"Pelaa - Verkossa\"\ncolors = \"Värit\"\nonline = \"Verkossa\"\nlocal = \"Paikallinen\"\ncomputer = \"Tietokone\"\nvariant = \"Variantti\"\nClassical = \"Klassinen\"\nConfined_Classical = \"Rajoitettu klassinen\"\nClassical_Plus = \"Klassinen+\"\nCoaIP = \"Shakkia äärettömällä alustalla\"\nPawndard = \"Pawndard\"\nKnighted_Chess = \"Hepoittetu shakki\"\nPalace = \"Palatsi\"\nKnightline = \"Heppalinja\"\nCore = \"Ydin\"\nStandarch = \"Standarch\"\nPawn_Horde = \"Sotilaslauma\"\nSpace_Classic = \"Klassinen avaruus\"\nSpace = \"Avaruus\"\nObstocean = \"Estemeri\"\nAbundance = \"Runsaus\"\nAmazon_Chandelier = \"Amazon chandelier\"\nContainment = \"Hillitseminen\"\nClassical_Limit_7 = \"Klassinen - 7 rajoitus\"\nCoaIP_Limit_7 = \"Coaip - 7 rajoitus\"\nChess = \"Shakki\"\nClassical_KOTH = \"Kokeellinen: Klassinen - KOTH\"\nCoaIP_KOTH = \"Kokeellinen: Coaip - KOTH\"\nCoaIP_HO = \"Shakkia äärettömällä alustalla - Huygen variantti\"\nCoaIP_RO = \"Shakkia äärettömällä alustalla - Ruusu variantti\"\nCoaIP_NO = \"Shakkia äärettömällä alustalla - Ratsuratsastaja variantti\"\nOmega = \"Näyte: Omega\"\nOmega_Squared = \"Näyte: Omega^2\"\nOmega_Cubed = \"Näyte: Omega^3\"\nOmega_Fourth = \"Näyte: Omega^4\"\n4x4x4x4_Chess = \"4×4×4×4 Shakki\"\n5D_Chess = \"5D Shakki\"\nno_clock = \"Ei kelloa\"\nclock = \"Kello\"\nminutes = \"m\"\nseconds = \"s\"\ninfinite_time = \"Ääretön aika\"\ncolor = \"Väri\"\npiece_colors = [\"Satunnainen\", \"Valkoinen\", \"Musta\"]\nprivate = \"Yksityinen\"\nno = \"Ei\"\nyes = \"Kyllä\"\nrated = \"Arvioitu\"\ncasual = \"Rento\"\neasy = \"Helppo\"\nmedium = \"Keskivaikea\"\nhard = \"Vaikea\"\njoin_games = \"Liity valmiiseen - Aktiiviset pelit:\"\nprivate_invite = \"Yksityinen kutsu:\"\nyour_invite = \"Sinun kutsukoodisi:\"\ncreate_invite = \"Luo kutsu\"\njoin = \"Liity\"\ncopy = \"Kopioi\"\nback = \"Takaisin\"\ncode = \"Koodi\"\n\n[play.gamebuttontooltips]\nundo_transition = \"Kumoa siirtymä\"\nexpand_fit_all = \"Laajenna nähdäksesi kaiken\"\nrecenter = \"Keskitä\"\nannotations = \"Piirrä merkintöjä\"\nerase = \"Poista merkintöjä\"\ncollapse = \"Supista merkinnät\"\nrewind_move = \"Kelaa taaksepäin siirto\"\nforward_move = \"Eteenpäin siirto\"\nundo_edit = \"Kumoa muokkaus (Ctrl+Z)\" # Board editor\nredo_edit = \"Tee muokkaus uudelleen (Ctrl+Y)\" # Board editor\npause = \"Pysäytä\"\nundo = \"Kumoa siirto\" # Checkmate practice game\nrestart = \"Käynnistä peli uudelleen\" # Checkmate practice game\n\n[play.pause]\ntitle = \"Pysäytetty\"\nresume = \"Palaa\"\narrows = \"Nuolet: Puolustus\"\nperspective = \"Perspektiivi: Pois\"\ncopy = \"Kopioi peli\"\npaste = \"Liitä peli\"\noffer_draw = \"Ehdota tasapeliä\"\npractice_menu = \"Harjoitteluvalikko\"\nmain_menu = \"Päävalikkoon\"\n\n[play.drawoffer] # The draw offer UI that appears on the bottom bar\nquestion = \"Hyväksystö tasapelin?\"\n\n[play.javascript] # Not text that's included in the html, but text that scripts use!\nguest_indicator = \"(Vieras)\"\nyou_indicator = \"(Sinä)\"\nengine_indicator = \"Tietokone\"\nplayer_name_white_generic = \"Valkoinen\"\nplayer_name_black_generic = \"Musta\"\nwhite_to_move = \"Valkoisen vuoro\"\nblack_to_move = \"Mustan vuoro\"\nyour_move = \"Sinun siirto\"\ntheir_move = \"Hänen siirto\"\nlost_network = \"Yhteys menetetty.\"\nfailed_to_load = \"Yksi tai enemmän resurssia ei latautunut. Lataa sivu uudelleen.\"\nplanned_feature = \"Tämä on tulossa!\"\nmain_menu = \"Päävalikko\"\nresign_game = \"Luovuta\"\nabort_game = \"Keskeytä peli\"\noffer_draw = \"Ehdota tasapeliä\" # Offer draw button text in the pause menu\naccept_draw = \"Hyväksy tasapeli\" # Offer draw button text in the pause menu\narrows_off = \"Nuolet: Pois\"\narrows_defense = \"Nuolet: Puolustus\"\narrows_all = \"Nuolet: Kaikki\"\narrows_all_hippogonals = \"Nuolet: Kaikki (hippogonaalit)\"\ntoggled = \"Vaihdettu\"\nmenu_online = \"Pelaa - Verkossa\"\nmenu_local = \"Pelaa - Paikallinen\"\nmenu_computer = \"Pelaa - Tietokone\"\ninvite_error_digits = \"Kutsukoodin täytyy olla 5 pitkä.\"\ninvite_copied = \"Kutsukoodi kopioitui.\"\nmove_counter = \"Siirto:\"\nconstructing_mesh = \"Rakennetaan meshiä\"\nrotating_mesh = \"Käännetään meshiä\"\nlost_connection = \"Yhteys menetetty.\"\nplease_wait = \"Odota hetki suorittaaksesi tämän.\"\nwebgl_unsupported = \"Sinun nettiselain ei tue WebGL2:ta. Päivitä nettiselaimesi!\"\nbigints_unsupported = \"BigInts ei ole tuettu. Päivitä nettiselaimesi.\\nBigIntsiä tarvitaan, jotta lauta olisi ääretön.\"\n# Checkmate Practice\nversus = \"vs\"\neasy = \"Helppo\"\nmedium = \"Keskitaso\"\nhard = \"Vaikea\"\ninsane = \"Hullu\"\ncheckmate_logged_out = \"Sinun tulee olla kirjautunut ansaitaksesi merkkejä.\"\ncheckmate_bronze = \"Shakkimatti veteraani: Läpäise 50% kaikista harjoitusshakkimateista.\"\ncheckmate_silver = \"Shakkimatti ammattilainen: Läpäise 75% kaikista harjoitusshakkimateista.\"\ncheckmate_gold = \"Shakkimatti mestari: Läpäise 100% kaikista harjoitusshakkimateista.\"\ncheckmate_bronze_unearned = \"Läpäise 50% kaikista harjoitusshakkimateista ansaitaksesi tämän.\"\ncheckmate_silver_unearned = \"Läpäise 75% kaikista harjoitusshakkimateista ansaitaksesi tämän.\"\ncheckmate_gold_unearned = \"Läpäise 100% kaikista harjoitusshakkimateista ansaitaksesi tämän.\"\ncoords-invalid = \"Virheellinen koordinaattimuoto. Anna vain kokonaislukuja tai e-merkintä (esim. 1.23e4).\"\ncoords-exceeded = \"Et voi siirtä niin kauas! Se olisi liian helppoa ;)\"\n\n[play.javascript.piecenames] # The string representations of each raw piece type, as found in typeutil.strtypes\nvoid = \"Tyhjyys\"\nobstacle = \"Este\"\nking = \"Kuningas\"\ngiraffe = \"Kirahvi\"\ncamel = \"Kameli\"\nzebra = \"Seepra\"\nknightrider = \"Ratsuratsastaja\"\namazon = \"Amazon\"\nqueen = \"Kuningatar\"\nroyalQueen = \"Kuninkaallinen kuningatar\"\nhawk = \"Haukka\"\nchancellor = \"Kansleri\"\narchbishop = \"Arkkipiispa\"\ncentaur = \"Centauri\"\nroyalCentaur = \"Kuninkaallinen centauri\"\nrose = \"Ruusu\"\nknight = \"Ratsu\"\nguard = \"Vartija\"\nhuygen = \"Huygen\"\nrook = \"Torni\"\nbishop = \"Lähetti\"\npawn = \"Sotilas\"\n\n[play.javascript.copypaste]\ncopied_game = \"Peli kopioitu leikepöydälle!\"\ncannot_paste_in_public = \"Peliä ei voi liittää julkisessa pelissä!\"\ncannot_paste_in_rated = \"Peliä ei voi liittää arvioidussa pelissä!\"\ncannot_paste_in_engine = \"Peliä ei voi liittää tietokonepelissä!\"\ncannot_paste_after_moves = \"Peliä ei voi liittää siirron jälkeen!\"\nclipboard_denied = \"Leikepöytä-oikeus evätty. Tämä saattaa olla nettiselaimesi syytä.\"\nclipboard_invalid = \"Leikepöytä ei ole oikeassa ICN merkinnässä.\"\ngame_needs_to_specify = \"Pelin täytyy määritellä joko 'Variant' metadata, tai 'position' ominaisuus.\"\ninvalid_wincon = \"Pelaajalla on virheellinen voittoehto\"\npasting_game = \"Liitetään peliä...\"\npasting_in_private = \"Pelin liittäminen yksityiseen otteluun aiheuttaa synkronoinnin katkeamisen, jos vastustajasi ei tee samoin!\"\npiece_count = \"Nappuloiden määrä\"\nexceeded = \"ylitettiin\"\nchanged_wincon = \"Shakkimattivoiton ehdot muutettiin kuninkaalliseksi vangitsemiseksi ja kuvakkeiden renderöinti otettiin pois päältä. Paina 'P' ottaaksesi sen uudelleen käyttöön (ei suositella).\"\nloaded_from_clipboard = \"Peli ladattu leikepöydältä!\"\ncopied_position = \"Asema kopioitiin leikepöydälle!\"\nloaded_position_from_clipboard = \"Asema ladattiin leikepöydältä!\"\nreset_position = \"Asema asetettiin oletukseen!\"\nclear_position = \"Asema tyhjennettiin!\"\n\n[play.javascript.rendering]\non = \"Päällä\"\noff = \"Pois\"\nicon_rendering_off = \"Vaihdettu pois kuvakerenderöinti.\"\nicon_rendering_on = \"Vaihdettu päälle kuvakerenderöinti.\"\nperspective = \"Perspektiivi\"\nperspective_mode_on_desktop = \"Perspektiivi on saatavilla tietokoneella!\"\nmovement_tutorial = \"WASD liikkuaksesi. Välilyönti ja vaihto zoomataksesi.\"\nregenerated_pieces = \"Uudelleenluotiin nappulat.\"\n\n[play.javascript.invites]\nmove_mouse = \"Liikuta hiirtäsi yhdistääksesi uudelleen.\"\ncannot_cancel = \"Kutsua, jonka tunnus on määrittelemätön, ei voi peruuttaa.\"\nyou_are_white = \"Olet: Valkoinen\"\nyou_are_black = \"Olet: Musta\"\nrandom = \"Satunnainen\"\naccept = \"Hyväksy\"\ncancel = \"Peruuta\"\ncreate_invite = \"Luo kutsu\"\ncancel_invite = \"Peruuta kutsu\"\nstart_game = \"Aloita peli\"\njoin_existing_active_games = \"Liity valmiiseen peliin - Aktiiviset pelit:\"\n\n[play.javascript.onlinegame]\nafk_warning = \"Olet AFK.\"\nopponent_afk = \"Vastustaja on AFK.\"\nopponent_disconnected = \"Vastustaja katkaisi yhteyden.\"\nopponent_lost_connection = \"Vastustaja menetti yhteyden.\"\nauto_resigning_in = \"Automaattisesti luovutetaan\"\nauto_aborting_in = \"Automaattisesti keskeytetään\"\nnot_logged_in = \"Et ole kirjautunut sisään. Kirjaudu sisään yhdistääksesi uudelleen tähän peliin.\"\ngame_no_longer_exists = \"Peli ei ole enään olemassa.\"\nanother_window_connected = \"Toinen ikkuna yhdisti.\"\nserver_restarting = \"Palvelin uudelleenkäynnistyy kohta...\"\nserver_restarting_in = \"Palvelin uudelleenkäynnistyy \"\nminute = \"minuutissa\"\nminutes = \"minuutissa\"\n\n[play.javascript.websocket]\nno_connection = \"Ei yhteyttä.\"\nreconnected = \"Yhdistettiin uudelleen.\"\nunable_to_identify_ip = \"IP-osoitetta ei tunnistettu.\"\nonline_play_disabled = \"Verkossa pelaaminen on pysäytetty. Evästeitä ei tueta. Yritä toisella selaimella.\"\ntoo_many_requests = \"Liian monta pyyntöä. Yritä uudelleen kohta.\"\nmessage_too_big = \"Viesti liian suuri.\"\ntoo_many_sockets = \"Liian monta socketia\"\norigin_error = \"Alkuperä virhe.\"\nconnection_closed = \"Yhteys katkaistiin yllättäen. Palvelimen viesti:\"\nplease_report_bug = \"Tämän ei pitäisi tapahtua, ilmoita tästä bugista!\"\nmalformed_message = \"Vastaanotettu odottamaton websocket-viesti. Ilmoita tästä bugista!\"\n\n[play.javascript.termination] # What caused the termination of the game, in spoken language\ncheckmate = \"Shakkimatti\"\nstalemate = \"Patti\"\nrepetition = \"Kolminkertainen toisto\"\nmoverule = [\"\", \"-siirron sääntö\"]  # The game inserts a number inbetween these two strings\ninsuffmat = \"Ei tarpeeksi materiaalia\"\nroyalcapture = \"Kuninkaallinen kaappaus\"\nallroyalscaptured = \"Kaikki kuninkaaliset kaapattu\"\nallpiecescaptured = \"Kaikki nappulat kaapattu\"\nkoth = \"Kukkulan kuningas\"\nresignation = \"Eroaminen\"\nagreement = \"Yhteisymmärrys\" \ntime = \"Aika loppui\"\naborted = \"Keskeytetty\" # Game was cancelled (no elo exchanged)\ndisconnect = \"Hylätty\" # A player left\n\n[play.javascript.results]\nyou_checkmate = \"Sinä voitit shakkimatilla!\"\nyou_time = \"Sinä voitit ajassa!\"\nyou_resignation = \"Sinä voitit eroamisella!\"\nyou_disconnect = \"Sinä voitit hylkäyksellä!\"\nyou_royalcapture = \"Sinä voitit kaappaamalla kuninkaalisen!\"\nyou_allroyalscaptured = \"Sinä voitit kaappaamalla kaikki kuninkaaliset!\"\nyou_allpiecescaptured = \"Sinä voitit kaappaamalla kaikki nappulat!\"\nyou_koth = \"Sinä voitit olemalla kukkulan kuningas!\"\nyou_generic = \"Sinä voitit!\"\ndraw_stalemate = \"Tasapeli patista!\"\ndraw_repetition = \"Tasapeli kertaamisesta!\"\ndraw_moverule = [\"Tasapeli \", \"-siirron-säännollä!\"] # The game inserts a number inbetween these two strings\ndraw_insuffmat = \"Tasapeli tarvitulla materiaalilla!\"\ndraw_agreement = \"Tasapeli yhteisymmärryksestä!\"\ndraw_generic = \"Tasapeli!\"\naborted = \"Peli keskeytetty.\"\nopponent_checkmate = \"Sinä hävisit shakkimatilla!\"\nopponent_time = \"Sinä hävisit ajassa!\"\nopponent_resignation = \"Sinä hävisit eroamisella!\"\nopponent_disconnect = \"Sinä hävisit hylkäyksellä!\"\nopponent_royalcapture = \"Sinä hävisit kuninkaalisen kaappaamisella!\"\nopponent_allroyalscaptured = \"Sinä hävisit kaikkien kuninkaalisten kaappaamisella!\"\nopponent_allpiecescaptured = \"Sinä hävisit kaikkien nappuloiden kaappaamisella!\"\nopponent_koth = \"Sinä hävisit kukkulan kuninkaalle!\"\nopponent_generic = \"Sinä hävisit!\"\nwhite_checkmate = \"Valkoinen voittaa shakkimatilla!\"\nblack_checkmate = \"Musta voittaa shakkimatilla!\"\nwhite_time = \"Valkoinen voittaa ajassa!\"\nblack_time = \"Musta voittaa ajassa!\"\nwhite_resignation = \"Valkoisen vetäytyminen\"\nblack_resignation = \"Mustan vetäytyminen\"\nwhite_disconnect = \"Valkoisen yhteyden katkeaminen\"\nblack_disconnect = \"Mustan yhteyden katkeaminen\"\nwhite_royalcapture = \"Valkoinen voittaa kaappaamalla kuninkaalisen!\"\nblack_royalcapture = \"Musta voittaa kaappaamalla kuninkaalisen!\"\nwhite_allroyalscaptured = \"Valkoinen voittaa kaappaamalla kaikki kuninkaaliset!\"\nblack_allroyalscaptured = \"Musta voittaa kaappaamalla kaikki kuninkaaliset!\"\nwhite_allpiecescaptured = \"Valkoinen voittaa kaappaamalla kaikki nappulat!\"\nblack_allpiecescaptured = \"Musta voittaa kaappaamalla kaikki nappulat!\"\nwhite_koth = \"Valkoinen voittaa olemalla kukkulan kuningas!\"\nblack_koth = \"Musta voittaa olemalla kukkulan kuningas!\"\nbug_generic = \"Tämä on bugi, ilmoita meille!\"\n\n[play.javascript.editor]\n# Sidebar toggle\nexpand_sidebar = \"Avaa sivuvalikko\"\ncollapse_sidebar = \"Sulje sivuvalikko\"\n# Position header\nnew_position = \"Uusi asema\"\n# Load/Save Position window headers\nload_position_header = \"Avaa asema\"\nsave_position_as_header = \"Tallenna asema nimellä\"\n# Confirmation modal\ndelete_title = \"Poista asema?\"\ndelete_message = [\"Oletko varma, että haluat poistaa tämän aseman \\\"\", \"\\\"? Tätä ei voi perua.\"]\nload_title = \"Avaa asema?\"\nload_message = [\"Oletko varma, että haluat avata tämän aseman \\\"\", \"\\\"? Tallentamattomat muutokset tämänhetkiseen asemaan menetetään.\"]\noverwrite_title = \"Korvaa asema?\"\noverwrite_message = [\"Oletko varma, että haluat korvata tämän aseman \\\"\", \"\\\"? Tätä ei voi perua.\"]\n# Save list row tooltips\ntooltip_load_position = \"Avaa asema\"\ntooltip_save_to_cloud = \"Tallenna pilveen\"\ntooltip_remove_from_cloud = \"Poista pilvestä\"\ntooltip_delete_position = \"Poista asema\"\n# Toast messages\nposition_loaded = \"Asema avattu onnistuneesti.\"\ncannot_start_local_empty = \"Paikallista peliä ei voi aloittaa tyhjästä asemasta!\"\ncannot_start_engine_empty = \"Tietokonepeliä ei voi aloittaa tyhjästä asemasta!\"\nposition_not_supported = \"Asemaa ei tueta syystä:\"\nsaved_in_browser = \"Asema on tallennettu selaimeen.\"\nposition_corrupted = \"Asema on vioittunut.\"\nfailed_to_load = \"Aseman avaaminen epäonnistui:\"\nfailed_to_convert_icn = \"Aseman muuntaminen ICN-muotoon pilvilatausta varten epäonnistui.\"\ntoo_large_for_cloud = \"Asema on liian suuri pilvitallennusta varten.\"\nfailed_to_upload = \"Aseman lataus pilveen epäonnistui:\"\nsaved_to_cloud = \"Asema tallennettu pilveen.\"\nno_changes = \"Muutoksia ei tapahtunut.\"\nfailed_to_load_cloud = \"Aseman avaaminen pilvestä epäonnistui:\"\nfailed_to_delete_cloud = \"Aseman postaminen pilvestä epäonnistui:\"\nfailed_to_remove_cloud = \"Aseman siirto pilvestä epäonnistui:\"\nsaved_locally = \"Asema tallennettu paikallisesti.\"\nfailed_to_fetch_cloud = \"Pilvitallennusten hakeminen epäonnistui:\"\n\n[terms]\ntitle = \"Käyttöehdot\"\nwarning = [\"TÄMÄ DOKUMENTTI EI OLE OIKEUDELLISESTI SITOVA. Olemme vastuussa vain tämän dokumentin englanninkielisestä versiosta. Tämä käännös on tarkoitettu vain yleistä tiedotusta varten. Voit käyttää virallista englanninkielistä versiota \", \"täällä\", \".\"]\nconsent = \"Käyttämällä tätä sivustoa hyväksyt seuraavat ehdot. Jos et hyväksy, sinun tulee lopettaa sivuston käyttö välittömästi.\"\nguardian_consent = \"Jos olet alle 18-vuotias, sinun tulee saada vanhemman tai laillisen huoltajan suostumus käyttääksesi tätä sivustoa ja luodaksesi tilin.\"\nparents_header = \"Vanhemmille\"\nparents_paragraphs = [\n\"Sivustolla on algoritmi, joka estää käyttäjiä asettamasta nimekseen yleisiä kirosanoja. Tällä hetkellä sivustolla ei ole jäsenten välistä viestintämahdollisuutta.\",\n\"Tällä hetkellä jäsenet eivät voi asettaa omaa profiilikuvaa. Tämän ominaisuuden lisäämistä suunnitellaan. Silloin pyrimme estämään sopimattomat profiilikuvat parhaamme mukaan.\",\n]\nfair_play_header = \"Reilu peli\"\nfair_play_paragraph1 = [\"Et voi luoda useampaa kuin yhtä tiliä.\"]\nfair_play_paragraph2 = \"Jotta pelaaminen olisi hauskaa ja reilua kaikille, ET SAA:\"\nfair_play_rules = [\n\"Muokata tai manipuloida koodia millään tavalla, mukaan lukien mutta ei rajoittuen: Konsolikomennot, paikalliset ohitukset, omat skriptit, http-pyyntöjen muokkaaminen jne. Tätä voidaan tehdä tarkoituksella pelin rikkomiseksi tai saadakseen etua.\",\n\"Hyödyntää bugeja tai virheitä pelin keskeyttämiseksi, edun saamiseksi tai pelin tekemiseksi muuten pelikelvottomaksi.\",\n\"Arvostelluissa peleissä saada apua/toisen henkilön tai ohjelman neuvoja siitä, mitä sinun tulisi pelata. (Algoritmin luominen on ok ja kannustettua, mutta sen käyttö tulee rajoittaa arvostelemattomiin, rentoihin peleihin)\",\n\"Vaihtaa elo-pisteitä muiden kanssa häviämällä tarkoituksellisesti, jotta vastustajan elo nousee, tai saamalla elo-pisteitä vastustajalta, joka aikoo hävitä nostaakseen sinun arvoasi. Tämä vääristää järjestelmää ja luo epätarkkoja luokituksia taitotasoon nähden.\"\n]\ncleanliness_header = \"Siisteys\"\ncleanliness_rules = [\n\"Kaikki sivustolla käyttämässäsi kieli tulee pysyä siistinä, ei kiroilua tai rumaa kieltä. Et saa kiusata, häiritä tai uhkailla ketään, etkä tehdä mitään laitonta. Et saa spämmätä muita käyttäjiä tai foorumeita.\",\n\"Et saa ladata profiiliisi kuvia, jotka ovat sopimattomia, vihjailevia tai verisiä. Tällainen toiminta voi johtaa tilin estämiseen tai poistoon.\"\n]\nprivacy_header = \"Yksityisyys\"\nprivacy_rules = [\n\"Tällä hetkellä ainoa keräämämme henkilötieto on sähköpostiosoite. Tämän tarkoituksena on vahvistaa käyttäjien tilit ja tarjota keino todistaa henkilöllisyys salasanan palautuspyynnön yhteydessä. Emme lähetä mainosviestejä tai tarjouksia. Emme jaa käyttäjän sähköpostiosoitetta kenellekään.\",\n\"InfiniteChess.org saattaa kerätä tietoja sivuston käytöstäsi, mukaan lukien IP-osoitteesi. Tämä on tarkoitettu bottien ja muiden ei-toivottujen tahojen estämiseen sekä tilastojen ylläpitoon tietokannassa. Tämä EI ole kotiosoitteesi.\",\n\"Kaikki pelit, joita pelaat tällä sivustolla, ovat julkista tietoa. Jos haluat pysyä anonyyminä, älä jaa käyttäjätunnustasi ystävien tai perheen kanssa. Jos tämä on toiveesi, sinun vastuullasi on varmistaa, ettei kukaan saa tietää käyttäjätunnuksesi liittyvän henkilöllisyyteesi.\",\n\"Tilisi aktiivisuus ja arvioitu viimeisin aktiivisuusaika sivustolla ovat myös julkista tietoa.\",\n[\"Vaikka InfiniteChess.org pyrkii parhaansa mukaan pitämään kaikkien tilit ja henkilötiedot turvassa, mahdollisen hakkeroinnin tai tietovuodon sattuessa et voi nostaa syytteitä meitä vastaan. Jos tietovuoto tapahtuu, käyttäjille ilmoitetaan \", \"Uutiset\", \"sivulla.\"],\n\"Sivustolla ei ole ostettavaa sisältöä. Muita henkilötietoja ei kerätä.\",\n\"Voit poistaa yksityiset tietosi palvelimiltamme poistamalla tilisi profiilisivulta. Ainoa asia, jota emme poista ja joka liittyy käyttäjätunnukseesi, on pelihistoria, koska kaikki pelit ovat julkista tietoa.\",\n]\ncookie_header = \"Evästekäytäntö\"\ncookie_paragraphs = [\n\"Tämä sivusto käyttää evästeitä, jotka ovat pieniä tekstitiedostoja, jotka tallennetaan selaimeesi ja lähetetään palvelimelle pyyntöjen yhteydessä. Evästeiden tarkoitus on: Vahvistaa kirjautumissessiosi, varmistaa selaimesi kuuluvan oikeaan peliin ja tallentaa käyttäjän peliasetukset, jotta ne säilyvät seuraavalla vierailulla. Sivusto ei käytä kolmannen osapuolen evästeitä, eikä evästeitä jaeta ulkopuolisille.\",\n\"Evästeet ovat välttämättömiä sivuston ja pelin toiminnalle. Jos et halua sivuston tallentavan evästeitä, sinun tulee lopettaa sivuston käyttö. Voit poistaa olemassa olevat evästeet selaimesi asetuksista. Jatkamalla sivuston käyttöä hyväksyt evästeiden käytön.\"\n]\nconclusion_header = \"Yhteenveto\"\nconclusion_paragraphs = [\n\"Kaikkien näiden ehtojen rikkominen voi johtaa tilin estämiseen tai poistoon. InfiniteChess.org haluaa tarjota kaikille mahdollisuuden pelata ja pitää hauskaa! Varaamme kuitenkin oikeuden milloin tahansa estää tai poistaa käyttäjien tilejä syistä, joita ei tarvitse ilmoittaa. Meitä vastaan ei voi nostaa syytteitä.\",\n[\"Näitä käyttöehtoja voidaan muuttaa milloin tahansa. On SINUN vastuullasi pysyä ajan tasalla viimeisimmistä muutoksista! Kun käyttöehdot päivittyvät, siitä ilmoitetaan \", \"Uutiset\", \"sivulla. Jos et hyväksy uusia ehtoja päivityksen yhteydessä, sinun tulee lopettaa sivuston käyttö välittömästi. Voit poistaa tilisi profiilisivulta. Jos poistat tilisi, kaikki yksityiset tietosi ja tilitietosi poistetaan, PAITSI pelihistoriaa ei poisteta, koska se on julkista tietoa.\"],\n[\"Tällä sivustolla on avoin lähdekoodi. Voit kopioida tai jakaa mitä tahansa tältä sivustolta, kunhan noudatat \", \"lisenssiehtoja\", \"! Jos tämä linkki on rikki, on sinun vastuullasi etsiä ehdot.\"],\n\"Emme voi taata, että sivusto toimii 100% ajasta. Emme myöskään voi taata, ettei data koskaan korruptoidu.\",\n\"Et saa tehdä mitään laitonta sivustolla.\",\n[\"Jos sinulla on kysyttävää näistä ehdoista tai muusta sivustoon liittyvästä, \", \"lähetä meille sähköpostia!\"]\n]\nthanks = \"Kiitos!\"\n\n[login]\ntitle = \"Kirjaudu sisään\" # The tab name\nusername = \"Käyttäjänimi:\"\npassword = \"Salasana:\"\nlogin_button = \"Kirjaudu sisään\"\nsend_reset_link = \"Lähetä palautuslinkki\"\nforgot_question = \"Unohditko salasanasi?\"\nback_to_login = \"Takaisin kirjautumaan\"\nforgot_instruction = \"Unohditko salasanasi? Syötä käyttäjätunnuksesi ja lähetämme sinulle palautuslinkin.\"\n\n[login.javascript]\nnetwork-error = \"Verkkovirhe tapahtui. Kokeile uudestaan.\"\n\n[reset_password]\ntitle = \"Palauta salasanasi\"\ninstruction = \"Syötä ja vahvista uusi salasanasi.\"\nnew_password = \"Uusi salasana\"\nconfirm_password = \"Vahvista salasana\"\nsubmit_button = \"Palauta salasana\"\n\n[error-pages] # Messages shown on some error pages explaining what went wrong\n400_message = \"Väärä pyyntö oli vastaanotettu.\"\n409_message = [\"On ehkä olemassa samanniminen käyttäjä tai sähköposti. \", \"Lataa uudelleen\", \", tämä sivu.\"]\n500_message = \"Tämän ei ole tarkoitus tapahtua. Jotain täytyy korjata!\"\n\n[news]\ntitle = \"Uutiset\" # The tab name\nmore_dev_logs = [\"Enemmän kehittäjälogeja on \", \"discord-kanavalla\", \", ja \", \"chess.com foorumeilla!\"]\n\n[server.javascript]\nws-invalid_username = \"Käyttäjänimi on väärä\"\nws-incorrect_password = \"Salasana on väärä\"\nws-login_failure_retry_in = \"Sisäänkirjautuminen epäonnistui, yritä uudelleen\"\nws-seconds = \"sekunnissa\" # unit of time\nws-second = \"sekunnissa\" # unit of time\nws-username_letters = \"Käyttäjänimessä saa sisältää vain kirjaimia A-Z ja numeroita 0-9\"\nws-username_taken = \"Käyttäjänimi on otettu\"\nws-username_bad_word = \"Käyttäjänimi sisältää sanan, joka on kielletty\"\nws-email_too_long = \"Sinun sähköpostiosoitteesi on liian piiitkä.\"\nws-email_invalid = \"Tämä ei ole oikea sähköpostiosoite\"\nws-email_in_use = \"Sähköposti on jo käytössä\"\nws-email_domain_invalid = \"Vääränlainen nettiosoite.\"\nws-email_blacklisted = \"Sinun sähköpostiosoitteesi on estolistalla.\"\nws-password_length = \"Salasanan tulee olla 6-72 kirjainta pitkä\"\nws-password_password = \"Salasana ei saa olla 'password'\"\nws-password-reset-link-sent = \"Jos kyseisellä sähköpostiosoitteella on jo tili, salasanan palautuslinkki on lähetetty.\"\nws-password-change-success = \"Salasanan palauttaminen onnistui. Sinut ohjataan pian kirjautumissivulle.\"\nws-password-reset-token-invalid = \"Salasanan palautustunnus on virheellinen tai vanhentunut.\"\nws-forbidden_wrong_account = \"Kielletty. Tämä ei ole sinun tilisi.\"\nws-deleting_account_not_found = \"Tilin poisto epäonnistui. Tiliä ei löydetty.\"\nws-deleting_account_in_game = \"Et voi poistaa tiliäsi, kun olet vielä pelissä.\"\nws-server_error = \"Anteeksi, palvelinvirhe tapahtui! Mene takaisin.\"\nws-not_found = \"404 Ei Löydetty\"\nws-forbidden = \"Kielletty.\"\nws-already_in_game = \"Olet jo pelissä.\"\nws-server_restarting = \"Palvelin uudelleenkäynnistyy\" # The server inserts a number immediately after this, followed by the correct plurality of minutes.\nws-server_under_maintenance = \"Palvelin on huollossa. Tule pian takaisin!\" # Can be changed at will to change the display message.\nws-minutes = \"minuutissa\" # unit of time\nws-minute = \"minuutissa\" # unit of time\nws-you_cheated = \"Hups! Sinä pelasit jotain sääntöjen vastaista. Tämä peli on keskeytetty. Jos tämä on vahinko, ilmoita meille!\"\nws-opponent_cheated = \"Vastustajasi pelasi jotain sääntöjen vastaista. Tämä peli on keskeytetty.\"\nws-cannot_resign_finished_game = \"Pelistä ei voi erota, se on jo ohi.\"\nws-invalid_code = \"Vääränlainen koodi!\" # Invite code doesn't match any existing invites\nws-game_aborted = \"Peli keskeytettiin.\" # Invite was cancelled as you clicked on it\nws-rated_invite_verification_needed = \"Pelataksesi arvosteltuja pelejä sinun on kirjauduttava sisään vahvistetulla tilillä.\"\n\n[rate-limiting]\ngeneric = \"Teit liian monta pyyntöä, kokeile myöhemmin uudelleen.\"\nerror = \"Liian monta pyyntöä\""
  },
  {
    "path": "translation/fr-FR.toml",
    "content": "name = \"Français\" # Name of language\nenglish_name = \"French\"\ndirection = \"ltr\"\nversion = \"17\"\nmaintainer = \"Life Enjoyer,cycy98,Heinrich Xiao\"\n\n[header]\nhome = \"Accueil\"\nplay = \"Jouer\"\nnews = \"Actualités\"\nlogin = \"Connexion\"\ncreateaccount = \"Créer un compte\"\n\n[footer]\ncontact = \"Contactez nous\"\nterms_of_service = \"Conditions d'utilisation\"\nsource_code = \"Code Source\"\nlanguage = \"Langue\"\n\n[header.javascript]\njs-profile = \"Profil\"\njs-logout = \"Déconnexion\"\njs-login = \"Connexion\"\njs-createaccount = \"Créer un compte\"\n\n[member.javascript]\njs-confirm_delete = \"Êtes vous sûr de vouloir supprimer votre compte ? Cette action est DÉFINITIVE ! Cliquez sur 'OK' pour rentrer votre mot de passe.\"\njs-enter_password = \"Entrez votre mot de passe pour supprimer votre compte DÉFINITIVEMENT:\"\n\n[index]\ntitle = \"Infinite Chess | Accueil - Le Site Officiel\" # The tab title\nsecondary_title = \"Le site officiel pour jouer en direct !\"\nwhat_is_it_title = \"Qu'est ce que c'est ?\"\nwhat_is_it_pargaraphs = [\n\"Infinite Chess est une variante des échecs dans laquelle il n'y pas de bordures, on est bien loin du plateau de 8x8 cases classique. La dame, les tours et les fous n'ont <em>pas de limites</em> à leur distance de déplacement. Vous pouvez vous déplacez sur n'importe quel case, de 0 à l'infini !\",\n\"Sans limite à la distance que vous pouvez parcourir, il y a des positions dans lesquelles l'horloge de la mort, ou le nombre de coups avant le mat, est représenté par le premier ordinal infini: <strong>omega ω</strong>. En fait, les recherches ont prouvé que le nombre de coups avant un mat peut être représenté par <strong>tout</strong> ordinal dénombrable !\",\n\"Comme vous pouvez l'imaginer, il y a une infinité de positions de départ possibles dont beaucoup que vous pouvez jouer de façon compétitive ! Le but final reste de faire un échec et mat, il est donc nécessaire de développer des nouvelles techniques vu qu'il n'y pas de murs pour coincer le roi ennemi. Normalement, les parties ne durent pas plus longtemps que dans un jeu d'échec classique. Les pions font leur promotions sur les lignes 1 et 8 respectivement !\",\n]\nhow_to_title = \"Comment Jouer ?\"\nhow_to_paragraph = [\"Le jeu est actuellement en version 1.10 sur la page \",\"Jouer\",\"!\"]\nabout_title = \"À Propos\"\nabout_paragraphs = [\n\"Je m'apelle Naviary. Dès que j'ai découvert Infinite Chess (le concept existait bien avant ce site), J'ai été très intrigué par ce jeu et par les possibilités qu'il débloque ! Jusqu'à aujourd'hui, y jouer était plutôt difficile, les membres de chess.com devaient créer des images du plateau eux même et se les envoyer à chaque coup. À cause de ça, peu de gens connaissent cette variante.\",\n[\"Mon objectif est de créer un site qui permettrait à tout le monde d'y jouer facilement, et de développer une communauté autour du jeu. J'ai passé un nombre incalculable d'heures de mon temps libre à faire ce site, à le maintenir et à developper le jeu et j'ai encore beaucoup d'idées qui vont m'occuper pendant pas mal de temps. Bien que je souhaite garder ce site gratuit, la vie a un coût et pour aider à me supporter financiérement vous pouvez rejoindre mon \", \"Patreon\", \".\"] # Patreon receives a hyperlink, here\n]\npatreon_title = \"Contributeurs Patreons\"\n\n[credits]\ntitle = \"Credits\"\ncopyright = \"Tout le contenu du site qui n'est pas listé ci dessous est la propriété de www.InfiniteChess.org\"\nvariants_heading = \"Variantes\"\nvariants_credits = [\n\"Core crée par Andreas Tsevas.\",\n\"Space crée crée par Andreas Tsevas.\",\n\"Space Classic crée par Andreas Tsevas.\",\n\"Coaip (Chess on an Infinite Plane) crée par V. Reinhart.\",\n\"Pawn Horde crée par Inaccessible Cardinal.\",\n\"Abundance crée par Clicktuck Suskriberz.\",\n\"Pawndard crée par SexyLexi.\",\n\"Classical+ crée par SexyLexi.\",\n\"Knightline crée par Inaccessible Cardinal.\",\n\"Knighted Chess crée par cycy98.\",\n\"crée par Cory Evans et Joel Hamkins.\",\n\"crée par Andreas Tsevas.\",\n\"crée par Cory Evans et Joel Hamkins.\",\n\"crée par Cory Evans, Joel Hamkins, et Norman Lewis Perlmutter.\",\n]\ntextures_heading = \"Textures\"\ntextures_licensed_under = \"textures sous la licence\"\ntextures_credits = [\n\"Gold coin par Quolte.\",\n]\nsounds_heading = \"Sons\"\nsounds_credits = [\n[\"Certains sons viennent du\", \"project under the\"],\n\"D'autres ont été crées par Naviary.\",\n]\ncode_heading = \"Code\"\ncode_credits = [\n\"par Brandon Jones et Colin MacKenzie IV.\",\n\"par Andreas Tsevas et Naviary.\",\n]\nlanguage_heading = \"Traductions de langue\"\nlanguage_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded.\n\t\"Français par \", \"Life Enjoyer\", \" et \", \"cycy98\", \".\",\n\t\"Chinois simplifié par \", \"Heinrich Xiao\", \".\",\n\t\"Chinois traditionnel par \", \"Heinrich Xiao\", \".\",\n\t\"Polonais par \", \"Tymon Becella\", \".\", # Apsurt\n\t\"Portugais par \", \"Emerson P. Machado\", \".\" # The_Skeleton on discord\n]\n\n[member]\ntitle = \"Membre\" # The tab name\nverify_message = \"Validez l'email pour vérifier votre compte.\"\nresend_message = [\"Vous n'avez pas reçu d'email ? Vérifiez vos spams. Aussi, \", \"renvoyer.\", \" Si vous ne recevez toujours rien,\", \"envoyez moi un message.\"]\nverify_confirm = \"Merci ! Votre compte est désormais vérifié.\"\nrating = \"Classement elo:\"\njoined = \"A rejoint le:\"\nseen = [\"Vu il y a:\", \"\"]\nreveal_info = \"Voir les informations du compte\"\naccount_info_heading = \"Informations\"\nemail = \"Email:\"\ndelete_account = \"Supprimer le compte\"\npassword_reset_message = [\"Pour changer votre nom d'utilisateur, votre mot de passe ou votre email vous pouvez, \", \"nous contacter.\"]\n\n[create-account]\ntitle = \"Créer un compte\"\nusername = \"Nom d'utilisateur:\"\nemail = \"Email:\"\npassword = \"Mot de Passe:\"\ncreate_button = \"Créer un compte\"\nagreement = [\"J'accepte les \", \"Conditions d'utilisation\", \".\"] # the middle entry is a hyperlink, the others are not\n\n[create-account.javascript]\njs-username_specs = \"Le nom d'utilisateur doit être d'au moins 3 caractères et ne doit contenir que des lettres ou des chiffres\"\njs-username_tooshort = \"Le nom d'utilisateur doit faire au moins caractères\"\njs-username_wrongenc = \"Le nom d'utilisateur ne peut contenir que des lettres ou des chiffres\"\njs-email_invalid = \"Cette adresse mail est invalide\"\njs-email_inuse = \"Cette adresse mail est déjà utilisée\"\njs-pwd_incorrect_format = \"Le mot de passe est dans un format incorrect\"\njs-pwd_too_short = \"Le mot de passe doit contenir au moins 6 caractères\"\njs-pwd_too_long = \"Le mot de passe doit contenir moins de 72 caractères\"\njs-pwd_not_pwd = \"Le mot de passe ne doit pas être 'password'\"\n\n[play]\ntitle = \"Infinite Chess - Jouer\"\nloading = \"CHARGEMENT\"\nerror = \"ERREUR\"\n\n[play.main-menu]\ncredits = \"Credits\"\nplay = \"Jouer\"\nguide = \"Guide\"\neditor = \"Editeur de plateau\"\nerror = \"ERREUR\"\n\n[play.guide]\ntitle = \"Guide\"\nrules = \"Règles\"\nrules_paragraphs = [\n\"Les règles de Infinite Chess sont presques identiques à celles des échecs classiques, la seule différence étant que le plateau est infini dans toutes les directions ! Les seuls changements que vous devez prendre en compte sont listés ci-dessous:\",\n\"Les pièces with avec des déplacements coulissants, comme les tours, les fous et les dames, n'ont pas de limite à la distance qu'elles peuvent parcourir en un tour. Tant que leur chemin n'est pas obstrué, elles peuvent traverser des millions de cases!\",\n[\"Dans la variante par défaut \\\"Classique\\\", les pions blancs font leur promotion ligne 8, et les pions noirs ligne 1. Sur cette image, ces lignes sont indiquées par les fines lignes noires. Elles sont discrètes, regardez si vous pouvez les voir ! Les pions ont seulement besoin d'atteindre la ligne opposée à leur position de départ pour faire promotion, \", \"pas\", \" de la dépasser.\"],\n\"Les cases ne sont plus décrites par leur lettre et leur numéro de ligne (ex: a1) mais plutôt comme une paire de coordonnées x et y. La case a1 devient (1,1), et la case h8 devient (8,8). Sur ordinateur, les coordonées de la case sur laquelle est votre souris sont affichées en haut au milieu de l'écran.\",\n\"Toutes les autres règles sont les mêmes que dans le jeu d'échec classique, comme les échecs-et-mat, les pats, les répétitions de positions, la règle des 50 coups, rocs, prise au passant, etc..\"\n]\ncareful_heading = \"Faites Attention !\"\ncareful_paragraphs = [\n\"L'ouverture donnée par un plateau infini fait qu'il est très facile d'exploiter les fourchettes, les clouages et les enfilades. La partie du plateau derrière votre Roi est souvent très vulnérable. Faites attention aux tactiques qui l'exploite ! Soyez créatif sur la protection que vous formez autour de votre roi et de vos tours! Les ouvertures sont très différentes de celles des échecs classiques.\",\n\"Beaucoup d'autres variantes ont été crées avec pour but de renforcer l'arrière du roi.\"\n]\ncontrols_heading = \"Contrôles\"\ncontrols_paragraph = \"Vous pouvez cliquer et faire glisser le plateau pour vous y déplacer, ou scroller pour zoomer, vous pouvez également cliquer sur n'importe quelle pièce (y compris celles de votre adversaire) pour voir leurs déplacements légaux. Il y a d'autres contrôles un peu plus avancés, les voici: \"\nkeybinds = [\n\" pour se déplacer.\",\n[\"Espace\", \" et \", \"Maj\", \" pour zoomer et dézoomer.\"],\n[\"Échap\", \" pour mettre pause.\"],\n[\"Tab\", \" pour activer et désactiver les flèches pointant vers les pièces hors champ sur les bords de l'écran. Par défaut elles sont en mode \\\"Défense\\\", une flèche n'est alors affichée que pour les pièces qui peuvent bouger de leur emplacement à une case visible sur l'écran. Mais, appuyer sur \", \"tab\", \" permet de désactivé totalement ces flèches ou de les passer en mode \\\"Toutes\\\". Le mode \\\"Toutes\\\" révèle toutes les pièces qui passent par des lignes ou des diagonales visibles à l'écran, selon si ces pièces se déplacent en ligne ou en diagonale. Le mode choisit peut également être changé depuis le menu pause. Cliquer sur les flèches vous téléportera à la pièce vers laquelle elles pointent.\"],\n\" va activer/désativer le \\\"mode d'édition\\\" dans les parties locales. Le mode d'édition vous permet déplacer n'importe quelle pièce n'importe où sur le plateau. Très utile pour faire des analyses.\"\n]\ncontrols_paragraph2 = \"Ce sont les principaux contrôles que vous devez connaître. Mais il y en d'autres qui pourraient vous être utiles !\"\nkeybinds_extra = [\n\" rénitialise l'affichage des pièces. C'est utile si elles deviennent invisibles. Ce qui peut arriver quand elles sont à des distances très éloignées (à partir de 10²¹ notamment).\",\n\" active/désactive l'affichage des barres de navigation et d'informations, ce qui peut être utile pour record. Les streams et les vidéos sur le jeu sont les bienvenus!\",\n\" active/désactive le compteur de FPS. Qui affiche le nombre de mises à jour de la partie par seconde (ce qui ne correspond pas toujours au nombre de frames affichées, étant donné que l'écran n'est rafraichi que quand quelque chose de visible change, pour optimiser les performances).\",\n\" active/désactive l'affichage des icônes. Les icônes sont des petites images cliquables des pièces quand vous dézoomez suffisament. Dans les parties importées avec plus de 50 000 pièces, ce paramètre est automatiquement désactivé, étant donné qu'il prend beaucoup de performances, il peut cependant être réactivé.\",\n[\" (backtick, ou la même touche que \", \") active/désactiver le debug mode.\"],\n]\nfairy_heading = \"Pièces fées\"\nfairy_paragraph = \"Vous savez déjà ce que vous devez savoir pour jouer à la variante par défaut \\\"Classique\\\". Les pièces fées ne sont pas utilisés dans les échecs conventionels, mais elles sont incorporées dans d'autres variantes de ce site ! Si vous vous trouvez dans une variante avec des pièces que vous n'avez jamais vu avant vous pouvez apprendre comment elles se déplacent ici !\"\nediting_heading = \"Editeur de Plateau\"\nediting_paragraphs = [\n[\"Il y a un \", \"éditeur de plateau\", \" externe, pour l'instant disponible dans un google sheet publique ! Il contient toutes les instructions sur comment l'utiliser (en anglais) et demande un peu de connaissances du fonctionnement des tableurs. Après ça, vous serez capable de créer et d'importer des positions personnalisées dans le jeu via le bouton \\\"Coller la Position\\\" dans le menu des options !\"],\n\"Pour jouer sur une position personnalisée avec un ami vous devez le rejoindre via une invitation privée, puis vous devez tous les deux copier et coller le code de la partie avant de commencer à jouer !\",\n\"Un editeur de plateau en jeu est prévu.\",\n]\nback = \"Retour\"\n\n[play.guide.pieces]\nchancellor = {name=\"Le Chancelier\", description=\"Il se déplace comme une tour et un cavalier combinés.\"}\narchbishop = {name=\"L'Archifou\", description=\"Il se déplace comme un fou et un cavalier combinés.\"}\namazon = {name=\"L'Amazone\", description=\"Elle se déplace comme une dame et un cavalier combinés. C'est la pièce fée la plus forte du jeu !\"}\nguard = {name=\"Le Garde\", description=\"Il se déplace comme le roi mais ne peut pas être mis en échec ou en échec et mat.\"}\nhawk = {name=\"Le Faucon\", description=\"Il se déplace d'exactement 2 ou 3 cases dans chaque direction. Il peut sauter au dessus d'autres pièces comme un cavalier.\"}\ncentaur = {name=\"Le Centaure\", description=\"Il se déplace comme un cavalier et un garde combinés.\"}\nknightrider = {name=\"Le Cavalier Sauteur\", description=\"Saute comme un cavalier mais jusqu'à l'infini, tant qu'il ne rencontre pas d'obstacles.\"}\nobstacle = {name=\"Obstacle\", description=\"Une pièce neutre (controlées par aucun des joueurs) qui bloque les mouvements mais peut être capturée.\"}\nvoid = {name=\"Vide\", description=\"Une pièce neutre (controlées par aucun des joueurs) qui représente l'absence d'une case sur le plateau. Les pièces ne peuvent se déplacer ni au dessus ni à travers du vide.\"}\n\n[play.play-menu]\ntitle = \"Jouer - En Ligne\"\ncolors = \"Couleurs\"\nonline = \"En Ligne\"\nlocal = \"Local\"\ncomputer = \"Ordinateur\"\nvariant = \"Variante\"\nClassical = \"Classique\"\nClassical_Plus = \"Classique+\"\nCoaIP = \"Chess on an Infinite Plane\"\nPawndard = \"Pawndard\"\nKnighted_Chess = \"Knighted Chess\"\nKnightline = \"Knightline\"\nCore = \"Core\"\nStandarch = \"Standarch\"\nPawn_Horde = \"Pawn Horde\"\nSpace_Classic = \"Space Classic\"\nSpace = \"Space\"\nObstocean = \"Obstocean\"\nAbundance = \"Abundance\"\nAmazon_Chandelier = \"Amazon Chandelier\"\nContainment = \"Containment\"\nClassical_Limit_7 = \"Classical - Limit 7\"\nCoaIP_Limit_7 = \"Coaip - Limit 7\"\nChess = \"Chess\"\nClassical_KOTH = \"Experimental: Classical - KOTH\"\nCoaIP_KOTH = \"Experimental: Coaip - KOTH\"\nOmega = \"Showcase: Omega\"\nOmega_Squared = \"Showcase: Omega^2\"\nOmega_Cubed = \"Showcase: Omega^3\"\nOmega_Fourth = \"Showcase: Omega^4\"\nno_clock = \"Pas d'horloge\"\nclock = \"Horloge\"\nminutes = \"m\"\nseconds = \"s\"\ninfinite_time = \"Temps Infini\"\ncolor = \"Couleur\"\npiece_colors = [\"Aléatoire\", \"Blanc\", \"Noir\"]\nprivate = \"Privé\"\nno = \"Non\"\nyes = \"Oui\"\nrated = \"Classé\"\ncasual = \"Non classé\"\njoin_games = \"Rejoindre des Parties:\"\nprivate_invite = \"Invitation Privée:\"\nyour_invite = \"Votre code d'invitation:\"\ncreate_invite = \"Créer une invitation\"\njoin = \"Rejoindre\"\ncopy = \"Copier\"\nback = \"Retour\"\ncode = \"Code\"\n\n[play.footer]\nwhite_to_move = \"Tour des Blancs\"\nplayer_white = \"Joueur Blanc\"\nplayer_black = \"Joueur Noir\"\n\n[play.pause]\ntitle = \"En Pause\"\nresume = \"Reprendre\"\narrows = \"Flèches: Defense\"\nperspective = \"Perspective: Off\"\ncopy = \"Copier la position\"\npaste = \"Coller une position\"\noffer_draw = \"Proposer une nulle\"\nmain_menu = \"Menu Principal\"\n\n[play.drawoffer] # The draw offer UI that appears on the bottom bar\nquestion = \"Accepter la nulle ?\"\n\n[play.javascript]\nguest_indicator = \"(Guest)\"\nyou_indicator = \"(Vous)\"\nwhite_to_move = \"Au tour des blancs\"\nblack_to_move = \"Au tour des noirs\"\nyour_move = \"Votre coup\"\ntheir_move = \"Son coup\"\nlost_network = \"Connexion perdue.\"\nfailed_to_load = \"Une ressource ou plus n'a pas plus charger. Rafraîchissez la page s'il vous plaît.\"\nplanned_feature = \"Cette fonctionnalité est prévue !\"\nmain_menu = \"Menu Principal\"\nresign_game = \"Abandonner la Partie\"\nabort_game = \"Abandonner la Partie\"\narrows_off = \"Flèches: Off\"\narrows_defense = \"Flèches: Défense\"\narrows_all = \"Flèches: Toutes\"\ntoggled = \"Activé\"\nmenu_online = \"Jouer - En Ligne\"\nmenu_local = \"Jouer - Local\"\ninvite_error_digits = \"Les codes d'invitation doivent être composés de 5 chiffres.\"\ninvite_copied = \"Le code d'invitation a été copié dans le presse papier.\"\nmove_counter = \"Tours:\"\nconstructing_mesh = \"Construction du mesh\"\nrotating_mesh = \"Rotation du mesh\"\nlost_connection = \"Connexion perdue.\"\nplease_wait = \"Attendez un moment s'il vous plaît.\"\nwebgl_unsupported = \"Votre navigateur ne supporte pas WebGL. Ce jeu ne peut pas fonctionner sans. Mettez à jour votre navigateur s'il vous plaît.\"\nbigints_unsupported = \"Votre navigateur ne supporte pas les BigInts. Mettez à jour votre navigateur.\\nLes BigInts sont nécessaire pour rendre le plateau infini.\"\nshaders_failed = \"Les shaders n'ont pas pu être initialisés:\"\nfailed_compiling_shaders = \"Une erreur s'est produite lors de la compilation des shaders:\"\noffer_draw = \"Proposer une nulle\"\naccept_draw = \"Accepter la nulle\"\n\n[play.javascript.copypaste]\ncopied_game = \"La position a été copiée dans le presse-papier !\"\ncannot_paste_in_public = \"Vous ne pouvez pas coller une position dans une partie publique !\"\ncannot_paste_after_moves = \"Vous ne pouvez pas coller une position après que des coups aient été joués !\"\nclipboard_denied = \"Erreur de permission avec le presse-papier. Ceci pourrait être du à votre navigateur.\"\nclipboard_invalid = \"Le presse-papier ne contient pas de notation ICN valide.\"\ngame_needs_to_specify = \"La partie doit spécifier la metadata 'Variant' ou la propriété 'position'.\"\ninvalid_wincon_white = \"Les Blancs ont une condition de victoire invalide\"\ninvalid_wincon_black = \"Les Noirs ont une condition de victoire invalide\"\npasting_game = \"Collage de la position...\"\npasting_in_private = \"Coller une position dans une partie privée causera une désynchronisation si votre adversaire ne fait pas de même !\"\npiece_count = \"Nombre de pièces\"\nexceeded = \"dépassé\"\nchanged_wincon = \"Les condition de victoire par mat ont étés passé à 'capture royale', et l'affichage des icones a été désactivé. Appuyez sur 'P' pour les réactiver (déconseillé).\"\nloaded_from_clipboard = \"La position a été chargée depuis le presse-papier !\"\nloaded = \"Position chargée !\"\nslidelimit_not_number = \"La gamerule 'slideLimit' doit être un nombre.\"\n\n[play.javascript.rendering]\non = \"On\"\noff = \"Off\"\nicon_rendering_off = \"L'affichage des icones a été désactivé.\"\nicon_rendering_on = \"L'affichage des icones a été activé.\"\ntoggled_edit = \"Le mode d'édition a été activé:\"\nperspective = \"Perspective\"\nperspective_mode_on_desktop = \"Le mode perspective n'est pas disponible sur téléphone !\"\nmovement_tutorial = \"WASD pour se déplacer. Espace et maj pour zoomer.\"\nregenerated_pieces = \"Les pièces ont été régénérés.\"\n\n[play.javascript.invites]\nmove_mouse = \"Déplacez votre souris pour vous reconnecter.\"\nunknown_action_received_1 = \"Action inconnue\"\nunknown_action_received_2 = \"reçu par le serveur dans l'abonnement d'invitations !\"\ncannot_cancel = \"Impossible d'annuler l'invitation ou identifiant non défini.\"\nyou_indicator = \"(Vous)\"\nyou_are_white = \"Vous êtes: Les blancs\"\nyou_are_black = \"Vous êtes: Les noirs\"\nrandom = \"Aléatoire\"\naccept = \"Accepter\"\ncancel = \"Annuler\"\ncreate_invite = \"Créer une invitation\"\ncancel_invite = \"Annuler l'invitation\"\nstart_game = \"Commencer\"\njoin_existing_active_games = \"Rejoindre des parties existantes - actives:\"\n\n[play.javascript.onlinegame]\nafk_warning = \"Vous êtes AFK.\"\nopponent_afk = \"Votre adversaire est AFK.\"\nopponent_disconnected = \"Votre adversaire s'est déconnecté.\"\nopponent_lost_connection = \"Votre adversaire est hors ligne.\"\nauto_resigning_in = \"Abandon automatique dans\"\nauto_aborting_in = \"Abandon automatique dans\"\nnot_logged_in = \"Vous n'êtes plus connecté. Reconnectez vous pour pouvoir retourner dans cette partie s'il vous plaît.\"\ngame_no_longer_exists = \"Cette partie n'existe plus.\"\nanother_window_connected = \"Une autre fenêtre a établi une connexion.\"\nserver_restarting = \"Le serveur redémarre bientôt...\"\nserver_restarting_in = \"Le serveur redémarrera dans\"\nminute = \"minute\"\nminutes = \"minutes\"\n\n[play.javascript.websocket]\nno_connection = \"Pas de connexion.\"\nreconnected = \"Reconnecté.\"\nunable_to_identify_ip = \"L'IP n'a pas pu être identifiée.\"\nonline_play_disabled = \"Le jeu en ligne est désactivé. Les cookies ne sont pas supportés. Essayez avec un autre navigateur.\"\ntoo_many_requests = \"Trop de requêtes. Réessayez dans quelques minutes.\"\nmessage_too_big = \"Message trop gros.\"\ntoo_many_sockets = \"Too many sockets\"\norigin_error = \"Origin error.\"\nconnection_closed = \"La connection a été fermé de façon imprévue. Message du serveur:\"\nplease_report_bug = \"Ceci ne devrait jamais arriver, reportez ce bug s'il vous plaît !\"\n\n[play.javascript.termination] # What caused the termination of the game, in spoken language\ncheckmate = \"Échec et Mat\"\nstalemate = \"Pat\"\nrepetition = \"Répétition de coups\" # Needs translation\nmoverule = [\"Regle des \", \" coups\"] # The game inserts a number inbetween these two strings\ninsuffmat = \"Matériel insuffisant\"\nroyalcapture = \"Capture royale\" # Needs translation\nallroyalscaptured = \"Toutes les pièces royales ont été capturées\"\nallpiecescaptured = \"Toutes les pièces ont été capturées\"\nthreecheck = \"Triple échec\"\nkoth = \"KOTH\"\nresignation = \"Abandon\"\nagreement = \"Accord\"\ntime = \"Défaite au temps\"\naborted = \"Annulation\" # Game was cancelled (no elo exchanged)\ndisconnect = \"Déconnexion\" # A player left\n\n[play.javascript.results]\nyou_checkmate = \"Victoire par échec-et-mat !\"\nyou_time = \"Victoire par le temps!\"\nyou_resignation = \"Victoire par abandon !\"\nyou_disconnect = \"Victoire par déconnexion !\"\nyou_royalcapture = \"Victoire par capture du Roi !\"\nyou_allroyalscaptured = \"Victoire par capture de tous les Rois!\"\nyou_allpiecescaptured = \"Victoire par capture de toutes les pièces!\"\nyou_threecheck = \"Victoire par triple échec !\"\nyou_koth = \"Victoire par KOTH !\"\nyou_generic = \"Victoire !\"\ndraw_stalemate = \"Pat !\"\ndraw_repetition = \"Égalité par répétition !\"\ndraw_moverule = [\"Égalité par la règle des \", \" coups\"] # The game inserts a number inbetween these two strings\ndraw_insuffmat = \"Égalité par manque de matériel !\"\ndraw_agreement = \"Égalité par accord !\"\ndraw_generic = \"Égalité!\"\naborted = \"Partie annulée.\"\nopponent_checkmate = \"Défaite par échec-et-mat !\"\nopponent_time = \"Défaite par le temps!\"\nopponent_resignation = \"Défaite par abandon !\"\nopponent_disconnect = \"Défaite par déconnexion !\"\nopponent_royalcapture = \"Défaite par perte du Roi !\"\nopponent_allroyalscaptured = \"Défaite par perte de tous les Rois!\"\nopponent_allpiecescaptured = \"Défaite par perte de toutes les pièces!\"\nopponent_threecheck = \"Défaite par triple échec !\"\nopponent_koth = \"Défaite par KOTH !\"\nopponent_generic = \"Défaite !\"\nwhite_checkmate = \"Les Blancs gagnent par échec-et-mat !\"\nblack_checkmate = \"Les Noirs gagnent par échec-et-mat !\"\nbug_checkmate = \"Ceci est un bug, reportez le s'il vous plaît. La partie s'est finie par un échec-et-mat.\"\nwhite_time = \"Les Blancs gagnent par le temps!\"\nblack_time = \"Les Noirs gagnent par le temps!\"\nbug_time = \"Ceci est un bug, reportez le s'il vous plaît. La partie s'est finie à cause du temps.\"\nwhite_royalcapture = \"Les Blancs gagnent par capture du Roi !\"\nblack_royalcapture = \"Les Noirs gagnent par capture du Roi !\"\nbug_royalcapture = \"Ceci est un bug, reportez le s'il vous plaît. La partie s'est finie par capture d'un Roi.\"\nwhite_allroyalscaptured = \"Les blancs gagnent par capture de tous les Rois!\"\nblack_allroyalscaptured = \"Les noirs gagnent par capture de tous les Rois!\"\nbug_allroyalscaptured = \"Ceci est un bug, reportez le s'il vous plaît. La partie s'est finie par la capture de tous les Rois d'un camp.\"\nwhite_allpiecescaptured = \"Les Blancs gagnent par capture de toutes les pièces!\"\nblack_allpiecescaptured = \"Les Noirs gagnent par capture de toutes les pièces!\"\nbug_allpiecescaptured = \"Ceci est un bug, reportez le s'il vous plaît. La partie s'est finie par la capture de toutes les pièces d'un camp.\"\nwhite_threecheck = \"Les Blancs gagnent par triple-échec !\"\nblack_threecheck = \"Les Noirs gagnent par triple-échec !\"\nbug_threecheck = \"Ceci est un bug, reportez le s'il vous plaît. La partie s'est finie par un triple-échec.\"\nwhite_koth = \"Les Blancs gagnent par KOTH !\"\nblack_koth = \"Les noirs gagnent par KOTH !\"\nbug_koth = \"Ceci est un bug, reportez le s'il vous plaît. La partie s'est finie par un KOTH.\"\nbug_generic = \"Ceci est un bug, reportez le s'il vous plaît.\"\n\n[terms]\ntitle = \"Conditions d'Utilisation\"\nwarning = [\"CE DOCUMENT N'A PAS DE VALEUR LÉGALE. Notre responsabilité n'est engagée que par la version anglaise de ce texte. Cette traduction n'a qu'une valeur informationelle et peut contenir des erreurs ou inexactitudes. Vous pouvez accèder à la version Anglaise \", \"ici\", \".\"]\nconsent = \"En utilisant ce site, vous vous engager à respecter les conditions suivantes. Si vous ne les acceptez pas, vous devez immédiatement quitter le site.\"\nguardian_consent = \"Si vous avez moins de 18 ans vous devez recevoir l'accord de vos responsables légaux pour utiliser ce site ou vous créer un compte.\"\nparents_header = \"Parents\"\nparents_paragraphs = [\n\"Il y a un système qui empêche les utilisateurs de mettre des certaines insultes en nom d'utilisateur. Pour l'instant il n'y pas de possibilité de communication entre les membres du site.\",\n\"Actuellement, les membres ne peuvent pas mettre de photos de profil personalisées, c'est prévu cependant. Au moment où cette fonctionnalité arrivera nous ferons de notre mieux pour empêcher les photos inappropriées.\",\n]\nfair_play_header = \"Fairplay\"\nfair_play_paragraph1 = [\"Vous ne pouvez pas créer plus d'un compte. Si vous voulez changer l'adresse mail associée à votre compte, \", \"contactez nous.\"]\nfair_play_paragraph2 = \"Pour garder le jeu amusant et équilibré pour tous, vous ne devez PAS:\"\nfair_play_rules = [\n\"Modifier ou manipuler le code ce qui inclut mais n'est pas limité à: l'utilisation de commandes dans la console, la réecriture de données locales, les scripts personnalisés, modifier les requêtes http et tout ce qui peut être fait intentionellement pour empêcher le fonctionnement normal du jeu ou vous donner un avantage.\",\n\"Dans les parties classées, recevoir de l'aide ou des conseils d'une autre personne ou d'un programme sur ce que vous devriez jouer. (Créer un programme est autorisé et encouragé mais vous devez vous limitez à l'utiliser dans des parties non classées).\",\n\"Échanger des points d'elo avec d'autres personnes en perdant volontairement dans l'objectif d'augmenter le classement de votre adversaire, ou en recevant des points d'elo d'un adversaire qui cherche à perdre pour augmenter votre classement. Ces situations abusent du système et créent des classements non représentatifs de la réalité.\"\n]\ncleanliness_header = \"Respect\"\ncleanliness_rules = [\n\"Vous devez rester respecteux sur tout le site, pas de vulgarité ou d'injures. Vous n'êtes pas autorisés à insulter, à harceler ou à menacer quiconque, ou à commettre un quelconque acte illégal. Vous n'êtes pas autorisé à spammer d'autres utilisateurs ou d'autres forums.\",\n\"Vous n'êtes pas autorisé à uploader des images qui peuvent être inappropriés, suggestives ou gores sur votre profil. Le faire pourrait résulter en un bannissement ou en une suppression de votre compte.\"\n]\nprivacy_header = \"Confidentialité\"\nprivacy_rules = [\n\"Pour l'instant la seule information personnelle que nous collectons est l'adresse mail. Elle nous permet de vérifier les comptes des utilisateurs et nous donne un moyen de prouver qui ils sont lorsqu'ils font une demande de changement de mot de passe. Nous n'envoyons pas de mails ou d'offres promotionelles et nous ne partageons pas vos adresses email avec des tiers.\",\n\"InfiniteChess.org peut collecter des données sur votre utilisation du site comme votre adresse IP. Cette collecte nous aide à empêcher les attaques automatisées ou autres connexions néfastes, et nous permet de collecter des statistiques précises sur l'utilisation du site. Votre adresse IP n'est PAS votre adresse réelle.\",\n\"Toutes les parties que vous jouez sur le site sont publiques. Si vous souhaitez rester anynome, ne partagez pas votre nom d'utilisateur avec vos amis ou votre famille. Si c'est ce que vous souhaitez, il est de votre  responsabilité de vous assurer que personne ne trouve de lien entre votre nom d'utilisateur et votre identité réelle.\",\n\"Le status d'activité de votre compte et la durée approximative depuis votre dernière connexion sur le site sont également publiques.\",\n[\"Bien qu'InfiniteChess.org fasse tout son possible pour garder toutes les données personnelles et les comptes sécurisés, dans le cas d'un hack ou d'une fuite de donnée, vous ne pourrez pas nous en tenir responsable. Si une fuite de donnée a lieu, les utilisateurs en seront informés sur la page \", \"Actualités\", \".\"],\n\"Il n'y pas d'achats intégrés au site. Toute information personnelle non précisée dans cette liste n'est pas collectée.\",\n\"Pour avoir toutes vos informations personnelles supprimées de nos serveurs, vous pouvez supprimer votre compte depuis votre page de profil. La seule chose en lien avec votre nom d'utilisateur que nous ne supprimerons PAS, est votre historique de parties, car toutes les parties jouées sont  publiques.\",\n]\ncookie_header = \"Utilisation de Cookies\"\ncookie_paragraphs = [\n\"Ce site utilise des cookies, qui sont des petits fichiers textes stockés dans votre navigateur, et envoyés au serveur quand vous vous connectez. Ces cookies servent à: vous authentifier, vérifier que votre navigateur est dans la bonne partie et stocker les préférences de votre compte pour que vous puissez les conserver entre deux visites. Le site n'utilise pas de cookies tierces, les cookies ne sont pas partagé avec des tiers.\",\n\"Les cookies sont nécessaires pour le bon fonctionnement du site. Si vous ne souhaitez pas que ce site stocke des cookies, vous devez arrêter de l'utiliser. Vous pouvez supprimer les cookies existants dans les paramètres de votre navigateur. En continuant d'utiliser  ce site, vous acceptez l'utilisation de cookies.\"\n]\nconclusion_header = \"Conclusion\"\nconclusion_paragraphs = [\n\"Toute violation de ces termes peut résulter en un bannissement ou en une suppression de votre compte. InfiniteChess.org a pour souhait de donner à tout le monde l'opportunité de jouer et de prendre du plaisir ! Mais, nous nous réservons le droit de bannir ou de supprimer des comptes, pour des raisons que nous ne sommes pas tenus de justifier ou de communiquer et ne ne pourrons légalement pas en être tenu responsables.\",\n[\"Ces termes peuvent être modifiés à tout moment. Il est de VOTRE responsabilité de vous tenir au courant des mises à jour de ces derniers ! Quand les conditions d'utilisations seront modifiées nous vous en informerons sur la page \", \"Actualités\", \". Si, vous n'adhérez plus aux conditions après une mise à jour de ces dernières vous devez immédiatement quitter le site. Vous pouvez supprimer votre compte depuis votre profil. Si vous supprimez votre compte, toutes vos informations personnelles et les données qui vont concernent seront supprimées SAUF l'historique de vos parties, qui sont associées à votre nom d'utilisateur. Car ces parties sont  publiques.\"],\n[\"Ce site est open-source. Vous pouvez récuperer ou distribuer toutes les ressources qui s'y trouvent tant que vos respectez les conditions décrites dans les \", \"licences\", \" ! Si le lien ci dessus n'est pas fonctionnel il est de votre responsabilité de trouver les licences.\"],\n\"Nous ne garantissons pas que ce site sera en ligne 100% du temps. Nous ne garantissons pas non plus que les données ne seront jamais corrompues.\",\n\"Vous n'êtes pas autorisé à performer des activités illégales sur ce site.\",\n[\"Si vous avez des questions concernant ces conditions, ou à propos du site de manière générale,\", \"envoyez nous un email !\"]\n]\nupdate = \"(Dernière mise à jour: 13 Juillet 2024. Ajout de l'avertissement sur le fait que toutes les parties jouées sur le site sont des informations publiques, ce qui inclut une approximation de la durée depuis la dernière activité de votre compte. Ces conditions peuvent être changées à tout instant et il est de votre responsabilité de vous assurez que vous restez à jour.)\"\nthanks = \"Merci !\"\n\n[login]\ntitle = \"Se Connecter\"\nusername = \"Nom d'utilisateur:\"\npassword = \"Mot de Passe:\"\nforgot_password = [\"Mot de passe oublié ? \", \"Envoyez nous un mail.\"]\nlogin_button = \"Se connecter\"\n\n[error-pages] # Messages shown on some error pages explaining what went wrong\n400_message = \"Des paramètres invalides ont été reçus.\"\n409_message = [\"Il y a peut être eu un conflit de nom d'utilisateur ou d'email. \", \"Rafraichissez\", \" la page s'il vous plaît.\"]\n500_message = \"Ceci n'est pas censé arriver. Du debugage doit être fait !\"\n\n########### NEWS ###########\n\n[news]\ntitle = \"Actualités\"\nmore_dev_logs = [\"Plus de devlogs sont publiés sur le \", \"discord officiel\", \", et sur les \", \"forums de chess.com\"]\n\n[server.javascript]\nws-invalid_username = \"Nom d'utilisateur invalide\"\nws-incorrect_password = \"Mot de passe incorrect\"\nws-username_and_password_required = \"Le nom d'utilisateur et le mot de passe sont requis.\"\nws-username_and_password_string = \"Le nom d'utilisateur et le mot de passe doivent être des chaines de caractères.\"\nws-login_failure_retry_in = \"Authentification impossible, réessayez dans\"\nws-seconds = \"secondes\"\nws-second = \"seconde\"\nws-username_length = \"Le nom d'utilisateur doit faire entre 3 et 20 caractères\"\nws-username_letters = \"Le nom d'utilisateur ne doit contenir que des lettres et des chiffres\"\nws-username_taken = \"Ce nom d'utilisateur est déjà pris\"\nws-username_bad_word = \"Ce nom d'utilisateur contient un mot interdit\"\nws-username_reserved = \"Ce nom d'utilisateur est réservé\"\nws-email_too_long = \"Votre email est trop looooong.\"\nws-email_invalid = \"Votre email n'est pas valide\"\nws-email_in_use = \"Cette adresse est déjà utilisée\"\nws-you_are_banned = \"Vous êtes banni.\"\nws-password_length = \"Le mot de passe doit fair entre 6 et 72 caractères\"\nws-password_format = \"Le mot de passe n'est pas dans le bon format\"\nws-password_password = \"Le mot de passe doit être différent de 'password'\"\nws-refresh_token_not_found_logged_out = \"Aucun utilisateur n'a ce token de rafraichissement (déjà déconnecté)\"\nws-refresh_token_not_found = \"Aucun utilisateur n'a ce token de rafraichissement\"\nws-refresh_token_expired = \"Pas de token de rafraichissement trouvé (session expirée)\"\nws-refresh_token_invalid = \"Le token de rafraichissement a expiré ou a été altéré\"\nws-member_not_found = \"Utilisateur introuvable\"\nws-forbidden_wrong_account = \"Interdit. Ceci n'est pas votre compte.\"\nws-deleting_account_not_found = \"Erreur lors de la suppression du compte. Le compte est introuvable.\"\nws-server_error = \"Désolé, une erreur de serveur s'est produite ! Revenez en arrière s'il vous plaît.\"\nws-unable_to_identify_client_ip = \"L'adresse ip du  client n'a pas pu être identifiée\"\nws-you_are_banned_by_server = \"Vous êtes banni\"\nws-too_many_requests_to_server = \"Trop de requêtes. Réessayez dans quelques minutes.\"\nws-bad_request = \"Mauvaise Requête\"\nws-not_found = \"404 Introuvable\"\nws-forbidden = \"Interdit.\"\nws-unauthorized_patron_page = \"Non autorisé. Cette page est réservé aux patreons.\"\nws-already_in_game = \"Vous êtes déjà dans une partie.\"\nws-server_restarting = \"Ce serveur redémarre dans\"\nws-server_under_maintenance = \"Le serveur redémarre bientôt.\" # Can be changed at will to change the display message.\nws-minutes = \"minutes\" # unit of time\nws-minute = \"minute\" # unit of time\nws-no_abort_game_over = \"Vous ne pouvez pas annuler la partie, elle est déjà finie.\"\nws-no_abort_after_moves = \"Vous ne pouvez pas annuler la partie, plus de 2 coups ont déjà été joués.\"\nws-game_aborted_cheating = \"Annulation de la partie à cause d'une forte probabilité de triche.\"\nws-cannot_resign_finished_game = \"Vous ne pouvez pas abandonner, la partie est déjà finie.\"\nws-invalid_code = \"Code invalide !\" # Invite code doesn't match any existing invites\nws-game_aborted = \"Partie annulée.\" # Invite was cancelled as you clicked on it\n"
  },
  {
    "path": "translation/news/en-US/2024-01-29.md",
    "content": "New video released today!\n\n<iframe src=\"https://www.youtube.com/embed/b-Bb_TyhC1A?si=kXWVsknpg5ksstKr\" title=\"YouTube video: The Search for the Longest Infinite Chess Game\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen></iframe>\n"
  },
  {
    "path": "translation/news/en-US/2024-05-14.md",
    "content": "Update 1.3 released today! This includes MANY new speed and user experience improvements. Just a few are:\n\n- The transition to websockets, decreasing the delay when your opponent moves.\n- No longer getting disconnected when you switch tabs.\n- Audible cues when you or someone else creates an invite, or makes a move.\n- Added the 50-move rule.\n- A drum countdown effect is now played at 10 seconds left on the clock.\n- An auto-resignation timer will start if you're opponent goes AFK (with an audible warning).\n\nAnd many others! For the full list, check out [the discord!](https://discord.com/channels/1114425729569017918/1114427288776364132/1240014519061712997)\n"
  },
  {
    "path": "translation/news/en-US/2024-05-24.md",
    "content": "Update 1.3.1 released! This includes the guide, pop-up tooltips when hovering over the navigation buttons, and links to the discord and game credits on the title page!\n"
  },
  {
    "path": "translation/news/en-US/2024-05-27.md",
    "content": "1.3.2: Added showcase variants for Omega^3 and Omega^4 that were shown in my latest video. Also, the checkmate algorithm is now compatible with multiple kings per side.\n"
  },
  {
    "path": "translation/news/en-US/2024-07-09.md",
    "content": "Infinite Chess is Now Open Source! See, and contribute, to the project [on GitHub!](https://github.com/Infinite-Chess/infinitechess.org)\n\n<iframe src=\"https://www.youtube.com/embed/fSUEKosgyt0?si=L-blqfVEpPBmQLMn\" title=\"YouTube video player\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen=\"\"></iframe>\n"
  },
  {
    "path": "translation/news/en-US/2024-07-13.md",
    "content": "The [Terms of Service](https://www.infinitechess.org/termsofservice) have been updated. Changes made: All games you play on the website may become public information, including the approximate time your account was last active. The terms may be updated at any time, and it is your responsibility to make sure you're up-to-date on them.\n\nYour game history may become available on your profile at a future time.\n"
  },
  {
    "path": "translation/news/en-US/2024-07-22.md",
    "content": "If you have not verified your account, please do so on your profile page! All unverified accounts will soon be deleted!!\n"
  },
  {
    "path": "translation/news/en-US/2024-08-01.md",
    "content": "Update 1.4 is released! There have been many collaborative features added since we open sourced!\n\n- Knightriders have been added, which hop infinitely like a knight until they're obstructed! The 'Knighted Chess' variant has been upgraded to replace the knights with knightriders!\n- Click your or your opponent's pieces at any time to view their possible moves!\n- Right-click at any time to deselect the currently selected piece.\n- Hovering over arrow indicators on the edge of the screen now renders the legal moves of the piece they are pointing to!\n- The game now automatically declares a draw if there's insufficient material on the board to force checkmate.\n- Translated the website into French! You can change the language by visiting the footer on any page.\n- Improved the loading time of the website.\n- New website icon, Ω! This automatically matches your preferred light or dark device theme.\n- The game code's, or the ICN's, metadata has been reformatted to more closely match PGN norms.\n- Users can now delete their account on their profile page, if they so choose, without having to email us.\n"
  },
  {
    "path": "translation/news/en-US/2024-09-11.md",
    "content": "The first-ever Infinite Chess tournament is now open for sign-ups!!! It will be played on the Classical variant, and the time control will be 10m+6s (this will be added soon). The winner will be given a special flare and/or role on the [community discord](https://discord.gg/NFWFGZeNh5)!\n\nHere's the [sign-up form](https://docs.google.com/forms/d/e/1FAIpQLScy5A3fDL_LduFuxy_qODx9hP1_aRip13SK37jH6ERjKWwu_w/viewform)! The deadline to sign up is **Friday, Sept 27th!** The full rules are located [here](https://docs.google.com/document/d/1lCc07bqYZwQbpSOkExZzY044TR5zNfyQT4IQZqqCinc/pub). For future updates about the tournament, join the [discord](https://discord.gg/NFWFGZeNh5)!\n\n**Update v.1.4.1 has been released!**\n\n- Draw offers have been added! Find the offer draw button in the pause menu!\n- Added languages for the following: Chinese, Polish, Portuguese!\n- Fixed bug where spamming the Create Invite button gave you messages such as you already have an invite, or you can't accept your own invite.\n"
  },
  {
    "path": "translation/news/en-US/2024-11-22.md",
    "content": "## Themes Update - v1.5 - Released!\n\n- Adjust the color of the board from a wide variety of options inside the new settings dropdown menu!\n- Choose whether legal moves are represented by dots or squares!\n- On desktop, adjust your perspective-mode mouse sensitivity and field of view!\n- The settings dropdown also includes a ping meter, so you can tell how fast your connection is!\n- The language selection has been moved from the footer to the settings dropdown.\n- Preferences are saved both on the browser, and on the server, so it will remember them wherever you go!\n- Completely redesigned the header bar! Added the Infinite Chess logo, vector graphics for each link, the settings gear dropdown. Also, there's no more horizontal scrolling needed on mobile, because the links adapt to the available space!\n- The board now retains momentum when you throw it with the mouse or your finger!\n- Login sessions are now automatically renewed when you reconnect 1 day after the previous renewal! No more abruptly being logged out when you are in the middle of a game.\n- Clocks now match exactly what the server says, subtract half your ping, instead of going off of your system clock, which may or may not be out of sync with the server machine's system clock. This was the cause of a bug displaying incorrect clock values.\n- Migrated members' account storage to a SQLite Database system.\n- Each member has been given a unique identifier. This cannot be changed, and cannot be reused when the account is deleted. Now, even when players change their name, their id will forever point to the same account. Game notation now includes the id of each player.\n"
  },
  {
    "path": "translation/news/en-US/2025-03-12.md",
    "content": "## Tournament announcement!\n\nWe are now opening the signups for the second ever Infinite Chess tournament since the creation of infinitechess.org, and the first tournament played on the “Chess on an Infinite Plane” starting position!\n\nThe time control will be 10m+6s and the format will consist of an initial group stage and a subsequent elimination stage, with games being played on a flexible schedule at a roughly weekly basis. The winner will be given a special unique role on the [community discord server](https://discord.gg/NFWFGZeNh5)!\n\n[Here's the sign-up form](https://docs.google.com/forms/d/e/1FAIpQLSegbe4y201GQDd8h8X0nxjgsY00j-gEE2CWWo6CaHpRV7xY-g/viewform?usp=dialog). The deadline to sign up is **Friday, April 4, 2025** and the tournament will begin on the following day. [The full rules are located here](https://docs.google.com/document/d/1QsV4WBC9bpbWHiaRZ-NT2Bdb4tfdl8dp/edit?usp=sharing&ouid=114043385276125637786&rtpof=true&sd=true). For future updates about the tournament, join the [discord](https://discord.gg/NFWFGZeNh5)!\n"
  },
  {
    "path": "translation/news/en-US/2025-03-17.md",
    "content": "# Big Update Release - Infinite Chess 1.6!\n\nWe're excited to announce the release of an update that's been long in progress! The main focuses are a new **checkmate practice mode**, the ability to **drag pieces**, and new **variants**!\n\n## Practice Mode\n\n- There's a new **Practice** menu on the title screen. Play against an engine to practice your checkmating skills against a lone king in lots of endgame configurations! Can you master every single checkmate known?\n\n- Earn radiant badges the more practice checkmates you complete! The highest badge you earn is displayed on your profile page for visitors to see.\n\n## Dragging Pieces\n\n- Pieces may now be dragged to move them. This is toggleable in the settings menu. Holding control will force drag the board instead of a piece.\n\n- Drag pieces onto arrow indicators on the edge of the screen to capture the piece the arrow is pointing to, if it is legal!\n\n## Variants\n\n- Piece movements and behavior are now highly customizable per variant. This has opened the door for new and exotic variants below. In addition, compatibility has been added for 4 dimensional variants of any size/depth/space!\n\n- New: **Confined Classical** by tsevasa. A wall of obstacles covers your rear, offering some protection against overpowered flank attacks, without inflating the number of pawns in the game.\n\n- New: **Chess on an Infinite Plane - Huygens Option** by V. Reinhart. This features a new piece, the Huygen. The Huygen moves in the same direction as rooks, except it is a _prime_ rider, meaning it only skips on squares which are a prime distance from its starting location. This has interesting mathematical implications on the infinite chessboard. Can you master its movement and dominate your opponents?\n\n- New: **4x4x4x4 Chess** by tsevasa. In this 4 dimensional variant, all pieces have gained the ability to jump across boards in different dimensions! The queen's movement imitates the princess, and the pawn's movement imitate the brawn, which are both found in 5D Chess with Multiverse Time Travel.\n\n- New: **5D Chess** by Jace. 64 squares. 64 boards. Chessboard inception! This variant was designed to be a reflection of 5D Chess with Multiverse Time Travel. Move interdimensionally across space and time to other worldly boards! The checkmate algorithm is disabled in this one, be careful not to step into check! The game ends when just one of the many kings are captured.\n\n- Deleted: Amazon Chandelier, Containment, Classical - Limit 7, and CoaIP - Limit 7. These were among the least played.\n\n## Other additions\n\n- Arrow indicators pointing to pieces off-screen are now animated with the moves. The mini images visible of the pieces when you are zoomed out are also now animated!\n\n- Added compatibility with the **Royal Queen**, and **Rose** piece. The Royal Queen is similar to a queen, but it will lose you the game if it is checkmated. The Rose piece behaves like a knightrider that leaps in circles. No variant features these yet (people are welcome to submit variant suggestions!).\n\n- All contributors to the infinitechess.org source code are now listed on the homepage. Thank you all!\n\n- Fixed the auto-aborting for \"cheating\" that ocurrs when you move a piece a distance of 1e21 or greater in an online game. You can now move as far in online games as you can in local games! Although you will still experience graphical glitches, those will be patched later.\n\n- Added a spinny-pawn animation while each game loads.\n\n- Your coordinates are now editable in local games.\n\n- Several other user experience improvements and bug fixes. Too many to list here!\n"
  },
  {
    "path": "translation/news/en-US/2025-05-21.md",
    "content": "### Updated the [Terms of Service](https://www.infinitechess.org/termsofservice).\n\nPlayers may not abuse bugs or glitches in order to abort the game, play otherwise illegal moves, give you an advantantage, or make the game otherwise unplayable.\n"
  },
  {
    "path": "translation/news/en-US/2025-06-16.md",
    "content": "# Infinite Chess Update 1.7!\n\n## Ranked + Leaderboard\n\n- Users can now choose to play rated games from the lobby! Win games to boost your own rating! Get estimates on your opponent's skill level.\n\n- Added a leaderboard page. How high can you climb? New players aren't immediately displayed on the leaderboard, only after their approximately first four rated games.\n\n- Updated username containers to display their rating, and hyperlink to their profile. Usernames are also now visible on mobile.\n\n## Annotations\n\n- Right-click the board to highlight squares, and right-click-drag to draw arrows! These can be used to help you analyze positions, or for streamers to show their chat what moves they're thinking of!\n\n- Double-right-click-drag to draw _rays_, which are an infinite line of square highlights in one direction. These can be used to quickly and efficiently line up long-distance attacks, without having to perform mental math to calculate what squares you need to land on.\n\n- Left click to collapse annotations. By default, this erases all square and arrow highlights, but if you have any drawn rays, then instead square highlights are added at all ray intersection points, and all rays are erased.\n\n- When zoomed out, annotations are rendered the same size as the pieces, and squares and rays can be clicked to automatically zoom into them.\n\n- Added a lingering annotations toggle in the settings dropdown. When enabled, selecting pieces will not automatically erase your existing annotations. This allows your annotations to persist from move to move, allowing you to remember key squares or line up attacks beforehand. You can still erase annotations by clicking an empty region of the board.\n\n- Mobile users have a new annotations button on the navigation bar, which when enabled will treat all touches as their right-click counterpart, allowing you to draw annotations without requiring a mouse. This means mobile users are not disadvantaged against pc users by not being able to draw rays to help line up long distance attacks.\n\n## Snapping\n\n- When you are zoomed out, and you hover the mouse over any legal move line or ray, your mouse will snap to points on that line that cardinally intersect with other pieces, or annotations, and clicking will immediately zoom you into that point! This makes it quick and easy to line up attacks without having to meticulously find the exact square you need.\n\n## Other additions\n\n- Added a piece-animations toggle in the settings dropdown. When disabled, pieces instantly teleport from their start square to their end square.\n\n- Mini images, even when they are disabled in large variants, now _always_ render pieces above square highlights, and the piece last moved. This is useful for keeping track of important pieces while zoomed out in large showcase variants.\n\n- Preset rays and squares have been added to the Omega^2 showcase to emphasize the important lines and squares for the main line of play. These are permanent and cannot be erased. Game notation also now supports preset squares and rays.\n\n- Holding alt and left clicking can be used to simulate a right click.\n\n- Added an automated password reset system on the login page.\n"
  },
  {
    "path": "translation/news/en-US/2025-11-28.md",
    "content": "# New video released + Infinite Chess Update 1.8!\n\n<iframe src=\"https://www.youtube.com/embed/AaBkZzy2t0Y?si=AUSy43E5kDPxFFvL\" title=\"Youtube video: The Journey to the Edge of the Infinite Chess Board\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen></iframe>\n\n## Infinite Board\n\n- The size of the board has been HUGELY increased! Removed soft zoom limits, allowing players to move much, much further, without experiencing glitches!\n\n- Added special board effects when you travel to extreme distances from the origin, amplifying a feeling of peeling back layers of reality. How far can you travel?\n\n- Added ambiences over 1,000 squares away from the origin.\n\n- Added a screen shake effect for large moves.\n\n- Added a new move sound and visual effects when moving extremely far.\n\n- Added a world border in Checkmate Practice games, and in the following variants: Obstocean, 4x4x4x4 Chess, 5D Chess, and Chess. Engines are not capable of infinite move distance, so this prevents them from breaking this update.\n\n- Added a Starfield effect inside VOID.\n\n- Added a Sound dropdown menu in the settings. Control the master volume of the game, and toggle on and off ambiences.\n\n- Added two toggles in the Appearance (renamed from Board) dropdown menu in the settings to toggle the Starfield or Advanced Board Effects.\n\n## Premoves\n\n- Added premoving! Move your piece while it's your opponent's turn to register it to be auto-submitted as soon as it's your turn again, assuming legality.\n\n- Disable premoves in the settings.\n\n## Other\n\n- Added new variants: Chess on an Infinite Plane - Roses Option, Chess on an Infinite Plane - Knightriders Option, and Palace. These feature the Rose (NEW), Knightrider, and Amazon! The Rose piece moves like a circular knightrider, turning 45 degrees after each jump. Also, deleted the Knighted Chess variant.\n\n- Added news post notifications. A red bubble appears next to the News hyperlink when you are logged in and have unread news posts. New news posts also have the \"NEW\" tag next to their date.\n\n- Patched the reverb effect on large moves commonly being abruptly cut off.\n"
  },
  {
    "path": "translation/news/en-US/2026-01-08.md",
    "content": "# Infinite Chess Update 1.9!\n\n## Computer Games\n\n- Practice anytime by playing against a strong engine in various variants, time control, and difficulty. Created by FirePlank. See the source code [here](https://github.com/FirePlank/infinite-chess-engine)!\n"
  },
  {
    "path": "translation/news/en-US/2026-03-09.md",
    "content": "# Infinite Chess Update 1.10 - Board Editor!\n\nThe highly requested board editor is finally here!\n\n## Board Editor\n\n- Place, move, and erase pieces freely to create new positions.\n\n- Selection tool gives you powerful ways to manipulate large groups of pieces at once, inspired by spreadsheet software.\n\n- Fully configurable gamerules.\n\n- Start a local or engine game directly from the editor.\n\n- Save and load positions in your browser or to the cloud (requires login).\n"
  },
  {
    "path": "translation/news/en-US/2026-04-24.md",
    "content": "# Infinite Chess Update 1.10.1!\n\n## Arrow Dragging\n\n- Click and drag an arrow pointing to one of your own off-screen pieces to move it directly, without needing to pan or zoom to it first! This is the complement to the existing feature of dragging your pieces onto arrows pointing to off-screen opponent pieces to capture them if legal.\n\n## Board Coordinates\n\n- Added a toggle in the settings to show rank and file coordinate labels along the edges of the board! This mostly benefits the sharing of puzzles, as previous board screenshots had no indication of piece coordinates.\n"
  },
  {
    "path": "translation/news/es-ES/2024-01-29.md",
    "content": "¡Nuevo vídeo publicado hoy!\n\n<iframe src=\"https://www.youtube.com/embed/b-Bb_TyhC1A?si=NmCm-RS21E61sA-O\" title=\"YouTube video: The Search for the Longest Infinite Chess Game\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" allowfullscreen=\"\"></iframe>\n"
  },
  {
    "path": "translation/news/es-ES/2024-05-14.md",
    "content": "¡La actualización 1.3 ha sido publicada hoy! Esto incluye muchas mejoras relacionadas con la velocidad y experiencia de usuario, como:\n\n- Transicionado a websockets, reduciendo el delay cuando tu oponente mueve.\n- Ya no te desconectamos cuando cambias de pestaña.\n- Sonidos cuando alguien crea una invitación, o hace un movimiento.\n- Añadida la regla de los 50 movimientos.\n- Un sonido de tambor sonará cuando queden 10 segundos.\n- Un timer de auto-resignación comenzará si tu oponente se va AFK (Con una advertencia sonora).\n\n¡Y muchas más! ¡La lista completa está en [el discord](https://discord.com/channels/1114425729569017918/1114427288776364132/1240014519061712997)!\n"
  },
  {
    "path": "translation/news/es-ES/2024-05-24.md",
    "content": "¡Se ha publicado la actualización 1.3.1! Esto incluye la guía, notas pop-up al poner el ratón encima de los botones de navegación, ¡Y links al discord y los créditos en la página principal!\n"
  },
  {
    "path": "translation/news/es-ES/2024-05-27.md",
    "content": "1.3.2: Se ha añadido una posición de ejemplo para Omega^3 y Omega^4 que aparecen en mi último video. También, el algoritmo de jaque mate es ahora compatible con varios reyes de cada color.\n"
  },
  {
    "path": "translation/news/es-ES/2024-07-09.md",
    "content": "¡Infinite Chess es ahora de Código Abierto! mira, y contribuye, al proyecto ¡[En GitHub](https://github.com/Infinite-Chess/infinitechess.org)!\n\n<iframe src=\"https://www.youtube.com/embed/fSUEKosgyt0?si=L-blqfVEpPBmQLMn\" title=\"YouTube video player\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen=\"\"></iframe>\n"
  },
  {
    "path": "translation/news/es-ES/2024-07-13.md",
    "content": "Los [Terminos de Servicio](https://www.infinitechess.org/termsofservice) han sido actualizados. Los cambios son: Todas las partidas que juegues en la web se convertirán en información pública, incluyendo aproximadamente la última vez que estuviste activo. Los términos podrán ser actualizados en cualquier momento, y es tu responsabilidad asegurare de que estás actualizado.\n\nTu historia de partidas puede volverse disponible en tu perfil en cualquier momento.\n"
  },
  {
    "path": "translation/news/es-ES/2024-07-22.md",
    "content": "Si no has verificado tu cuenta, ¡Hazlo en tu página de perfil! ¡¡Todas las cuentas sin verificar serán eliminadas!!\n"
  },
  {
    "path": "translation/news/es-ES/2024-08-01.md",
    "content": "¡La actualización 1.4 ha sido publicada! ¡Se han añadido muchas funcionalidades colaborativas desde que nos convertimos a código abierto!\n\n- ¡Se han añadido los Jinetes, que se mueven infinitamente como un caballo hasta que son obstruidos!¡La variante 'Knighted Chess' ha sido actualizada para que use jinetes en vez de caballos.\n- ¡Haz clic en las piezas de tu oponente en cualquier momento para ver su movimientos legales!\n- Haz clic derecho en cualquier momento para deseleccionar la pieza seleccionada.\n- ¡Pasar el ratón por encima de la flechas del borde de la pantalla ahora muestra los movimientos legales de esa pieza!\n- El juego ahora declara tablas automáticamente si no hay material suficiente en el tablero para forzar el jaque mate.\n- ¡Se ha traducido la página al francés! Puedes cambiar el idioma en el rodapié de cualquier página.\n- Se ha mejorado el tiempo de carga de la web.\n- ¡Nuevo icono de página, Ω! Este cambia automáticamente para adecuarse a tu tema preferido,claro o oscuro.\n- Los metadatos de los códigos de partida, o ICN's, ha sido reformateado para ajustarse mas a las normas de PGN.\n- Los usuarios ahora pueden eliminar su cuenta en sus páginas de perfil, si así lo desean, sin tener que mandarnos un correo.\n"
  },
  {
    "path": "translation/news/es-ES/2024-09-11.md",
    "content": "¡¡¡El primer torneo de Infinite Chess está abierto para anotarse!!! ¡Se jugará en la variante Clásica, y el reloj estará en 10m+6s (Se añadirá pronto). El ganador recibirá un rol especial en el [discord de la comunidad](https://discord.gg/NFWFGZeNh5)!\n\n¡Aquí está el [formulario de registración](https://docs.google.com/forms/d/e/1FAIpQLScy5A3fDL_LduFuxy_qODx9hP1_aRip13SK37jH6ERjKWwu_w/viewform)! ¡La fecha límite para apuntarse es el **Viernes, 27 de Septiembre!** Las bases completas están [aquí](https://docs.google.com/document/d/1lCc07bqYZwQbpSOkExZzY044TR5zNfyQT4IQZqqCinc/pub). ¡Para saber más sobre el torneo, únete al [discord](https://discord.gg/NFWFGZeNh5)!\n\n**Se ha publicado la actualización v1.4.1!**\n\n- ¡Se han añadido las ofertas de tablas!¡ Encontrarás el botón en el menú de pausa!\n- ¡Se han añadido idiomas para los siguientes: Chino, Polaco y Portugués!\n- Arreglado un bug en el que al spammear el botón de crear invitación, saltaban mensajes como ya tienes una invitación o no puedes aceptar tu propia invitación.\n"
  },
  {
    "path": "translation/news/es-ES/2024-11-22.md",
    "content": "## v1.5 - ¡Actualización de Temas!\n\n- ¡Ajusta el color de el tablero a una gran variedad de opciones en el nuevo menú de opciones!\n- ¡Escoge si quieres que los movimientos legales se representen con puntos o casillas!\n- ¡En sistemas de escritorio, ajusta la sensibilidad de tu ratón y campo de visión (FOV) de el modo perspectiva!\n- ¡El menú de opciones también contiene un medidor de latencia (ping), para saber como de rápida es tu conexión!\n- El menú de selección de Idioma ahora se encuentra en el menú de opciones.\n- ¡Las opciones se guardan tanto en tu navegador como en el servidor, así que siempre las recordaremos allá donde vallas (Y hayas iniciado sesión)!\n- ¡Hemos rediseñado completamente la cabecera! Hemos añadido el logo de Infinite Chess, Iconos para cada link y el menú de opciones. Además, ya no es necesario hacer scroll horizontal en dispositivos móviles, ¡Porque la página se ajusta automáticamente!\n- ¡El tablero ahora tiene inercia al moverlo con el ratón o tu dedo!\n- ¡Las sesiones de inicio ahora se renuevan automáticamente un día después de la sesión! Ya no se cerrará tu sesión en medio de una partida.\n- Los relojes ahora se ajustan exactamente a lo que dice el servidor, menos la mitad de tu ping, en vez de basarse en tu reloj local, que puede estar desincronizado con el del servidor. Esto causaba un error que mostraba valores del reloj incorrectos.\n- Hemos migrado el almacenamiento de las cuentas de usuario a una base de datos SQLite.\n- Se le ha dado a cada miembro un identificador único. Este no puede cambiar, y no puede ser reutilizado cuando se elimina la cuenta. Ahora, aunque los usuarios cambien su nombre, su id hará referencia a la misma cuenta. La notación de juego ahora incluya el id de cada jugador.\n"
  },
  {
    "path": "translation/news/es-ES/2025-03-12.md",
    "content": "## ¡Torneo de InfiniteChess!\n\nEstamos abriendo la inscripción del segundo torneo organizado desde la creación de infinitechess.org, ¡Y el primer torneo jugado en la variante \"Ajedrez en un plano infinito\"!\n\nEl reloj estará en 10m+6s, y el formato consistirá en una fase inicial de grupos y una fase de eliminación posterior, con partidas organizadas en un horario flexible, más o menos cada semana. El ganadaor recibirá un rol especial en el [servidor de Discord de la comunidad](https://discord.gg/NFWFGZeNh5)!\n\n[Este](https://docs.google.com/forms/d/e/1FAIpQLSegbe4y201GQDd8h8X0nxjgsY00j-gEE2CWWo6CaHpRV7xY-g/viewform?usp=dialog) es el formulario de inscripción. La fecha límite para anotarse es el **Viernes, 4 de Abril del 2025**, y el torneo comenzará al día siguiente. Las bases completas se encuentran [aquí](https://docs.google.com/document/d/1QsV4WBC9bpbWHiaRZ-NT2Bdb4tfdl8dp/edit?usp=sharing&ouid=114043385276125637786&rtpof=true&sd=true). ¡Para saber maś sobre el torneo, únete al [discord](https://discord.gg/NFWFGZeNh5)!\n"
  },
  {
    "path": "translation/news/es-ES/2025-03-17.md",
    "content": "# ¡Nueva actualización - Infinite Chess 1.6!\n\n¡Estamos encantados de anunciar el lanzamiento de una nueva actualización en la que llevamos mucho tiempo trabajando! Los puntos principales son: Un nuevo modo de **práctica de jaque mate**, la habilidad de **arrastrar piezas**, y **nuevas variantes**\n\n## Modo Práctica\n\n- Hay un nuevo menú de **Práctica** en la página principal. ¡Juega contra el ordenador para practicar tus habilidades de jaque mate contra un solo rey! ¿Puedes conquistar todos los mates conocidos?\n\n- ¡Consigue brillantes insignias cuantos más mates completes! La insignia de mayor valor que obtengas sera mostrada en tu página de perfíl.\n\n## Arrastrar piezas\n\n- Ahora las piezas pueden ser arrastradas para moverlas. Esto se puede activar o desactivar en el menu de opciones. Mantener control fuerza que se mueva el tablero, en vez de una pieza.\n\n- Arrastra las piezas a los indicadores de las flechas del borde de la pantalla para capturar esa pieza, ¡Si es legal!\n\n## Variantes\n\n- Los movimientos de las piezas y su comportamiento ahora son altamente personalizables. Esto ha abierto las puertas a nuevas y exóticas variantes, como las que veremos a continuación. ¡Adicionalmente, se han implementado variantes de 4D de cualquier tamaño/profundidad/espacio!\n\n- Nueva: **Clasica confinada** por tsevasa. Un muro de obstaculos cubren tu parte trasera, ofreciendo algo de protección contra ataques de flanco, sin incrementar el número de peones en juego.\n\n- Nueva: **Ajedrez en un plano infinito - Opciónes Huygens** por V. Reinhart. Esta variante contiene una nueva pieza, el Huygen. El Huygen se mueve en la misma dirección que las torres, excepto que solo cae en casillas que estén a una distancia prima del origen. Esto tiene implicaciones matemáticas interesantes en el tablero infinito. ¿Puedes dominar su movimiento y conquistar a tus oponentes?\n\n- Nueva: **Ajedrez 4x4x4x4** por tsevasa. En esta variante cuatridimensional, ¡Todas las piezas han ganado la habilidad de saltar a traves de los tableros en diferentes dimensiones! El movimiento de la dama imita al de la princesa, y el peon imita al brawn, que originan desde _\"5D Chess with Multiverse Time Travel\"_.\n\n- Nueva: **Ajedrez 5D** por Jace. 64 casillas. 64 tableros. Esta variante fue diseñada para ser un reflejo de _\"5D Chess with Multiverse Time Travel.\"_ M¡uevete interdimensionalmente a través del espacio y del tiempo a tableros de otro mundo! El algoritmo de jaque mate está desactivado en esta variante, ¡Ten cuidado de no caer en jaque! La partida acaba cuando uno de los muchos reyes es capturado.\n\n- Eliminadas: Candelabro Amazona, Containment, Clasica - Límite 7, y AeuPI - Límite 7. Estas estaban entre las menos jugadas.\n\n## Otros cambios\n\n- Los indicadores de flechas que apuntan a piezas fuera de plano estan ahora animados con los movimientos. ¡Las miniaturas que son visibles cuando haces zoom tabién estan animadas!\n\n- Implementadas las piezas de la **Dama Real** y la **Rosa**. La Dama Real es similar a una dama, pero perderás la partida si le hacen jaque mate. La rosa se comporta como un jinete que va en círculos. No hay ninguna variante que las incluya aún. (¿Las sugerencias son bienvenidas!)\n\n- Todos los contribuidores al código fuente de infinitechess.org ahora aparecen en la página principal. ¡Gracias a todos!\n\n- Arreglado el auto-aborte por \"trampas\" que ocurría cuando movías una pieza una distancia de 1e21 o mayor en una partida en linea. ¡Ahora puedes mover tan lejos como en las partidas locales! Puede que aún experiencies algunos errores visuales, esos serán arreglados más adelante.\n\n- Se ha añadido una animación de un peón girando mientras la posición se carga.\n\n- Tus coordenadas ahora son editables en las partidas locales.\n\n- Algunos otros arreglos de experiencia del usuario y de errores. ¡Demasiados para listar aquí!\n"
  },
  {
    "path": "translation/news/es-ES/2025-05-21.md",
    "content": "### Se han actualizado los [Terminos del Servicio](https://www.infinitechess.org/termsofservice).\n\nLos jugadores no deben abusar de errores o problemas del juego para abortar la partida, jugar movimientos que de otro modo serían ilegales, conseguir una ventaja, o hacer que no sea posible jugar.\n"
  },
  {
    "path": "translation/news/es-ES/2025-06-16.md",
    "content": "# Actualización 1.7 de Infinite Chess!\n\n## Partidas por puntos + Tabla de puntuación\n\n- ¡Los usuarios ahora pueden escojer jugar una partida por puntos en el menú! ¡Gana partidas para mejorar tu puntuación! Ahora puedes ver una estimación del nivel de tu rival.\n\n- Se ha añadido una página con la tabla de puntuación. ¿Como de alto puedes llegar? Los jugadores nuevos no aparecen de inmediato en la tabla, solo tras jugar aproximadamente cuatro partidas por puntos.\n\n- Se han actualizado los contenedores de nombre de usuario para que muestren su puntuación, y un link a su perfíl. Ahora también son visibles en dispositivos móviles.\n\n## Anotaciones\n\n- ¡Haz clic derecho en el tablero para mostrar casillar, y arrastra para dibujar flechas! ¡Puedes utilizarlas para analizar posiciones, o para que los streamers puedan decirle a sus espectadores lo que están pensando!\n\n- Haz doble-clic-derecho y arrastra para dibujar _rayos_, que son una linea de cuadrados subrayados en una dirección. Estos pueden usarse para alinear las piezas as larga distancia, sin tener que calcular mentalmente el cuadrado de destino.\n\n- Haz clic izquierdo para ocultar las anotaciones. Por defecto, esto borra todas las flechas y los cuadrados subrayados, pero si has dibujado uno o más rayos, entonces todos los puntos en los que un rayo interseca con una pieza se añade un cuadrado subrayado, y se borran todos los rayos.\n\n- Cuando se hace zoom out, las anotaciones aparecerán del mismo tamaño que las piezas, y puedes clicar los cuadrados y los rayos para hacer zoom en ellos.\n\n- Se ha añadido una opción de anotaciones persistentes en el menú de opciones. Cuando se activa, seleccionar piezas no borrará tus anotaciones. Esto permite que tus anotaciones persistan entre jugadas, ayudando a hacer estrategias. Siempre puedes borrar las anotaciones haciendo clic en cualquier parte del tablero que esté vacía.\n\n- Los usuarios de dispositivos móviles ahora tienen un nuevo botón de anotaciones en la barra de navegación, que si está activado tratará todas las pulsaciones como si fuesen un (clic-derecho), permitiendo dibujar anotaciones sin un ratón. Esto ayuda a reducir la desventaja de los jugadores en dispositivos móviles ya que permite que dibujen rayos y que planeen ataques a larga distancia.\n\n## Alineado automático\n\n- Cuando se hace zoom out, si pones el ratón encima de una linea de movimiento legal o un rayo, tu ratón se alineará a los puntos que coincidan con otras piezas, o anotaciones, ¡y hacer clic hará zoom en ese punto! Esto hace que sea facil alinear ataques sin tener que encontrar la casilla exacta.\n\n## Otros cambios\n\n- Se ha añadido una opción para desactivar las animaciones de piezas e el menú de opciones. Cuando se activa, las piezas se mueven instantaneamente de una casilla a otra.\n\n- Las miniaturas, incluso cuando están desactivas en variantes grandes, _siempre_ se renderizan cuando la pieza está sobre una cuasilla subrayada, o sea la última pieza que ha sido movida. Este cámbio es útil para no perder las piezas importantes cuando hay muchas.\n\n- Se han añadido cuasillas subrayadas y rayos a la variante Omega^2 para enfatizar las lineas de juego importantes. Estas son permanentes y no se pueden borrar. La notación de partida ahora puede contener casillas subrayadas y rayos.\n\n- Mantener alt y hacer clic izquierdo puede usarse para simular un clic derecho.\n\n- Se ha añadido un sistema de reinicio de contraseña automático en la página de inicio de sesión.\n"
  },
  {
    "path": "translation/news/es-ES/2025-11-28.md",
    "content": "# ¡Nuevo vídeo publicado + Actualización 1.8 de Infinite Chess!\n\n<iframe src=\"https://www.youtube.com/embed/AaBkZzy2t0Y?si=AUSy43E5kDPxFFvL\" title=\"Youtube video: The Journey to the Edge of the Infinite Chess Board\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen></iframe>\n\n## Tablero Infinito\n\n- ¡El tamaño del tablero ha aumentado MUCHÍSIMO! ¡Se han eliminado los límites de zoom, permitiendo a los jugadores mover las piezas, mucho, muchísimo mas lejos sin experimentar errores!\n\n- Se han añadido efectos de tablero especiales cuando viajas a distancias extremas del origen, amplificando el sentimiento de desvelar las capas de la realidad. ¿Cuan lejos podrás llegar?\n\n- Se ha añadido ambiencia a más de 1000 casillas del origen.\n\n- Se ha añadido un efecto de temblor de pantalla para movimientos grandes.\n\n- Se han añadido un sonido de movimiento nuevo y efectos visuales cuando el jugador mueve extremadamente lejos.\n\n- Se ha añadido un borde de mundo en partidas de práctica de jaque mate, y en las siguientes variantes: Obstocean, Ajedrez 4x4x4x4, Ajedrez 5D, y Ajedrez. Los motores no son capaces de mover infinitamente, asi que esto previene que se rompan en esta actualización.\n\n- Se ha añadido un efecto de campo de estrellas dentro de VACÍO.\n\n- Se ha añadido un menu desplegable de sonido en las opciones. Controla el volumen general del juego, y habilita o deshabilita la ambientación.\n\n- Se han añadido dos controles al menu desplegable \"Apariencia\" (Renombrado de \"Tablero\") para habilitar o dehabilitar el campo de estrellas o los efectos de tablero avanzados.\n\n## Premovimientos\n\n- ¡Ahora puedes hacer premovimientos! Mueve tus piezas mientras sea el turno de tu oponente para que se ejecute instantaneamente en tu próximo turno, asumiendo que el movimiento sea legal.\n\n- Puedes desactivar los premovimientos en las opciones.\n\n## Otros\n\n- Nuevas variantes: Ajedrez en un plano infinito - Rosas, Ajedrez en un plano infinito - Jinetes, y Palacio. ¡Estas contienen la Rosa (NUEVA), Jinete y Amazona! La rosa se mueve como un jinete circular, girando 45 grados tras cada salto. También se ha eliminado la variante Ajedrez a caballo.\n\n- Se han añadido notificaciones de publicaciones de noticias. Una burbuja roja aparece al lado del link \"Noticias\" cuando has iniciado sesión y tienes publicaciones sin leer. Las nuevas publicaciones también tienen la nota \"NUEVO\" al lado de la fecha.\n\n- Se ha arreglado un error que hacía que el efecto de reverberación en movimientos grandes se cortase.\n"
  },
  {
    "path": "translation/news/fi-FI/2026-01-08.md",
    "content": "# Infinite Chess päivitys 1.9!\n\n## Tietokonepelit\n\n- Harjoittele milloin vain pelaamalla todella hyvää tietokonetta vastaan monessa erilaisessa variantissa, aikakontrollilla, ja vaikeudessa. FirePlankin luoma. Katso lähdekoodi [täältä](https://github.com/FirePlank/infinite-chess-engine)!\n"
  },
  {
    "path": "translation/news/fi-FI/2026-03-09.md",
    "content": "# Infinite Chess päivitys 1.10 - Laudan muokkaaja!\n\nKauan toivottu laudanmuokkain on vihdoin täällä!\n\n## Laudan muokkaaja\n\n- Luo uusia asemia asettamalla, siirtämällä ja poistamalla nappuloita vapaasti.\n\n- Valintatyökalu tarjoaa tehokkaita tapoja käsitellä suuria nappularyhmiä kerralla taulukkolaskentaohjelmista tutulla tavalla.\n\n- Täysin muokattavat pelisäännöt.\n\n- Aloita paikallinen tai tietokonepeli suoraan muokkaimesta.\n\n- Tallenna ja lataa asemia selaimeesi tai pilveen (vaatii kirjautumisen).\n"
  },
  {
    "path": "translation/news/fi-FI/2026-04-24.md",
    "content": "# Infinite Chess päivitys 1.10.1!\n\n## Nuolien siirtäminen\n\n- Valitse ja siirrä nuolta, joka osoittaa ruudun ulkopuolella olevaa nappulaa, ilman että kameraa tarvitsisi liikuttaa sen luokse! Tämä täydentää olemassa olevaa ominaisuutta, jossa nappuloitasi voi vetää nuolille, jotka osoittavat ruudun ulkopuolella oleviin vastustajan nappuloihin, kaapataksesi ne, jos se on sallittua.\n\n## Laudan koordinaatit\n\n- Asetuksiin on lisätty valinta, jolla voi näyttää rivien ja sarakkeiden koordinaatit laudan reunoilla! Tämä hyödyttää erityisesti pulmien jakamista, sillä aiemmissa lautakuvissa ei ollut merkintöjä nappuloiden sijainneista.\n"
  },
  {
    "path": "translation/news/fr-FR/2024-01-29.md",
    "content": "Nouvelle vidéo sortie aujourd'hui !\n\n<iframe src=\"https://www.youtube.com/embed/b-Bb_TyhC1A?si=kXWVsknpg5ksstKr\" title=\"YouTube video: The Search for the Longest Infinite Chess Game\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen></iframe>\n"
  },
  {
    "path": "translation/news/fr-FR/2024-05-14.md",
    "content": "La version 1.3 est sortie aujourd'hui ! Elle comprend BEAUCOUP d'améliorations de vitesse et de l'expérience utilisateur. Dont notamment :\n\n- Le changement vers les websockets, diminuant le délai entre les coups.\n- La fin des déconnexions lorsque l'on change de fenêtre pendant une partie.\n- Des effets sonores lorsque vous ou quelqu'un d'autre crée une invitation ou joue un coup.\n- L'ajout de la règle des 50 coups.\n- Un compte à rebours sonore qui est joué quand il ne vous reste plus que 10 secondes à la pendule.\n- Un minuteur pour l'abandon automatique qui démarrera si votre adversaire s'en va (avec avertissement sonore).\n\nEt plein d'autres choses ! Pour la liste complète, rendez vous sur [le Discord](https://discord.com/channels/1114425729569017918/1114427288776364132/1240014519061712997) !\n"
  },
  {
    "path": "translation/news/fr-FR/2024-05-24.md",
    "content": "La version 1.3.1 est sortie ! Elle comprend le guide, des info-bulles au survol des boutons de navigation ainsi que des liens vers le Discord et les crédits sur le menu du jeu !\n"
  },
  {
    "path": "translation/news/fr-FR/2024-05-27.md",
    "content": "1.3.2 : Ajout des variantes de showcase pour Omega^3 et Omega^4 qui ont été présentées dans la dernière vidéo. L'algorithme de mat est maintenant compatible avec plusieurs rois.\n"
  },
  {
    "path": "translation/news/fr-FR/2024-07-09.md",
    "content": "Infinite Chess est désormais Open Source ! Regardez, et contribuez au projet [sur le GitHub](https://github.com/Infinite-Chess/infinitechess.org) !\n\n<iframe src=\"https://www.youtube.com/embed/fSUEKosgyt0?si=L-blqfVEpPBmQLMn\" title=\"YouTube video player\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen=\"\"></iframe>\n"
  },
  {
    "path": "translation/news/fr-FR/2024-07-13.md",
    "content": "Les [Conditions d'Utilisation](https://www.infinitechess.org/termsofservice) ont été mises à jour. Changements : Toutes les parties que vous jouez sur le site peuvent devenir publiques, et il en va de même pour la durée approximative depuis votre dernière activité. Les conditions d'utilisation peuvent être modifiées à tout instant, et il est de votre responsabilité de vous assurer que vous êtes à jour sur ces dernières.\n\nL'historique de vos parties pourrait être accessible depuis votre profil dans le futur.\n"
  },
  {
    "path": "translation/news/fr-FR/2024-07-22.md",
    "content": "Si vous n'avez pas vérifié votre compte, faites-le dès maintenant depuis votre profil s'il vous plaît ! Tous les comptes non vérifiés seront bientôt supprimés !!\n"
  },
  {
    "path": "translation/news/fr-FR/2024-08-01.md",
    "content": "La mise à jour 1.4 est sortie ! Il y a eu de nombreuses fonctionnalités ajoutées par la communauté depuis que nous sommes devenus Open Source !\n\n- Les Cavaliers Sauteurs ont été ajoutés, ils sautent comme un cavalier mais jusqu'à l'infini, tant que leur chemin n'est pas bloqué ! La variante « Knighted Chess » a été modifiée pour remplacer les cavaliers par les cavaliers sauteurs !\n- Vous pouvez désormais cliquer sur les pièces de votre adversaire à n'importe quel moment pour voir tous les mouvements qu'elles peuvent faire !\n- Vous pouvez maintenant faire un clic droit pour désélectionner une pièce.\n- Survoler les flèches sur les bords de l'écran avec le curseur affiche désormais les déplacements de la pièce vers laquelle la flèche pointe !\n- La partie sera automatiquement déclarée nulle lorsqu'il n'y a pas assez de matériel sur le plateau pour forcer un mat.\n- Le site a été entièrement traduit en français ! Vous pouvez changer la langue en bas de chaque page.\n- Les temps de chargement ont été améliorés sur le site.\n- Le site a une nouvelle icône: Ω ! Elle s'adapte automatiquement au mode clair ou sombre.\n- Les métadonnées des codes ICN ont été reformatées pour se rapprocher des standards PGN.\n- Les utilisateurs peuvent désormais supprimer leur compte depuis leur profil s'ils le souhaitent, sans avoir à nous envoyer de mail.\n"
  },
  {
    "path": "translation/news/fr-FR/2024-09-11.md",
    "content": "Le premier tournoi d'Échecs Infinis est maintenant ouvert aux inscriptions !!! Il sera joué sur la variante classique, et l'horloge sera sur 10m+6s. Le gagnant aura un badge spécial et/ou un rôle dans la communauté [Discord](https://discord.gg/NFWFGZeNh5) !\n\n[Pour s'inscrire (en anglais)](https://docs.google.com/forms/d/e/1FAIpQLScy5A3fDL_LduFuxy_qODx9hP1_aRip13SK37jH6ERjKWwu_w/viewform). La date limite d'inscription est **le 27 septembre**! Toutes les règles sont [ici](https://docs.google.com/document/d/1lCc07bqYZwQbpSOkExZzY044TR5zNfyQT4IQZqqCinc/pub). Pour les futures informations concernant le tournoi (en anglais), rejoignez le [Discord](https://discord.gg/NFWFGZeNh5)!\n\n**La mise à jour 1.4.1 est sortie !**\n\n- On peut proposer nulle ! Le bouton est dans le menu pause !\n- Trois nouvelles langues ont été rajoutées : Le chinois, le polonais, et le portugais !\n- Correction de bug où cliquer plusieurs fois le bouton \"Créer une invitation\" affiche des messages disant que vous en avez déjà créé une, ou que vous ne pouvez pas accepter votre propre invitation.\n"
  },
  {
    "path": "translation/news/fr-FR/2024-11-22.md",
    "content": "## Mise à jour des thèmes (v1.5) sortie !\n\n- La couleur du plateau peut désormais être ajustée avec beaucoup d'options via le nouveau menu déroulant de paramètres !\n- Vous pouvez désormais choisir si les coups légaux sont représentés par des points ou des carrés !\n- Sur PC, il est désormais possible d'ajuster la sensibilité de la souris ainsi que le champ de vision.\n- La latence est maintenant affichée dans les paramètres, ce qui vous permet de connaître la vitesse de votre connexion !\n- La langue a été déplacé du bas de des pages aux paramètres.\n- Les préférences sont enregistrées dans le navigateur et sur le serveur, de sorte qu'elles restent sauvegardées en permanence, où que vous soyez !\n- Le design de l'en-tête a été complètement refait ! Le logo des Échecs Infinis, des vecteurs graphiques pour chaque lien et l'engrenage des paramètres y ont été ajoutés. De plus, sur mobile, il n'est plus nécessaire de faire défiler l'écran horizontalement, car les liens s'adaptent à la taille de l'écran !\n- L'échiquier garde maintenant de l'élan lorsque vous le lancez avec la souris ou avec le doigt !\n- Les sessions de connexion sont automatiquement renouvelées lorsque vous vous reconnectez le lendemain d'une précédente renouvellement ! Vous ne serez plus déconnecté au milieu d'une partie.\n- À présent, le temps affiché par la pendule correspond exactement au temps du serveur, en soustrayant la moitié de votre ping, au lieu de regarder l'horloge de l'appareil, qui peut ne pas avoir la même heure que celle du serveur. C'était la cause d'un bug qui faisait s'afficher des valeurs incorrectes à la pendule.\n- Le stockage des comptes des membres a été migré vers un système de bases de donnée SQLite.\n- Chaque membre a maintenant un identifiant unique. Cet identifiant est inchangeable, il ne peut pas être réutilisé après la suppression d'un compte. Même en cas de changement de pseudonyme, l'identifiant indique le même compte. La notation des parties incluront également les identifiants des joueurs.\n"
  },
  {
    "path": "translation/news/fr-FR/2025-03-12.md",
    "content": "## Arrivée d'un tournoi !\n\nNous lançons désormais le deuxième tournoi des Échecs Infinis depuis la création d'infinitechess.org, et le premier joué dans la variante _Chess on an Infinite Plane_ (CoaIP, les \"Échecs sur un Plan Infini\").\n\nLa cadence sera de 10+6, et le tournoi consistera en une phase de groupes initiale suivie d’une phase éliminatoire, avec des parties jouées à des horaires très variables, environ une fois par semaine. Le gagnant obtiendra un rôle spécial sur le [serveur Discord de la communauté](https://discord.gg/NFWFGZeNh5) !\n\n[Voici le formulaire pour participer](https://docs.google.com/forms/d/e/1FAIpQLSegbe4y201GQDd8h8X0nxjgsY00j-gEE2CWWo6CaHpRV7xY-g/viewform?usp=dialog).\nLa date limite est fixée au **vendredi 4 avril 2025**, et le tournoi commencera le lendemain.\nToutes les règles sont [ici](https://docs.google.com/document/d/1QsV4WBC9bpbWHiaRZ-NT2Bdb4tfdl8dp/edit?usp=sharing&ouid=114043385276125637786&rtpof=true&sd=true).\nPour les informations futures sur le tournoi, rejoignez le [Discord](https://discord.gg/NFWFGZeNh5) !\n"
  },
  {
    "path": "translation/news/fr-FR/2025-03-17.md",
    "content": "# Mise à jour majeure - les Échecs Infinis 1.6 !\n\nNous sommes heureux d'annoncer la sortie d'une mise à jour tant attendue ! Les nouveautés les plus importantes concernent le **mode d'entraînement aux échecs et aux mats**, la possibilité de **faire glisser les pièces**, ainsi que de nouvelles **variantes** !\n\n## Mode Entraînement\n\n- Un nouveau menu **Entraînement** est disponible sur l'écran principal. Jouez contre l'ordinateur pour vous entraîner aux échecs et mats contre un roi seul, dans de nombreuses configurations de finale ! Pouvez-vous maîtriser tous les échecs et mats connus ?\n\n- Obtenez des badges resplendissants en complétant les échecs et mat d'entraînement ! Le meilleur badge que vous obtenez est affiché sur votre page de profil pour que les visiteurs la voient.\n\n## Faire glisser les pièces\n\n- Il est désormais possible de déplacer les pièces en les faisant glisser. Ça peut être activé ou désactivé dans les paramètres. Sur PC, maintenir Ctrl permet de déplacer le plateau au lieu des pièces.\n\n- Faites glisser les pièces sur les indicateurs de flèches situés au bord de l'écran pour capturer la pièce vers laquelle la flèche pointe (si c'est un coup légal) !\n\n## Variantes\n\n- Les mouvements et le comportement des pièces sont désormais grandement personnalisables pour chaque variante. Cela a permis l'émergence de nouvelles variantes exotiques (voir en dessous). De plus, la compatibilité a été ajoutée pour les variantes en 4 dimensions de n'importe quelle taille, profondeur ou espace !\n\n- Nouveau : **Classique Confiné** par tsevasa. Un mur d'obstacles couvre vos arrières, vous protégeant des attaques sournoises sans augmenter le nombre de pions.\n\n- Nouveau : **Échecs sur un Plan Infini — Option Hugyen** par V. Reinhart. Cette variante contient une nouvelle pièce, le Huygen (ou Huygène). Le Huygen se déplace dans les mêmes directions que la tour, mais est un sauteur _premier_, signifiant qu'il ne peut se déplacer que sur les cases à une distance première de sa case de départ (et sautant par dessus les autres). Cela a des implications mathématiques intéressantes sur l'échiquier infini. Pouvez-vous maîtriser ses mouvements et dominer vos adversaires ?\n\n- Nouveau : **Échecs 4x4x4x4** par tsevasa. Dans cette variante à quatre dimensions, toutes les pièces peuvent sauter à travers les plateaux dans des dimensions différentes ! La dame se déplace comme la princesse et le pion comme le brawn, les deux pièces étant dans « 5D Chess with Multiverse Time Travel » (les Échecs 5D avec Voyage Temporel dans le Multivers).\n\n- Nouveau : **Échecs 5D** par Jace : 64 cases, 64 plateaux. Le commencement d'un échiquier ! Cette variante a été conçue pour être un reflet des Échecs 5D avec Voyage Temporel dans le Multivers. Déplacez-vous interdimensionnellement dans l'espace et dans le temps sur d'autres échiquiers de ce monde ! L'algorithme d'échec et mat été désactivé dans cette variante, faites attention de ne pas vous mettre en échec ! La partie se termine quand un seul des nombreux rois est capturé.\n\n- Supprimés : Chandelier d'Amazone, Endiguement, Classique - Limite 7, et CoaIP - Limite 7, car personne n'y jouait.\n\n## Autres\n\n- Les indicateurs fléchés pointant vers des pièces situées en dehors de l'écran sont animées avec les coups. Les mini-icônes des pièces lorsque vous avez dézoomé sont aussi animées !\n\n- Les **Dame royale** et **Rose** sont désormais compatibles. La Dame Royale est similaire à une dame, mais elle vous fera perdre la partie si elle est mise en échec et mat. La Rose se comporte comme un chevalier qui saute en cercle. Il n'y a pas encore de variante pour ces pièces (les suggestions de variantes sont les bienvenues !).\n\n- Tous les contributeurs au code source d'infinitechess.org sont désormais listés sur la page d'accueil. Merci à tous !\n\n- Correction de l'abandon automatique pour « tricherie » qui se produit lorsque vous déplacez une pièce sur une distance de 10^21 cases ou plus dans une partie en ligne. Vous pouvez désormais vous déplacer aussi loin dans les parties en ligne que dans les parties locales ! Bien que vous allez encore rencontrer des bugs graphiques, ceux-ci seront corrigés ultérieurement.\n\n- Une animation d'un pion qui tourne pendant qu'une partie se charge a été ajouté.\n\n- Vos coordonnées peuvent désormais être modifiées dans les parties locales.\n\n- Plusieurs autres améliorations de l'expérience utilisateur et corrections de bugs. Il y en a trop pour les lister ici !\n"
  },
  {
    "path": "translation/news/fr-FR/2025-05-21.md",
    "content": "### Les [Conditions d'Utilisation](https://www.infinitechess.org/termsofservice) ont été mises à jour.\n\nPlayers may not abuse bugs or glitches in order to abort the game, play otherwise illegal moves, give you an advantantage, or make the game otherwise unplayable.\n\nLes joueurs ne sont pas autorisés à abuser de bugs pour interrompre la partie, jouer des coups autrement illégaux, obtenir un avantage ou sinon rendre le jeu injouable.\n"
  },
  {
    "path": "translation/news/fr-FR/2025-06-16.md",
    "content": "# Mise à jour 1.7 des Échecs Infinis !\n\n## Parties Classées + Classement\n\n- Les utilisateurs peuvent désormais choisir des parties classées depuis le menu de jeu ! Gagnez des parties pour améliorer votre propre classement ! Ayez une estimation sur le niveau de jeu de votre adversaire.\n\n- Une page avec le classement a été ajoutée. Jusqu'à où pouvez-vous aller ? Les nouveaux joueurs ne sont pas immédiatement affichés sur le classement, mais seulement après environ 4 parties classées.\n\n- Les conteneurs des pseudonymes ont été mis à jour pour contenir leur classement, et un lien vers leur profil. Les pseudonymes sont aussi visibles sur mobile.\n\n## Annotations\n\n- Faites des clics droit pour surligner des cases, et faites glisser avec clic droit pour dessiner des flèches ! Cela peut être utile pour analyser une position, ou en streaming pour montrer à quel coups on pense !\n\n- Faire glisser avec double clic droit pour dessiner des _rayons_, des lignes infinies de cases surlignées. Ils peuvent être utilisés pour prévoir des attaques à longue distance rapidement et efficacement, sans faire de calcul mental pour trouver la case sur laquelle se déplacer.\n\n- Faites un clic gauche pour enlever les annotations. Par défaut, ça supprime tous les surlignages de case et flèches, mais si vous avez au moins un rayon dessiné, alors à la place des surlignages de case sont ajoutés à tous les points d'intersection des rayons, et tous les rayons sont effacés.\n\n- Quand vous avez dézoomé, les annotations sont affichées avec la même taille que les pièces, et vous pouvez cliquer sur les cases et les rayons pour zoomer dessus.\n\n- Un bouton pour les annotations persistantes a été ajouté dans le menu déroulant des paramètres. Lorsque c'est activé, sélectionner des pièces ne va pas effacer automatiquement vos annotations existantes. Ça permet à vos annotations de persister de coup en coup, vous permettant de vous souvenir des cases clés ou d'organiser des attaques en avance. Vous pouvez toujours effacer les annotations en cliquant sur une zone vide du plateau.\n\n- Les utilisateurs sur mobile ont un nouveau bouton d'annotation sur la barre de navigation, qui, quand activé, va compter tous leurs contacts comme l'équivalent du clic droit, vous permettant d'ajouter des annotations sans avoir besoin d'une souris. Cela signifie que les utilisateurs sur mobile ne sont pas désavantagés par rapports aux utilisateurs sur PC par l'incapacité de dessiner des rayons pour les aider à organiser des attaques à longue distance.\n\n## Accrochage\n\n- Lorsque vous êtes dézoomés, et que vous survolez avec votre curseur au dessus de n'importe quelle ligne de coups légaux ou rayon, votre souris s'accrochera aux points de cette ligne qui croisent cardinalement d'autres pièces ou annotations, et cliquer vous permettra d'immédiatement zoomer sur ce point ! Cela rend rapide et facile d'organiser des attaques sans devoir méticuleusement trouver la case exacte dont vous avez besoin.\n\n## Autres ajouts\n\n- Ajout d'une option pour activer/désactiver les animations des pièces dans le menu déroulant des paramètres. Lorsqu'elle est désactivée, les pièces se téléportent instantanément de leur case de départ à leur case d'arrivée.\n\n- Les mini-images, même lorsqu'elles sont désactivées dans les grandes variantes, affichent désormais systématiquement les pièces au-dessus des cases surlignées, ainsi que la dernière pièce déplacée. C'est utile pour suivre les pièces importantes en étant dézoomé dans les grandes variantes de showcase.\n\n- Des rayons et surlignages préréglés ont été ajoutés à la variante de showcase d'Omega^2 pour mettre l'accent sur les lignes et cases importantes pour la ligne principale. Ils sont permanents et ne peuvent pas être effacés. La notation des parties supporte désormais les cases et rayons préréglés.\n\n- Maintenir Alt en faisant un clic gauche peut être utilisé pour simuler un clic droit.\n\n- Un système automatisé de réinitialisation du mot de passe a été ajouté sur la page de connexion.\n"
  },
  {
    "path": "translation/news/fr-FR/2025-11-28.md",
    "content": "# Nouvelle vidéo sortie + Mise à jour 1.8 des Échecs Infinis !\n\n<iframe src=\"https://www.youtube.com/embed/AaBkZzy2t0Y?si=AUSy43E5kDPxFFvL\" title=\"Youtube video: The Journey to the Edge of the Infinite Chess Board\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen></iframe>\n\n## Plateau infini\n\n- La taille du plateau a été ÉNORMÉMENT augmentée ! Les limites de dézoom ont été supprimées, permettant aux joueurs de se déplacer beaucoup, beaucoup plus loin, sans bug.\n- Des effets spéciaux ont été ajoutés au plateau lorsque vous vous déplacez à des distances extrêmes de l'origine, amplifiant le sentiment de se débarrasser de couches de la réalité. Jusqu'à quelle distance pouvez-vous aller ?\n- Des ambiances au-delà de 1 000 cases de l'origine ont été ajoutées.\n- Des effets de tremblement pour les grands coups ont été ajoutés.\n- Un nouveau son et un nouveau effet visuel ont été ajoutés pour les déplacements très lointains.\n- Une limite a été ajoutée au plateau dans les parties d'entraînement aux échecs et mats, et dans les variantes suivantes : Obstocean, les échecs 4×4×4×4, les échecs en 5D, et les échecs. Les moteurs ne sont pas capables de se déplacer à des distances infinies, donc cela permet qu'ils puissent encore fonctionner après cette mise à jour.\n\n- Un effet de champ d'étoiles a été ajouté dans le VIDE.\n- Un menu déroulant Son a été ajouté dans les paramètres. Contrôlez le volume du jeu, and activez ou désactivez les ambiances.\n- Deux boutons ont été ajoutés dans le menu déroulant Apparence (anciennement Plateau) dans les paramètres pour activer ou désactiver le champ d'étoiles ou les effets avancés du plateau.\n\n## Précoups\n\n- Les précoups on été ajoutés ! Bougez votre pièce quand c'est au tour de votre adversaire de jouer pour qu'elle soit automatiquement bougée dès que c'est de nouveau à vous de jouer (si le coup est légal).\n\n- Désactivez les précoups dans les paramètres.\n\n## Autres\n\n- De nouvelles variantes ont été ajoutées : les échecs sur un Plan Infini — Option Rose, les échecs sur un Plan Infini — Option Cavalier sauteur, et Palais. Ces variantes présentent la Rose (NOUVEAU), le Cavalier sauteur, et l'Amazone ! La pièce Rose se déplace comme un cavalier sauteur circulaire, tournant de 45 degrés après chaque saut. Aussi, la variante Knighted Chess a été supprimée.\n\n- Des notifications de lettres de nouvelle ont été ajoutées. Une bulle rouge apparaît à côté de l'hyperlien Actualités lorsque vous êtes connecté et que vous avez des lettres de nouvelles non lues. Les nouvelles lettres de nouvelles ont aussi l'étiquette « NOUVEAU » à côté de leur date.\n\n- Le problème de l'effet de réverbération étant souvent brusquement coupé a été réglé.\n"
  },
  {
    "path": "translation/news/fr-FR/2026-01-08.md",
    "content": "# Mise à jour 1.9 des Échecs Infinis !\n\n## Parties contre l'ordinateur\n\n- Entraînez-vous à n'importe quelle moment en jouant contre un fort ordinateur dans des variantes, cadences et difficultés différentes. Il a été créé par FirePlank. Le code source est [ici](https://github.com/FirePlank/infinite-chess-engine) !\n"
  },
  {
    "path": "translation/news/fr-FR/2026-03-09.md",
    "content": "# Mise à jour 1.10 des Échecs Infinis — Éditeur de position !\n\nL'éditeur de position, beaucoup demandé, est enfin arrivé !\n\n## Éditeur de position\n\n- Placez, bougez et effacez les pièces librement pour créer de nouvelles positions.\n\n- L'outil de sélection puissant vous permet de manipuler de grands groupes de pièces d'un coup, inspiré par les logiciels de tableur.\n\n- Les règles de jeu sont entièrement configurables.\n\n- Démarrez une partie locale ou contre l'ordinateur directement depuis le navigateur.\n\n- Enregistrez et chargez des positions dans votre navigateur ou dans le cloud (nécessite de s'authentifier).\n"
  },
  {
    "path": "translation/news/pl-PL/2024-01-29.md",
    "content": "Wyszedł nowy film!\n\n<iframe src=\"https://www.youtube.com/embed/b-Bb_TyhC1A?si=NmCm-RS21E61sA-O\" title=\"YouTube video: The Search for the Longest Infinite Chess Game\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" allowfullscreen=\"\"></iframe>\n"
  },
  {
    "path": "translation/news/pl-PL/2024-05-14.md",
    "content": "Aktualizacja 1.3! Zawiera DUŻO usprawnień w użytkowaniu. Między innymi:\n\n- Zmiana architektury sieciowej na websockets, zmniejszająca opóźnienie ruchu przeciwnika.\n- Nie będziesz już rozłączany gdy zmienisz kartę w przeglądarce.\n- Dodano powiadomienie dzwiękowe gdy ty lub ktoś inny stworzy zaproszenie albo wykona ruch.\n- Dodano zasadę 50 ruchów.\n- Od teraz, gdy na zegarze zostanie mniej niż 10 sekund, będzie grał efekt dzwiękowy.\n- Licznik automatycznego poddania się zacznie odliczać gdy twój przeciwnik będzie AFK (z dzwiękowym ostrzeżeniem).\n\nI wiele więcej! By zobaczyć pełną listę sprawdź [discorda](https://discord.com/channels/1114425729569017918/1114427288776364132/1240014519061712997)!\n"
  },
  {
    "path": "translation/news/pl-PL/2024-05-24.md",
    "content": "Aktualizacja 1.3.1! Dodano poradnik, popowiedzi po najechaniu na przyciski nawigacji, oraz linki do discorda i podziękowań na stronie tytułowej!\n"
  },
  {
    "path": "translation/news/pl-PL/2024-05-27.md",
    "content": "1.3.2: Dodano wariant showcase Omega^3 oraz showcase Omega^4, które zostały zaprezentowane w moim ostatnim filmie. Algorytm matowania działa teraz przy grach z więcej niż jednym królem po każdej ze stron.\n"
  },
  {
    "path": "translation/news/pl-PL/2024-07-09.md",
    "content": "Infinite Chess jest teraz Open Source! Zobacz i buduj projekt z nami na [GitHubie](https://github.com/Infinite-Chess/infinitechess.org)!\n\n<iframe src=\"https://www.youtube.com/embed/fSUEKosgyt0?si=L-blqfVEpPBmQLMn\" title=\"YouTube video player\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen=\"\"></iframe>\n"
  },
  {
    "path": "translation/news/pl-PL/2024-07-13.md",
    "content": "[Warunki korzystania z usługi](https://www.infinitechess.org/termsofservice) zostały zaktualizowane. Zmiany: Wszystkie gry, które zagrasz na stronie, a także twój ostatni czas logowania, zostanie informacją publiczną. Warunki mogą zostać zaktualizowane w dowolnym momencie, i że twoją odpowiedzialnością jest zapoznać się ze zmianami.\n\nW przyszłości twoja historia gier będzie widoczna na twoim profilu.\n"
  },
  {
    "path": "translation/news/pl-PL/2024-07-22.md",
    "content": "Jeśli twoje konto nie jest zweryfikowane, zrób to na swoim profilu! Niedługo wszystkie niezweryfikowane konta zostaną usunięte!!\n"
  },
  {
    "path": "translation/news/pl-PL/2024-08-01.md",
    "content": "Wyszła aktualizacja 1.4 została! Pojawiło się wiele nowych funkcji od kiedy projekt jest open source!\n\n- Jeździec został dodany, może on skakać w nieskończoność dopóki nic nie stanie mu na drodze! Wariant 'Knighted Chess' został ulepszony, skoczki zostały zastąpione jeźdzcami!\n- Kliknij figurę twojego przeciwnika by zobaczyć jej potencjalne ruchy!\n- Kliknij prawym przyciskiem myszy w dowolnym momencie by odznaczyć figurę.\n- Przytrzymaj kursor nad strzałką na krańcu ekranu, by zobaczyć wszystike możliwe ruchy figury, na którą ona wskazuje!\n- Od teraz gra automatycznie ogłasza remis gdy na planszy nie ma wystarczająco materiału by dać mata.\n- Przetłumaczono stronę na język francuski! Możesz zmienić język na dole strony.\n- Przyśpieszono ładowanie strony.\n- Nowe logo, Ω! Automatycznie dopasowuje się do trybu jasnego i ciemnego twojego urządzenia.\n- Kod gry, notacja ICN's, metadata zostały przeformatowane by bardziej przypominać notację PGN.\n- Od teraz użytkownicy mogą usunąć sowje konto za pomocą przycisku na swoim profilu. Nie musisz już pisać e-maila do nas.\n"
  },
  {
    "path": "translation/news/pl-PL/2024-09-11.md",
    "content": "Zapisy na pierwszy turniej Infinite Chess są otwarte!!! Formułą będzie wariant klasyczny i każdy z zawodników będzie miał na swoim zegarze 10 minut + 6 sekund za każdy ruch (zostanie to dodane niedługo). Zwycięzca otrzyma unikalną odznakę i/lub rolę na [serwerze discord](https://discord.gg/NFWFGZeNh5) naszej społeczności!\n\nTutaj znajdziesz [formularz zapisu](https://docs.google.com/forms/d/e/1FAIpQLScy5A3fDL_LduFuxy_qODx9hP1_aRip13SK37jH6ERjKWwu_w/viewform)! Koniec zapisów jest w **Piątek, 27-ego Września!** Pełne zasady znajdziesz [tutaj](https://docs.google.com/document/d/1lCc07bqYZwQbpSOkExZzY044TR5zNfyQT4IQZqqCinc/pub). By słyszeć o przyszłych aktualnościach odnośnie turnieju dołącz na [serwer discord](https://discord.gg/NFWFGZeNh5)!\n\n**Aktualizacja v.1.4.1 została wydana!**\n\n- W końcu możesz zaproponować remis swojemu przeciwnikowi! Przycisk propozycji remisu znajdziesz w menu pauzy!\n- Dodano lokalizację dla języków: Chińskiego, Polskiego (!) oraz Portugalskiego!\n- Naprawiono błąd gdy podczas wielokrotnego klikania przycisku utworzenia zaproszenia użytkownik otrzymywał wiadomości takie jak: masz już aktywne zaproszenie, bądź nie możesz zaakceptować swojego zaproszenia.\n"
  },
  {
    "path": "translation/news/pt-BR/2024-01-29.md",
    "content": "Novo vídeo lançado hoje!\n\n<iframe src=\"https://www.youtube.com/embed/b-Bb_TyhC1A?si=NmCm-RS21E61sA-O\" title=\"YouTube video: The Search for the Longest Infinite Chess Game\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" allowfullscreen=\"\"></iframe>\n"
  },
  {
    "path": "translation/news/pt-BR/2024-05-14.md",
    "content": "A atualização 1.3 foi lançada hoje! Ela inclui MUITAS novas melhorias na velocidade e na experiência do usuário. Apenas algumas delas são:\n\n- A transição para websockets, diminuindo o atraso quando seu oponente se move.\n- Você não é mais desconectado ao alternar entre guias.\n- Avisos sonoros quando você ou outra pessoa cria um convite ou faz um lance.\n- Adicionado a regra dos 50 Lances.\n- Um efeito sonoro de bateria agora é reproduzido quando há 10 segundos restantes no relógio.\n- Um cronômetro de desistência automática começará se seu oponente ficar ausente (com um aviso sonoro).\n\nE muitos outros! Para ver a lista completa, confira [o discord](https://discord.com/channels/1114425729569017918/1114427288776364132/1240014519061712997)!\n"
  },
  {
    "path": "translation/news/pt-BR/2024-05-24.md",
    "content": "Atualização 1.3.1 lançada! Ela inclui o guia, dicas de ferramentas pop-up ao passar o mouse sobre os botões de navegação e links para o discord e os créditos do jogo na página de título!\n"
  },
  {
    "path": "translation/news/pt-BR/2024-05-27.md",
    "content": "1.3.2: Adicionadas variantes de mostruário para Ômega^3 e Ômega^4 que foram mostradas no meu último vídeo. Além disso, o algoritmo de xeque-mate agora é compatível com vários reis para cada lado.\n"
  },
  {
    "path": "translation/news/pt-BR/2024-07-09.md",
    "content": "O Infinite Chess agora é de código aberto! Veja e contribua com o projeto [no GitHub](https://github.com/Infinite-Chess/infinitechess.org)!\n\n<iframe src=\"https://www.youtube.com/embed/fSUEKosgyt0?si=L-blqfVEpPBmQLMn\" title=\"YouTube video player\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen=\"\"></iframe>\n"
  },
  {
    "path": "translation/news/pt-BR/2024-07-13.md",
    "content": "O [Termos de Serviço](https://www.infinitechess.org/termsofservice) foram atualizados. Alterações feitas: Todos os jogos que você joga no site podem se tornar informações públicas, inclusive o horário aproximado em que sua conta esteve ativa pela última vez. Os termos podem ser atualizados a qualquer momento, e é sua responsabilidade certificar-se de que está em dia com eles.\n\nSeu histórico de jogos poderá ficar disponível em seu perfil em um momento futuro.\n"
  },
  {
    "path": "translation/news/pt-BR/2024-07-22.md",
    "content": "Se você ainda não verificou sua conta, faça isso em sua página de perfil! Todas as contas não verificadas serão excluídas em breve!\n"
  },
  {
    "path": "translation/news/pt-BR/2024-08-01.md",
    "content": "A atualização 1.4 foi lançada! Foram adicionados muitos recursos de colaboração desde que abrimos o código-fonte!\n\n- Knightriders foram adicionados, que saltam infinitamente como um cavalo até serem obstruídos! A variante 'Knighted Chess' foi atualizada para substituir os cavalos por knightriders!\n- Clique em suas peças ou nas do adversário a qualquer momento para ver os movimentos possíveis!\n- Clique com o botão direito do mouse a qualquer momento para desmarcar a peça selecionada.\n- Passar o mouse sobre os indicadores de seta na borda da tela agora renderiza os movimentos legais da peça que elas estão apontando!\n- O jogo agora declara automaticamente um empate se não houver material suficiente no tabuleiro para forçar o xeque-mate.\n- Traduzimos o site para o Francês! Você pode alterar o idioma acessando o rodapé de qualquer página.\n- Melhoria no tempo de carregamento do site.\n- Novo ícone do site, Ω! Ele corresponde automaticamente ao tema de seu dispositivo claro ou escuro preferido.\n- Os metadados do código do jogo, ou do ICN, foram reformatados para se aproximarem mais das normas do PGN.\n- Os usuários agora podem excluir suas contas na página de perfil, se assim desejarem, sem precisar nos enviar um e-mail.\n"
  },
  {
    "path": "translation/news/pt-BR/2024-09-11.md",
    "content": "O primeiro torneio de Infinite Chess já está aberto para inscrições!!! Ele será jogado na variante Classical e o controle de tempo será de 10m+6s (isso será adicionado em breve). O vencedor receberá um sinalizador especial e/ou um cargo no [discord](https://discord.gg/NFWFGZeNh5)!\n\nAqui está o [formulário de inscrição](https://docs.google.com/forms/d/e/1FAIpQLScy5A3fDL_LduFuxy_qODx9hP1_aRip13SK37jH6ERjKWwu_w/viewform)! O prazo para se inscrever é **sexta-feira, 27 de setembro!** As regras completas estão localizadas [aqui](https://docs.google.com/document/d/1lCc07bqYZwQbpSOkExZzY044TR5zNfyQT4IQZqqCinc/pub). Para futuras atualizações sobre o torneio, participe do [discord](https://discord.gg/NFWFGZeNh5)!\n\n**A atualização v.1.4.1 foi lançada!**\n\n- Ofertas de empate foram adicionadas! Encontre o botão de oferecer empates no menu de pausa!\n- Adicionados os seguintes idiomas: Chinês, Polonês e Português!\n- Foi corrigido o erro que ao \"spammar\" o botão Criar convite gerasse mensagens como “você já tem um convite” ou “você não pode aceitar seu próprio convite”.\n"
  },
  {
    "path": "translation/news/zh-CN/2024-01-29.md",
    "content": "仅以案由一个新视频\n\n<iframe src=\"https://www.youtube.com/embed/b-Bb_TyhC1A?si=NmCm-RS21E61sA-O\" title=\"YouTube video: The Search for the Longest Infinite Chess Game\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" allowfullscreen=\"\"></iframe>\n"
  },
  {
    "path": "translation/news/zh-CN/2024-05-14.md",
    "content": "1.3更新今天发布！这包括很多的改进：\n\n- 切换到 WebSocket，减少对手移动时的延迟。\n- 切换标签不再断开连接。\n- 当您或其他人创建邀请或下棋时，发出声音提示。\n- 加了50步规则。\n- 现在在时钟剩余10秒时播放鼓式倒计时效果。\n- 如果您的对手AFK（并发出声音警告），将开始自动认输计时器。\n\n还有更多！那些都在我们的 [Discord](https://discord.com/channels/1114425729569017918/1114427288776364132/1240014519061712997)！\n"
  },
  {
    "path": "translation/news/zh-CN/2024-05-24.md",
    "content": "1.3更新今天发布！这包括指南、悬停导航按钮时的弹出式工具提示以及标题页上的 Discord 和游戏信用链接！\n"
  },
  {
    "path": "translation/news/zh-CN/2024-05-27.md",
    "content": "1.3.2更新今天发布！新增了我在最新视频中展示的 Omega^3 和 Omega^4 的展示变体。此外，将军算法现在兼容每边多个国王。\n"
  },
  {
    "path": "translation/news/zh-CN/2024-07-09.md",
    "content": "无限棋现在开源！查看并贡献项目，请访问 [GitHub](https://github.com/Infinite-Chess/infinitechess.org)！\n\n<iframe src=\"https://www.youtube.com/embed/fSUEKosgyt0?si=L-blqfVEpPBmQLMn\" title=\"YouTube video player\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen=\"\"></iframe>\n"
  },
  {
    "path": "translation/news/zh-CN/2024-07-13.md",
    "content": "[服务条款](https://www.infinitechess.org/termsofservice)已更新。变更内容：您在网站上玩的所有游戏都可能成为公共信息，包括您的帐户上次活跃的大概时间。条款可能会随时更新，确保您了解最新条款是您的责任。\n\n您的游戏历史可能在未来可在您的个人资料中查看。\n"
  },
  {
    "path": "translation/news/zh-CN/2024-07-22.md",
    "content": "如果您尚未验证您的账户，请在您的个人资料页面上进行验证！所有未验证的账户将很快被删除！\n"
  },
  {
    "path": "translation/news/zh-CN/2024-08-01.md",
    "content": "更新 1.4 发布了！自从我们开源以来，添加了许多协作功能！\n\n- 新增了骑士骑行者，它们像骑士一样无限跳跃，直到被障碍物阻挡！'骑士国际象棋' 变体已经升级，将骑士替换为骑士骑行者！\n- 随时点击自己或对手的棋子以查看其可能的移动！\n- 随时右键点击以取消选择当前选中的棋子。\n- 将鼠标悬停在屏幕边缘的箭头指示器上，现在会显示指示的棋子的合法移动！\n- 如果棋盘上没有足够的棋子来强制将死，游戏现在会自动判定平局。\n- 网站已经翻译成法语！您可以通过访问任何页面的页脚来更改语言。\n- 改善了网站的加载时间。\n- 新的网站图标，Ω！它会自动匹配您首选的浅色或深色设备主题。\n- 游戏代码或 ICN 的元数据已重新格式化，更加符合 PGN 标准。\n- 用户现在可以在个人资料页面删除他们的帐户，无需通过电子邮件联系我们。\n"
  },
  {
    "path": "translation/news/zh-CN/2024-09-11.md",
    "content": "首届无限棋锦标赛现已开放报名！！！比赛将采用经典变体，时间控制为10分钟加6秒（此功能将很快添加）。获胜者将在 [社区Discord](https://discord.gg/NFWFGZeNh5) 上获得特殊标识和/或角色！\n\n这是 [报名表格](https://docs.google.com/forms/d/e/1FAIpQLScy5A3fDL_LduFuxy_qODx9hP1_aRip13SK37jH6ERjKWwu_w/viewform)！报名截止日期是 **2024年9月27日（星期五）！** 完整规则请查看 [这里](https://docs.google.com/document/d/1lCc07bqYZwQbpSOkExZzY044TR5zNfyQT4IQZqqCinc/pub)。有关锦标赛的最新动态，请加入 [Discord](https://discord.gg/NFWFGZeNh5)！\n\n**更新 v.1.4.1 已发布！**\n\n- 已添加和棋提议功能！在暂停菜单中找到提议和棋按钮！\n- 已添加以下语言支持：中文、波兰语、葡萄牙语！\n- 修复了一个错误：重复点击创建邀请按钮时会收到如您已拥有邀请或无法接受自己的邀请等消息。\n"
  },
  {
    "path": "translation/news/zh-TW/2024-01-29.md",
    "content": "僅以案由一個新視頻\n\n<iframe src=\"https://www.youtube.com/embed/b-Bb_TyhC1A?si=NmCm-RS21E61sA-O\" title=\"YouTube video: The Search for the Longest Infinite Chess Game\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" allowfullscreen=\"\"></iframe>\n"
  },
  {
    "path": "translation/news/zh-TW/2024-05-14.md",
    "content": "1.3更新今天發布！這包括很多的改進：\n\n- 切換到 WebSocket，減少對手移動時的延遲。\n- 切換標簽不再斷開連接。\n- 當您或其他人創建邀請或下棋時，發出聲音提示。\n- 加了50步規則。\n- 現在在時鐘剩余10秒時播放鼓式倒計時效果。\n- 如果您的對手AFK（並發出聲音警告），將開始自動認輸計時器。\n\n還有更多！那些都在我們的 [Discord](https://discord.com/channels/1114425729569017918/1114427288776364132/1240014519061712997)！\n"
  },
  {
    "path": "translation/news/zh-TW/2024-05-24.md",
    "content": "1.3更新今天發布！這包括指南、懸停導航按鈕時的彈出式工具提示以及標題頁上的 Discord 和游戲信用鏈接！\n"
  },
  {
    "path": "translation/news/zh-TW/2024-05-27.md",
    "content": "1.3.2更新今天發布！新增了我在最新視頻中展示的 Omega^3 和 Omega^4 的展示變體。此外，將軍算法現在兼容每邊多個國王。\n"
  },
  {
    "path": "translation/news/zh-TW/2024-07-09.md",
    "content": "無限棋現在開源！查看並貢獻項目，請訪問 [GitHub](https://github.com/Infinite-Chess/infinitechess.org)！\n\n<iframe src=\"https://www.youtube.com/embed/fSUEKosgyt0?si=L-blqfVEpPBmQLMn\" title=\"YouTube video player\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen=\"\"></iframe>\n"
  },
  {
    "path": "translation/news/zh-TW/2024-07-13.md",
    "content": "[服務條款](https://www.infinitechess.org/termsofservice)已更新。變更內容：您在網站上玩的所有游戲都可能成為公共信息，包括您的帳戶上次活躍的大概時間。條款可能會隨時更新，確保您了解最新條款是您的責任。\n\n您的游戲歷史可能在未來可在您的個人資料中查看。\n"
  },
  {
    "path": "translation/news/zh-TW/2024-07-22.md",
    "content": "如果您尚未驗証您的賬戶，請在您的個人資料頁面上進行驗証！所有未驗証的賬戶將很快被刪除！\n"
  },
  {
    "path": "translation/news/zh-TW/2024-08-01.md",
    "content": "更新 1.4 發布了！自從我們開源以來，添加了許多協作功能！\n\n- 新增了騎士騎行者，它們像騎士一樣無限跳躍，直到被障礙物阻擋！'騎士國際象棋' 變體已經升級，將騎士替換為騎士騎行者！\n- 隨時點擊自己或對手的棋子以查看其可能的移動！\n- 隨時右鍵點擊以取消選擇當前選中的棋子。\n- 將鼠標懸停在屏幕邊緣的箭頭指示器上，現在會顯示指示的棋子的合法移動！\n- 如果棋盤上沒有足夠的棋子來強制將死，游戲現在會自動判定平局。\n- 網站已經翻譯成法語！您可以通過訪問任何頁面的頁腳來更改語言。\n- 改善了網站的加載時間。\n- 新的網站圖標，Ω！它會自動匹配您首選的淺色或深色設備主題。\n- 遊戲代碼或 ICN 的元數據已重新格式化，更加符合 PGN 標準。\n- 用戶現在可以在個人資料頁面刪除他們的帳戶，無需通過電子郵件聯系我們。\n"
  },
  {
    "path": "translation/news/zh-TW/2024-09-11.md",
    "content": "首屆無限棋錦標賽現已開放報名！！！比賽將採用經典變體，時間控制為10分鐘加6秒（此功能將很快添加）。獲勝者將在 [社區Discord](https://discord.gg/NFWFGZeNh5) 上獲得特殊標識和/或角色！\n\n這是 [報名表格](https://docs.google.com/forms/d/e/1FAIpQLScy5A3fDL_LduFuxy_qODx9hP1_aRip13SK37jH6ERjKWwu_w/viewform)！報名截止日期是 **2024年9月27日（星期五）！** 完整規則請查看 [這裡](https://docs.google.com/document/d/1lCc07bqYZwQbpSOkExZzY044TR5zNfyQT4IQZqqCinc/pub)。有關錦標賽的最新動態，請加入 [Discord](https://discord.gg/NFWFGZeNh5)！\n\n**更新 v.1.4.1 已發布！**\n\n- 已添加和棋提議功能！在暫停菜單中找到提議和棋按鈕！\n- 已添加以下語言支持：中文、波蘭語、葡萄牙語！\n- 修復了一個錯誤：重復點擊創建邀請按鈕時會收到如您已擁有邀請或無法接受自己的邀請等消息。\n"
  },
  {
    "path": "translation/pl-PL.toml",
    "content": "name = \"Polski\" # Polish - Name of language\nenglish_name = \"Polish\"\ndirection = \"ltr\" # Change to \"rtl\" for right to left languages\nversion = \"22\"\nmaintainer = \"Apsurt\"\n\n[header]\nhome = \"Infinite Chess\"\nplay = \"Graj\"\nnews = \"Aktualności\"\nlogin = \"Zaloguj się\"\nprofile = \"Profil\"\ncreateaccount = \"Utwórz konto\"\nlogout = \"Wyloguj się\"\n\n[header.settings]\nlanguage = \"Język\"\nboard = \"Motywy\" # Board color/theme\nlegalmoves = \"Wskaźnik ruchów\" # Legal moves shape\nlegalmoves-squares = \"Pola\"\nlegalmoves-dots = \"Kropki\" # Dots and 4 corner triangles\nperspective = \"Widok 3D\" # Perspective-mode\nperspective-mouse-sensitivity = \"Czułość myszki\"\nperspective-fov = \"Pole widzenia\"\nping = [\"Ping\", \"ms\"] # A number is inserted between these 2 strings.\nreset-to-default = \"Domyślne\"\n\n[footer]\ncontact = \"Kontakt\"\nterms_of_service = \"Warunki korzystania z usługi\"\nsource_code = \"Kod źródłowy\"\nlanguage = \"Język\"\n\n[member.javascript]\njs-confirm_delete = \"Czy napewno chcesz usunąć swoje konto? Usunięcie konta jest NIEODWRACALNE! Naciśnij OK, aby wpisać hasło\"\njs-enter_password = \"Wpisz swoje hasło, aby usunąć konto NA ZAWSZE\"\n\n[index]\ntitle = \"Infinite Chess | Strona Główna - Oficjalna Strona\"\nsecondary_title = \"Oficjalna strona do gry online!\"\nwhat_is_it_title = \"Jak to działa?\"\nwhat_is_it_pargaraphs = [\n\"Infinite Chess (Nieskończone Szachy) to wariant gry, w którym plansza nie ma granic i jest znacznie większa niż typowa plansza 8 na 8. Hetman, wieże, oraz gońce mogą poruszać się <em>bez limitu</em> odległości w jednym ruchu. Możesz wybrac dowolną liczbę pól aż po nieskończoność!\",\n\"Bez ograniczeń co do tego, jak daleko można się poruszać, pojawia się możliwość, że mat nastąpi w <strong>omega ω</strong> ruchach, co oznacza najmniejszą nieskończoną liczbę porządkową. Zostało udowodnione, że mat może nastąpić po <strong>dowolnej</strong> liczbie porządkowej ruchów!\",\n\"Łatwo się domyślić, że istnieje nieskończona liczba możliwości dla otwarć szachowych, z których, wiele, jest w stanie zapewnić szybką przewagę w partii! Finalnie, cel jest then sam, dać mata przeciwnemu królowi. Ten warinat wymaga jednak nowych strategii, zważywszy na to, że plansza nie ma ścian, które mogą pomóc w złapaniu króla w pułapkę. Partie zazwyczaj trwają tyle co w oryginalnych szachach. Promocja pionów, również zachodzi na wierszach 1. oraz 8.\",\n]\nhow_to_title = \"Jak zagrać?\"\nhow_to_paragraph = [\"Obecna wersja 1.10 jest dostępna na stronie \",\"Graj\",\"!\"]\nabout_title = \"O projekcie\"\nabout_paragraphs = [\n\"Jestem Naviary. Gdy odkryłem Nieskończone Szachy (koncept istniał długo przed tą stroną), byłem bardzo zaintrygowany jego możliwościami. Granie w tą wersję było bardzo trudne, gdyż wymagało od graczy na chess.com wysyłania zdjęć planszy po każdym zagranym ruchu. Ze względu na to, niewiele osób było w stanie zagrać w Nieskończone Szachy, a sam wariant gry nie był popularny.\",\n[\"Moim celem było stworzenie sposobu aby Nieskończone Szachy były łatwo grywalne dla każdego, a także stworzenie społeczności. Spędziłem niezliczone godziny nad tą stroną, aktualizowaniem i tworzeniem gry. Mam wiele innych pomysłów, które będą zajmować sporo mojego czasu. O ile chciałbym, żeby ta gra była darmowa, nic w życiu nie jest za darmo, aby wesprzeć mnie finansowo rozważ dołączenie na mój \", \"Patreon\", \".\"] # Patreon receives a hyperlink, here\n]\npatreon_title = \"Wspierający\"\n\n[credits]\ntitle = \"Podziękowania\"\ncopyright = \"Wszystko co jest na stronie (wyłączając poniżej wypisane warianty), jest własnością www.InfiniteChess.org\"\nvariants_heading = \"Warianty\"\nvariants_credits = [\n\"Core stworzony przez Andreas Tsevas.\",\n\"Space stworzony przez Andreas Tsevas.\",\n\"Space Classic stworzony przez Andreas Tsevas.\",\n\"Coaip (Chess on an Infinite Plane) stworzony przez V. Reinhart.\",\n\"Pawn Horde stworzony przez Inaccessible Cardinal.\",\n\"Abundance stworzony przez Clicktuck Suskriberz.\",\n\"Pawndard przez SexyLexi.\",\n\"Classical+ przez SexyLexi.\",\n\"Knightline przez Inaccessible Cardinal.\",\n\"Knighted Chess przez cycy98.\",\n\"stworzony przez Cory Evans and Joel Hamkins.\",\n\"stworzony przez Andreas Tsevas.\",\n\"stworzony przez Cory Evans i Joel Hamkins.\",\n\"stworzony przez Cory Evans, Joel Hamkins, i Norman Lewis Perlmutter.\",\n]\ntextures_heading = \"Tekstury\"\ntextures_licensed_under = \"tekstury na licencji\"\ntextures_credits = [\n\"Gold coin przez Quolte.\",\n]\nsounds_heading = \"Dźwięki\"\nsounds_credits = [\n[\"Niektóre dźwięki pochodzą z projektu\", \"pod licencją\"],\n\"Inne efekty dźwiękowe stworzone przez Naviary.\",\n]\ncode_heading = \"Kod\"\ncode_credits = [\n\"- Brandon Jones i Colin MacKenzie IV.\",\n\"- Andreas Tsevas i Naviary.\",\n]\nlanguage_heading = \"Tłumaczenia\"\nlanguage_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded.\n\t\"Francuski - \", \"Life Enjoyer\", \" and \", \"cycy98\", \".\",\n\t\"Chiński uproszczony - \", \"Heinrich Xiao\", \".\",\n\t\"Chiński tradycyjny - \", \"Heinrich Xiao\", \".\",\n\t\"Polski - \", \"Tymon Becella\", \".\", # Apsurt\n\t\"Portugalski - \", \"Emerson P. Machado\", \".\", # The_Skeleton on discord\n\t\"Hiszpański - \", \"xa31er\", \".\"\n]\n\n[member]\ntitle = \"Twój Profil\"\nverify_message = \"Sprawdź swojego e-mail'a, aby potwierdzić rejestrację. Konto, które nie zostanie zwerifikowane w ciągu 3 dni, zostanie usunięte.\"\nresend_message = [\"Nie widzisz wiadomości? Sprawdź folder spamu. Możesz również \", \"wyślij ponownie.\", \" Jeśli dalej nie widzisz wiadomości, \", \"napisz do nas.\"]\nverify_confirm = \"Dziękujemy! Twoje konto zostało zarejestrowane pomyślnie.\"\nrating = \"Ranking ELO:\"\njoined = \"Dołączył:\"\nseen = [\"Ostatnio widziany:\", \" temu\"]\nreveal_info = \"Pokaż informacje o koncie\"\naccount_info_heading = \"Informacje o koncie\"\nemail = \"Adres e-mail:\"\ndelete_account = \"Usuń konto\"\npassword_reset_message = [\"Aby zmienić nazwę użytkownika, e-mail lub hasło, \", \"napisz do nas.\"]\n\n[create-account]\ntitle = \"Utwórz konto\"\nusername = \"Nazwa użytkownika:\"\nemail = \"E-mail:\"\npassword = \"Hasło:\"\ncreate_button = \"Zarejestruj się\"\nagreement = [\"Zgadzam się na \", \"Warunki korzystania z usługi\"]\n\n[create-account.javascript]\njs-username_specs = \"Nazwa użytkownika musi posiadać przynajmniej 3 znaki oraz zawierać tylko litery A-Z i cyfry 0-9\"\njs-username_tooshort = \"Nazwa użytkownika musi poisadać przynajmniej 3 znaki\"\njs-username_wrongenc = \"Nazwa użytkownia może zaweirać tylko litery oraz cyfry 0-9\"\njs-email_invalid = \"Adres e-mail jest nieprawidłowy\"\njs-email_inuse = \"Adres e-mail jest już używany przez inne konto.\"\njs-pwd_incorrect_format = \"Hasło ma nieprawidłowy format\"\njs-pwd_too_short = \"Hasło musi posiadać przynajmniej 6 znaków\"\njs-pwd_too_long = \"Hasło nie może być dłuższe niż 72 znaki\"\njs-pwd_not_pwd = \"Hasło nie może być 'password'\"\n\n[play]\ntitle = \"Infinite Chess - Graj\"\nloading = \"ŁADOWANIE\"\nerror = \"BŁĄD\"\n\n[play.main-menu]\ncredits = \"Podziękowania\"\nplay = \"Graj\"\nguide = \"Poradnik\"\neditor = \"Edytor planszy\"\n\n[play.guide]\ntitle = \"Poradnik\"\nrules = \"Zasady\"\nrules_paragraphs = [\n\"Zasady Nieskończonych Szachów są niemal identyczne do zwykłych szachów, z jednym wyjątkiem - plansza ma nieskończony rozmiar! To są jedyne zmiany, o których musisz wiedzieć:\",\n\"Figury, takie jak wieże, gońce, i hetman nie mają ograniczeń co do odległości ruchu. Tak długo jak nie mają przeszkód na drodze, możesz poruszyć się nawet o milion pól i więcej!\",\n[\"W \\\"Klasycznym\\\" wariancie, białe pionki awansują na wierszu 8, a czarne pionki na wierszu 1. Na zdjęciu, jest to wskazane przez cienkie czarne linie. Pionki muszą jedynie stanąć naprzeciwko linii\", \"nie\", \" jest konieczne jej przekroczenie.\"],\n\"Notacja pól nie jest już opisana jako litera i cyfra (np. a1), ale jako koordynaty x i y. Na przykład, pole a1 ma teraz notację (1,1), a h8 notację (8,8). Na komputerach, koordynaty nad którymi znajduję się kursor znajdują się w górnej części ekranu.\",\n\"Pozostałe zasady klasycznych szachów, takie jak: szach-mat, pat, trzykrotne powtórzenie pozycji, zasada 50 ruchów, roszada, en passant, oraz inne, pozostają takie same!\"\n]\ncareful_heading = \"Uważaj!\"\ncareful_paragraphs = [\n\"Przestronność nieskończonej planszy oznacza, że łatwo wpaść w pułapki takie jak: podwójne bicie czy przypięcie. Tyły twojej armii są teraz bardziej podatne an ataki przeciwnika. Uważaj na te startegie! Myśl nieszablonowo w trakcie formowania obrony dla twojego króla i wież! Otwarcia mogą się znacząco róznić od tych znanych z klasycznych szachów.\",\n\"Wiele innych wariantów zostało stowrzoych by zbalansować słaby punkt twojej armii, czyli tyły. Sprawdź je!\"\n]\ncontrols_heading = \"Sterowanie\"\ncontrols_paragraph = \"Kliknij i przeciągnij planszę by poruszyć kamerę. Użyj scrolla by oddalić i przybliżyć. Klkinij dowolną figurę (również przeciwnika) by zobaczyć legalne ruchy. Dodatkowe klawisze sterowania:\"\nkeybinds = [\n\" aby się poruszać.\",\n[\"Spacja\", \" i \", \"Shift\", \" aby przybliżać i oddalać.\"],\n[\"Escape\", \" aby zatrzymać grę.\"],\n[\"Tab\", \" przełącza wskaźnik strzałek na granicy twojego ekranu, wskazujące położenie twoich figur poza ekranem. Domyślnie, opcja ta jest ustawiona na tryb \\\"Obrona\\\", który pokazuje strzałki dla figur, które mogą się bezpośrednio poruszyć do twojej lokalizacji. Klawisz \", \"tab\", \" może przełączyć tą opcję na tryby \\\"Wszystko\\\" lub \\\"Wyłączone\\\", gdzie tryb \\\"Wszystko\\\" pokazuje wszystkie figury, które mogą się poruszać prostopadle lub przekątnie po planszy. Opcja ta, może być również przełączona w menu pauzy.\"],\n\" przełączy \\\"Tryb Edycji\\\" w grach lokalnych. Pozwoli to na poruszanie twoich pionków w dowolne miejsce na planszy! Bardzo użyteczne podczas analizy gry.\"\n]\ncontrols_paragraph2 = \"To są najpotrzebniejsze klawisze sterowania jakie musisz znać, ale są też dodatkowe jeśli będziesz ich kiedykolwiek potrzebować!\"\nkeybinds_extra = [\n\" zresetuje wyświetlanie figur. Przydatne gdy figury znikną. Ten błąd może przytrafić się na dalekich dystansach (np. 1e21).\",\n\" przełączy menu nawigacji i statystyk gry. Przydatne przy nagrywaniu. Nagrywanie oraz transmisje na żywo z gry są mile widziane!\",\n\" przełączy wskaźnik klatek na sekundę. Wyświetla on ile razy na sekundę gra się odświeża, niekoniecznie jak często gra wyświetla nowy obraz, gdyż gra czasami pomija wyświtlanie nowego obrazu gdy nic się nie zmienia, by oszcządzać zasoby.\",\n\" przełączy wyświetlanie ikon. Są to miniatury figur gdy oddalisz wystarczająco daleko, możesz je kliknąć. W grach z ponad 50,000 figur jest to automatycznie wyłączone, gdyż często powoduje to spadek wydajności, natomiast można je włączyć z powrotem za pomocą \",\n[\" (backtick, bądź ten sam klawisz co \", \") przełączy tryb debugowania.\"],\n]\nfairy_heading = \"Alternatywne Figury\"\nfairy_paragraph = \"Wiesz już wszytko co muszisz wiedzieć by móc grać podstawowy wariant \\\"Klasyczny\\\". Alternatywne figury nie są używane w podstawowych szachach, lecz są używane w innych wariantach! Gdy natrafisz na figurę, której nigdy nie widziałeś i nie wiesz jak działa, spójrz tutaj by się dowiedzieć!\"\nediting_heading = \"Edytowanie planszy\"\nediting_paragraphs = [\n[\"Istnieje zewnętrzny \", \"edytor planszy\", \" aktuanie dostępny na publicznym arkuszu google! zawiera instrukcje jak go używać. Wymaga podstawowej znajomości arkusza google. Po ustawieniu, będziesz w stanie tworzyć i importować niestandardowe pozycje do gry za pomocą przycisku \\\"Wklej Grę\\\" w menu opcji!\"],\n\"By zagrać niestandardową pozycję ze znajomym, zaproś go do prywatnej gry, a następnie obydwoje wklejcie grę przed rozpoczęciem rozgrywki!\",\n\"Wewnętrzny edytor jest na etapie planowania.\",\n]\nback = \"Powrót\"\n\n[play.guide.pieces]\nchancellor = {name=\"Kanclerz\", description=\"Porusza się jak połączenie wieży oraz skoczka.\"}\narchbishop = {name=\"Arcybiskup\", description=\"Porusza się jak połączenie gońca oraz skoczka.\"}\namazon = {name=\"Amazonka\", description=\"Porusza się jak połączenie hetmana oraz skoczka. Najsilniejsza figura w grze!\"}\nguard = {name=\"Strażnik\", description=\"Porusza się jak król, lecz nie można go szachować ani matować.\"}\nhawk = {name=\"Jastrząb\", description=\"Skacze dokładnie 2 bądź 3 pola w dowolnym kierunku.\"}\ncentaur = {name=\"Centaur\", description=\"Porusza się jak połączenie skoczka oraz strażnika.\"}\nknightrider = {name=\"Jeździec\", description=\"Skacze jak skoczek nieskończenie w jednym kierunku, dopóki nic nie stanie mu na drodze.\"}\nobstacle = {name=\"Blokada\", description=\"Neutralna figura (niekantrolowana przez żadnego z graczy), która blokuje ruch, lecz może być zbita.\"}\nvoid = {name=\"Pustka\", description=\"Neutralna figura (niekantrolowana przez żadnego z graczy), która reprezentuje \\\"dziurę\\\" w planszy. Figury nie mogą na niej stawać, ani się przez nią poruszać.\"}\n\n[play.play-menu]\ntitle = \"Graj - Online\"\ncolors = \"Kolor\"\nonline = \"Online\"\nlocal = \"Lokalnie\"\ncomputer = \"Komputer\"\nvariant = \"Wariant\"\nClassical = \"Klasyczny\"\nClassical_Plus = \"Klasyczny+\"\nCoaIP = \"Chess on an Infinite Plane\"\nPawndard = \"Pawndard\"\nKnighted_Chess = \"Knighted Chess\"\nKnightline = \"Knightline\"\nCore = \"Core\"\nStandarch = \"Standarch\"\nPawn_Horde = \"Pawn Horde\"\nSpace_Classic = \"Space Classic\"\nSpace = \"Space\"\nObstocean = \"Obstocean\"\nAbundance = \"Abundance\"\nAmazon_Chandelier = \"Amazon Chandelier\"\nContainment = \"Containment\"\nClassical_Limit_7 = \"Classical - Limit 7\"\nCoaIP_Limit_7 = \"Coaip - Limit 7\"\nChess = \"Chess\"\nClassical_KOTH = \"Experimental: Classical - KOTH\"\nCoaIP_KOTH = \"Experimental: Coaip - KOTH\"\nOmega = \"Showcase: Omega\"\nOmega_Squared = \"Showcase: Omega^2\"\nOmega_Cubed = \"Showcase: Omega^3\"\nOmega_Fourth = \"Showcase: Omega^4\"\nno_clock = \"Bez czsu\"\nclock = \"Czas\"\nminutes = \"m\"\nseconds = \"s\"\ninfinite_time = \"Nieskończony czas\"\ncolor = \"Kolor\"\npiece_colors = [\"Losowy\", \"Biały\", \"Czarny\"]\nprivate = \"Prywatny\"\nno = \"Nie\"\nyes = \"Tak\"\nrated = \"Rankingowy\"\ncasual = \"Nierankingowy\"\njoin_games = \"Dołącz do istniejącej - Trwające gry:\"\nprivate_invite = \"Prywatne zaproszenie:\"\nyour_invite = \"Twój kod zaproszenia:\"\ncreate_invite = \"Stwórz zaproszenie\"\njoin = \"Dołącz\"\ncopy = \"Skopiuj\"\nback = \"Powrót\"\ncode = \"Kod\"\n\n[play.gamebuttontooltips]\nundo_transition = \"Cofnij\"\nexpand_fit_all = \"Oddal by zobaczyć wszystko\"\nrecenter = \"Wyśrodkuj\"\nrewind_move = \"Cofnij\"\nforward_move = \"Następny\"\npause = \"Pauza\"\n\n[play.footer]\nwhite_to_move = \"Ruch białego\"\nplayer_white = \"Gracz Biały\"\nplayer_black = \"Gracz Czarny\"\n\n[play.pause]\ntitle = \"Pauza\"\nresume = \"Wznów\"\narrows = \"Strzałki: Obrona\"\nperspective = \"Perspektywa: Wyłączona\"\ncopy = \"Skopiuj Grę\"\npaste = \"Wklej Grę\"\noffer_draw = \"Remis\"\nmain_menu = \"Strona Główna\"\n\n[play.drawoffer]\nquestion = \"Akceptujesz remis?\"\n\n[play.javascript]\nguest_indicator = \"(Gość)\"\nyou_indicator = \"(Ty)\"\nwhite_to_move = \"Ruch białego\"\nblack_to_move = \"Ruch czarnego\"\nyour_move = \"Twój ruch\"\ntheir_move = \"Ruch przeciwnika\"\nlost_network = \"Utracenie połączenia.\"\nfailed_to_load = \"Nie udało się załadować jednego bądź więcej zasobów. Proszę odświeżyć.\"\nplanned_feature = \"W planach!\"\nmain_menu = \"Strona Główna\"\nresign_game = \"Poddaj się\"\nabort_game = \"Porzuć grę\"\noffer_draw = \"Remis\"\naccept_draw = \"Zaakceptuj remis\"\narrows_off = \"Strzałki: Wyłączone\"\narrows_defense = \"Strzałki: Obrona\"\narrows_all = \"Strzałki: Wszystkie\"\ntoggled = \"Przełączono\"\nmenu_online = \"Graj - Online\"\nmenu_local = \"Graj - Lokalnie\"\ninvite_error_digits = \"Kod zaproszenia musi składać się z 5 cyfr.\"\ninvite_copied = \"Skopiowano kod zaproszenia do schowka.\"\nmove_counter = \"Ruch:\"\nconstructing_mesh = \"Tworzenie siatki\"\nrotating_mesh = \"Obracanie siatki\"\nlost_connection = \"Utracono połączenie.\"\nplease_wait = \"Zaczekaj chwilę.\"\nwebgl_unsupported = \"Twoja przeglądarka nie wspiera WebGL. Ta gra potrzebuje tego do funkcjonowania. Zaktualizuj swoją przeglądarkę.\"\nbigints_unsupported = \"BigInts nie są wspierane. Zaktualizuj swoją przeglądarkę.\\nBigInts are needed to make the board infinite.\"\nshaders_failed = \"Nie udało się uruchomić shadera:\"\nfailed_compiling_shaders = \"Wystąpił błąd przy kompilowaniu shadera:\"\n\n[play.javascript.copypaste]\ncopied_game = \"Skopiowano grę do schowka!\"\ncannot_paste_in_public = \"Nie można wkleić gry do podczas publicznej rozgrywki!\"\ncannot_paste_after_moves = \"Nie można wkleić gry gdy ruch został już wykonany!\"\nclipboard_denied = \"Zgoda na używanie schowka odrzucona. Może być to spowodowane przez twoją przeglądarkę.\"\nclipboard_invalid = \"Zawartość schowka nie jest poprawną notacją ICN.\"\ngame_needs_to_specify = \"Gra musi określić metadatę 'Wariant', bądź właściwość 'początkowaPozycja'.\"\ninvalid_wincon_white = \"Biały gracz ma nieprawidłową zasadę wygranej\"\ninvalid_wincon_black = \"Czarny gracz ma nieprawidłową zasadę wygranej\"\npasting_game = \"Wklejanie gry...\"\npasting_in_private = \"Wklejenie gry w prywatnym meczu spowoduje desynchronizację jeśli twój przeciwnik nie zrobi tego samego!\"\npiece_count = \"Liczba figur\"\nexceeded = \"przekroczono\"\nchanged_wincon = \"Zmieniono zasadę wygranej na royalcapture i wyłączono wyświetlanie ikon. Wciśnij 'P' by włączyć z powrotem (nie zalecane).\"\nloaded_from_clipboard = \"Wczytano grę ze schowka!\"\nloaded = \"Wczytano grę!\"\nslidelimit_not_number = \"Zasada 'slideLimit' musi być liczbą. Otrzymano\"\n\n[play.javascript.rendering]\non = \"Włączona\"\noff = \"Wyłączona\"\nicon_rendering_off = \"Wyłączono wyświetlanie ikon.\"\nicon_rendering_on = \"Włączono wyświetlanie ikon.\"\ntoggled_edit = \"Przełączono tryb edytowania:\"\nperspective = \"Perspektywa\"\nperspective_mode_on_desktop = \"Tryb perspektywy dostępny na komputerze!\"\nmovement_tutorial = \"WASD by poruszać planszą. Space & shift by oddalać i przybliżać.\"\nregenerated_pieces = \"Odświeżono figury.\"\n\n[play.javascript.invites]\nmove_mouse = \"Porusz kursorem by połączyć się ponownie.\"\nunknown_action_received_1 = \"Nieznana akcja\"\nunknown_action_received_2 = \"otrzymano subskrypcję zaproszenia od serwera!\"\ncannot_cancel = \"Nie można anulować zaproszenia z nieznanym ID.\"\nyou_indicator = \"(Ty)\"\nyou_are_white = \"Jesteś: Białym\"\nyou_are_black = \"Jesteś: Czarnym\"\nrandom = \"Losowy\"\naccept = \"Akceptuj\"\ncancel = \"Anuluj\"\ncreate_invite = \"Stwórz zaproszenie\"\ncancel_invite = \"Anuluj zaproszenie\"\nstart_game = \"Rozpocznij grę\"\njoin_existing_active_games = \"Dołącz do istniejącej - Trwające Gry:\"\n\n[play.javascript.onlinegame]\nafk_warning = \"Jesteś AFK.\"\nopponent_afk = \"Przeciwnik jest AFK.\"\nopponent_disconnected = \"Przeciwnik rozłączył się.\"\nopponent_lost_connection = \"Przeciwnik utracił połączenie.\"\nauto_resigning_in = \"Auto-rezygnowanie za\"\nauto_aborting_in = \"Auto-porzucanie za\"\nnot_logged_in = \"Nie jesteś zalogowany. Zaloguj się by polączyć sie ponownie do tej gry.\"\ngame_no_longer_exists = \"Gra już nie istnieje.\"\nanother_window_connected = \"Inne okno się połączyło.\"\nserver_restarting = \"Serwer niedługo się zresetuje...\"\nserver_restarting_in = \"Reset serwera za\"\nminute = \"minutę\"\nminutes = \"minut\"\n\n[play.javascript.websocket]\nno_connection = \"Brak połączenia.\"\nreconnected = \"Połączono.\"\nunable_to_identify_ip = \"Nie możemy zidentyfikować adresu IP.\"\nonline_play_disabled = \"Gra online wyłączona. Ciasteczka nie są wspierane. Spróbuj w innej przeglądarce.\"\ntoo_many_requests = \"Zbyt wiele zapytań. Spróbuj ponownie później.\"\nmessage_too_big = \"Wiadomość zbyt długa.\"\ntoo_many_sockets = \"Too many sockets\"\norigin_error = \"Origin error.\"\nconnection_closed = \"Połączenie niespodziewanie przerwane. Wiadomość serwera:\"\nplease_report_bug = \"Nie powinno się to nigdy zdarzyć. Zgłoś błąd!\"\n\n[play.javascript.termination] # What caused the termination of the game, in spoken language\ncheckmate = \"Szach-mat\"\nstalemate = \"Pat\"\nrepetition = \"Potrójne powtórzenie pozycji\"\nmoverule = [\"Zasada \", \" 50 ruchów\"]  # The game inserts a number inbetween these two strings\ninsuffmat = \"Nie wystarczający materiał\"\nroyalcapture = \"Królewskie zbicie\"\nallroyalscaptured = \"Cała królewska rodzina zbita\"\nallpiecescaptured = \"Wszystkie figury zbite\"\nthreecheck = \"Potrójny szach\"\nkoth = \"Król wzgórza\"\nresignation = \"Poddanie się\"\nagreement = \"Remis\"\ntime = \"Koniec czasu\"\naborted = \"Przerwanie\" # Game was cancelled (no elo exchanged)\ndisconnect = \"Porzucenie\" # A player left\n\n[play.javascript.results]\nyou_checkmate = \"Wygrałeś przez szacha-mata!\"\nyou_time = \"Wygrałeś na czas!\"\nyou_resignation = \"Wygrałeś przez poddanie się przeciwnika!\"\nyou_disconnect = \"Wygrałeś przez porzucenie!\"\nyou_royalcapture = \"Wygrałeś przez królewskie zbicie!\"\nyou_allroyalscaptured = \"Wygrałeś przez zbicie całej rodziny królewskiej!\"\nyou_allpiecescaptured = \"Wygrałeś przez zbicie wszystkich figur!\"\nyou_threecheck = \"Wygrałeś przez potrójnego szacha!\"\nyou_koth = \"Zostałeś królem wzgórza!\"\nyou_generic = \"Wygrałeś!\"\ndraw_stalemate = \"Remis przez pata!\"\ndraw_repetition = \"Remis przez powtórzenie pozycji!\"\ndraw_moverule = [\"Remis przez zasadę \", \" ruchów!\"] # The game inserts a number inbetween these two strings\ndraw_insuffmat = \"Remis przez niewystarczający materiał!\"\ndraw_agreement = \"Remis!\"\ndraw_generic = \"Remis!\"\naborted = \"Gra porzucona.\"\nopponent_checkmate = \"Przegrałeś przez szacha-mata!\"\nopponent_time = \"Przegrałeś na czas!\"\nopponent_resignation = \"Przegrałeś przez poddanie się!\"\nopponent_disconnect = \"Przegrałeś przez porzucenie!\"\nopponent_royalcapture = \"Przegrałeś przez królewskie zbicie!\"\nopponent_allroyalscaptured = \"Przegrałeś przez zbicie całej rodziny królewskiej!\"\nopponent_allpiecescaptured = \"Przegrałeś przez zbicie wszystkich figur!\"\nopponent_threecheck = \"Przegrałeś przez potrójnego szacha!\"\nopponent_koth = \"Przeciwnik został królem wzgórza!\"\nopponent_generic = \"Przegrałeś!\"\nwhite_checkmate = \"Biały wygrał przez szach-mat!\"\nblack_checkmate = \"Czarny wygrał przez szach-mat!\"\nbug_checkmate = \"Jest to błąd, prosimy o zgłoszenie tego. Gra skończona przez szach-mat.\"\nwhite_time = \"Biały wygrał na czas!\"\nblack_time = \"Czarny wygrał na czas!\"\nbug_time = \"Jest to błąd, prosimy o zgłoszenie tego. Gra skończona na czas\"\nwhite_royalcapture = \"Biały wygrał przez królewskie zbicie!\"\nblack_royalcapture = \"Czarny wygrał przez królewskie zbicie!\"\nbug_royalcapture = \"Jest to błąd, prosimy o zgłoszenie tego. Gra skończona przez królewskie zbicie.\"\nwhite_allroyalscaptured = \"Biały wygrał przez zbicie całej rodziny królewskiej!\"\nblack_allroyalscaptured = \"Czarny wygrał przez zbicie całej rodziny królewskiej!\"\nbug_allroyalscaptured = \"Jest to błąd, prosimy o zgłoszenie tego. Gra skończona przez zbicie całej rodziny królewskiej!\"\nwhite_allpiecescaptured = \"Biały wygrał przez zbicie wszystkich figur!\"\nblack_allpiecescaptured = \"Czarny wygrał przez zbicie wszystkich figur!\"\nbug_allpiecescaptured = \"Jest to błąd, prosimy o zgłoszenie tego. Gra skończona przez zbicie wszystkich figur.\"\nwhite_threecheck = \"Biały wygrał przez potrójnego szacha!\"\nblack_threecheck = \"Czarny wygrał przez potrójnego szacha!\"\nbug_threecheck = \"Jest to błąd, prosimy o zgłoszenie tego. Gra skończona przez potrójnego szacha\"\nwhite_koth = \"Biały zostaje królem wzgórza!\"\nblack_koth = \"Czarny zostaje królem wzgórza!\"\nbug_koth = \"Jest to błąd, prosimy o zgłoszenie tego. Gra skończona przez króla wzgórza.\"\nbug_generic = \"Jest to błąd, prosimy o zgłoszenie tego!\"\n\n[terms]\ntitle = \"Warunki korzystania z usługi\"\nwarning = [\"TEN DOKUMENT NIE JEST PRAWNIE WIĄŻĄCY. Jesteśmy tylko odpowiedzialni za angielską wersję tego dokumentu. To tłumaczenie powstało tylko dal celów informacyjnych. Dostęp do angielskiej wersji jest \", \"tutaj\", \".\"]\nconsent = \"Używając tej strony, wyrażasz zgodę na poniższe postanowienia. Jeśli nie chcesz wyrażać zgody, natychmiast opuść tą stronę.\"\nguardian_consent = \"Jeśli jesteś niepełnoletni potrzebujesz dostać zgodę od rodzica, bądź opiekuna prawnego by korzystać z tej strony oraz założyć konto.\"\nparents_header = \"Dla Rodziców\"\nparents_paragraphs = [\n\"Wdrożony jest system, który zabrania użytkownikom używania popularnych przestępstw jako nazw użytkownika. Aktualnie nie ma innego sposobó na komunikowanie się pomiędzy użytkownikami.\",\n\"Aktualnie użytkownicy nie mają opcji ustawiać własnych zdjęć profilowych. Taka opcja jest w planach, lecz do tego czasu wprowadzony zostanie system, który bedzie uniemożliwiał używania nieodpowiednich zdjęć profilowych.\",\n]\nfair_play_header = \"Fair Play\"\nfair_play_paragraph1 = [\"Nie możesz zakładać więcej niż 1 konta. Jeśli chcesz zmienić swój adres e-mail, \", \"skontaktuj się z nami.\"]\nfair_play_paragraph2 = \"By gra była przyjemna i sprawiedliwa dla wszystkich, NIE możesz:\"\nfair_play_rules = [\n\"Modyfikować ani manipulować kodu źródłowego w żaden sposób, wliczając: używanie komend w konsoli, nadpisywanie lokalne, niestandardowe skrypty, modywikowanie zapytań http, itd. Może być to użyte celowo by zepsuć grę, bądź zyskać przewagę nad przeciwnikiem.\",\n\"W grach rankingowych, otrzymywać pomocy od innej osoby, bądź programu, który podpowie ci co powinieneś zrobić. (Tworzenie botów szachowych jest okej, ale możesz go używać tylko na grach nierankingowych)\",\n\"\\\"Handlować\\\" punktami elo z innymi graczami, poprzez celowe przegrywanie by zwiększyć elo towjego przeciwnika, bądź poprzez dostawanie punktów od przeciwnika, który celowo przegrywa by zwiększyć twój ranking elo. Takie praktyki wykorzystują lukę w systemie i tworzą fałszywy ranking, który nie jest reprezentacją twoich prawdziwych umiejętności.\"\n]\ncleanliness_header = \"Zachowanie kultury\"\ncleanliness_rules = [\n\"Wsyzstkie wypowiedzi na tej stornie muszą pozostać kulturalne, nie używaj wulgaryzmów ani przekleństw. nie prześladuj, szantażuj, upokażaj nikogo. Nie groź i nie rób nic co jest nielegalne. Nie możesz \\\"spamować\\\" innym użytkownikom ani na żadnych forach.\",\n\"Nie możesz przesyłać na swój profil żadnych treści które są nieodpowiednie, wulgarne, sprośne, bądź makabryczne. Takie działanie może skutkować zablokowaniem albo usunięciem konta.\"\n]\nprivacy_header = \"Prywatność\"\nprivacy_rules = [\n\"Na ten moment jedyne dane osobowe, które zbieramy to e-mail. Jest on używany w celu weryfikacji kont użytkowników oraz by potwierdzić tożsamość przy próbie zresetowania hasła. Nie wysyłamy żadnych maili reklamowych ani ofert. Nie udostępniamy danych żadnego z użytkowników nikomu.\",\n\"InfiniteChess.org może zbierać dane o użytkowaniu strony, takie jak adres IP. Jest on używany by chronić stronę przed atakami botów i innych niechcianych jednostek, a także by utrzymywać poprawne statystyki w bazie danych. NIE jest to twój adres domowy.\",\n\"Wszystkie gry, które zagrasz na tej stronie będą informacją publiczną. Jeśli chcesz pozostać anonimowy nie udostępniaj swojej nazwy użytkownika rodzinie i znajomym. Jeśli jest to twoja wola, twoją odpowiedzialnością jest by nikt nie poznał twojej nazwy użytkownika, która jest powiązana z twoją tożsamością.\",\n\"Twój status online oraz przybliżony czas ostatniego logowania również jest publiczną informacją.\",\n[\"InfiniteChess.org stara się utrzymać konta oraz dane osobowe wszystkich użytkowników bezpiecznymi z największą uwagą i troską, na wypadek włamania lub wycieku danych, nie możesz podejmować kroków prawnych. Gdyby wyciek danych kiedykolwiek się zdarzył, użytkownicy zostaną poinformowani na stronie\", \"Aktualności.\", \"\"],\n\"Na tej stronie nie ma nic dostępnego do zakupu. Wszystkie inne dane osobowe nie są zbierane.\",\n\"By wyczyścić wszystkie dane osobowe z naszych serwerów, należy usunąć konto na stronie profilu. Jedyne informacje powiązane z nazwą użytkownika, które NIE zostaną usunięte to te dotyczące historii gier, gdyż te są informacjami publicznymi.\",\n]\ncookie_header = \"Polityka Cookies\"\ncookie_paragraphs = [\n\"Ta strona używa plików cookies, czyli małych plików tekstowych, które są przechowywane w twojej przeglądarce i wysyłane do serwera gdy połączenie jest ustanowione. Ich celem jest: weryfikowanie twojej sesji logowania, sprawdzanie czy twoja przeglądarka jest w grze, w której twierdzi, że jest, oraz przechowywania ustawień by były takie same po ponownym włączeniu strony. Strona nie używa plików cookies z zewnętrznych stron, pliki cookies nie są nikomu udostępniane.\",\n\"Pliki cookies są wymagane by strona i gra funkcjonowały poprawnie. Jeśli nie chcesz by strona przechowywała pliki cookies musisz przestać jej używać. Następnie usuń pliki cookies w swojej przeglądarce. Używając tej strony, wyrażasz zgodę na używanie plików cookies.\"\n]\nconclusion_header = \"Podsumowanie\"\nconclusion_paragraphs = [\n\"Jakiekolwiek naruszenie zasad może skutkować zawieszeniem bądź usunięciem konta. InfiniteChess.org chce dać każdemu możliwość by grać i dobrze spędzić czas! Niemniej jednak zachowujemy prawo, by w dowolnym czasie, zawiesić lub usunąć konta dowolnego użytkownika, bez podania przyczyny. Nie możemy ponieść za to odpowiedzialności prawnej.\",\n[\"Warunki korzystania z usługi mogą być zmienione w dowolnym momencie. To TWOJĄ odpowiedzialnością jest by sprawdzać ostatnie zmiany! Gdy warunki korzystania z usługi ulegną zmianie, informacja o tym zostani eopublikowana na stronie\", \"Aktualności.\", \"Jeśli po zmianie warunków korzystania z usługi, nie zgadzasz się z nowymi warunkami, musisz natychmiast przestać używać strony. Możesz usunąć swoje konto na stronie profilu. Gdy usuniesz swoje konto, wszystkie dane osobowe oraz dane konta zostaną usunięte, OPRÓCZ historii gier, która jest powiązana z twoją nazwą użytkownika, ponieważ jest to informacja publiczna.\"],\n[\"Kod źródłowy tej strony jest publiczny. Możesz kopiować i udostępniać wszystko na tej stronie, tak długo jak tylko przestrzegasz zasad opisanych w\", \"licencji\", \"! Jeśli ten link nie działa twoją odpowiedzialnością jest odnalezienie i zastosowanie się do warunków licencji.\"],\n\"Nie jesteśmy w stanie zagwarantować, że strona będzie działać cały czas. Nie jesteśmy w stanie zagwarantować, że dane nie zostaną nigdy uszkodzone.\",\n\"Nie możesz wykonywać żadnych nielegalnych działań na tej stronie.\",\n[\"Jeśli masz do nas pytania dotyczące warunków korzystania z usługi lub inne pytania dotyczące tej strony,\", \"napisz do nas e-mail!\"]\n]\nupdate = \"(Ostatnio aktualizowane: 7/13/24. Dodano ostrzeżenie mówiące o tym, że wszystkie zagrane gry staną się informacją publiczną, włączając także dane o przybliżonym czasie ostatniego logowania, a także, że te warunki mogą zostać zaktualizowane w dowolnym momencie, i że twoją odpowiedzialnością jest zapoznać się ze zmianami.)\"\nthanks = \"Dziękujemy!\"\n\n[login]\ntitle = \"Zaloguj się\"\nusername = \"Nazwa użytkownika:\"\npassword = \"Hasło:\"\nforgot_password = [\"Zapomniałeś hasła? \", \"Napisz do nas e-mail.\"]\nlogin_button = \"Zaloguj\"\n\n[error-pages] # Messages shown on some error pages explaining what went wrong\n400_message = \"Otrzymano błędne parametry.\"\n409_message = [\"Możliwy błąd dotyczący nazwy użytkownika lub e-mail. \", \"Przeładuj\", \", stronę.\"]\n500_message = \"Nie powinno się to zdarzyć!\"\n\n[news]\ntitle = \"Aktualności\"\nmore_dev_logs = [\"Więcej wpisów deweloperskich na \", \"oficjalnym serwerze discord\", \", oraz na \", \"forach chess.com!\"]\n\n[server.javascript]\nws-invalid_username = \"Nieprawidłowa nazwa użytkownika\"\nws-incorrect_password = \"Nieprawidłowe hasło\"\nws-username_and_password_required = \"Nazwa użytkownika i hasło są wymagane.\"\nws-username_and_password_string = \"Nazwa użytkownika i hasło muszą być tekstem.\"\nws-login_failure_retry_in = \"Logowanie nie powiodło się, spróbuj ponownie za\"\nws-seconds = \"sekund\" # unit of time\nws-second = \"sekundę\" # unit of time\nws-username_length = \"Nazwa użytkownika musi mieć pomiędzy 3-20 znaków\"\nws-username_letters = \"Nazwa użytkownika może zawierać tylko znaki A-Z i cyfry 0-9\"\nws-username_taken = \"Nazwa użytkownika jest już zajęta\"\nws-username_bad_word = \"Nazwa użytkownika zawiera niedozwolone słowo\"\nws-username_reserved = \"Nazwa użytkownika jest zarezerwowana\"\nws-email_too_long = \"Twój e-mail jest za długi.\"\nws-email_invalid = \"To nie jest prawidłowy e-mail\"\nws-email_in_use = \"Ten e-mail jest już zajęty\"\nws-you_are_banned = \"Jestes zawieszony.\"\nws-password_length = \"Hasło musi mieć 6-72 znaków\"\nws-password_format = \"Hasło ma nieprowidłowy format\"\nws-password_password = \"Hasło nie może być 'password'\"\nws-refresh_token_not_found_logged_out = \"Żaden z użytkowników nie ma takiego tokenu (już jesteś wylogowany)\"\nws-refresh_token_not_found = \"Żaden z użytkowników nie ma takiego tokenu\"\nws-refresh_token_expired = \"Nie znaleziono tokenu (sesja wygasłą)\"\nws-refresh_token_invalid = \"Token wygasł lub został zmieniony\"\nws-member_not_found = \"Użytkownik nie znaleziony\"\nws-forbidden_wrong_account = \"Zakazane. To nie towje konto.\"\nws-deleting_account_not_found = \"Nie udało się usunąć konta. Konto nie znalezione.\"\nws-server_error = \"Przepraszamy, nastąpił błąd serwera! Wróć na poprzednią stronę.\"\nws-unable_to_identify_client_ip = \"Nie udało się zidentyfikować adresu IP klienta\"\nws-you_are_banned_by_server = \"Jesteś zawieszony\"\nws-too_many_requests_to_server = \"Zbyt dużo połączeń. Spróbuj później.\"\nws-bad_request = \"Bad Request\"\nws-not_found = \"404 Not Found\"\nws-forbidden = \"Forbidden.\"\nws-unauthorized_patron_page = \"Brak wstępu. Ta strona jest tylko dla wspierających.\"\nws-already_in_game = \"Jestś już w grze.\"\nws-server_restarting = \"Restart serwera za\" # The server inserts a number immediately after this, followed by the correct plurality of minutes.\nws-server_under_maintenance = \"Trwają prace nad serwerem. Wróć później!\" # Can be changed at will to change the display message.\nws-minutes = \"minut\" # unit of time\nws-minute = \"minutę\" # unit of time\nws-no_abort_game_over = \"Nie możesz porzucić gry, która się skonczyła.\"\nws-no_abort_after_moves = \"Nie możesz porzucić gry gdy 2 ruchy zostały zagrane.\"\nws-game_aborted_cheating = \"Gra porzucona, podejrzenie oszustwa.\"\nws-cannot_resign_finished_game = \"Nie możesz poddać się w grze, która się skończyłą.\"\nws-invalid_code = \"Nieprawidłowy kod!\" # Invite code doesn't match any existing invites\nws-game_aborted = \"Gra porzucona.\" # Invite was cancelled as you clicked on it"
  },
  {
    "path": "translation/pt-BR.toml",
    "content": "name = \"Português\" # Nome do idioma\r\nenglish_name = \"Portuguese\"\r\ndirection = \"ltr\" # Change to \"rtl\" for right to left languages\r\nversion = \"64\"\r\nmaintainer = \"\" # No current maintainer\r\n\r\n[header]\r\nhome = \"Xadrez Infinito\"\r\nplay = \"Jogar\"\r\nnews = \"Notícias\"\r\nlogin = \"Entrar\"\r\nprofile = \"Perfil\"\r\ncreateaccount = \"Criar conta\"\r\nlogout = \"Sair\"\r\nleaderboard = \"Classificação\"\r\n\r\n[header.settings]\r\nlanguage = \"Idioma\"\r\nboard = \"Tabuleiro\" # Cor/Tema do Tabuleiro\r\nlegalmoves = \"Lances Legais\" # Legal moves shape\r\nlegalmoves-squares = \"Casas\"\r\nlegalmoves-dots = \"Pontos\" # Pontos e triângulos de 4 cantos\r\nselection = \"Selection\"\r\nselection-drag = \"Arrastar peças\"\r\nselection-premove = \"Premoves\"\r\nselection-animations = \"Animações\"\r\nselection-lingering_annotations = \"Anotações Persistentes\"\r\nperspective = \"Perspectiva\" # Modo da perspectiva\r\nperspective-mouse-sensitivity = \"Sensibilidade do Mouse\"\r\nperspective-fov = \"Campo de Visão\"\r\nping = [\"Ping\", \"ms\"] # Um número é inserido dentro dessas 2 strings.\r\nreset-to-default = \"Resetar para o Padrão\"\r\n\r\n[footer]\r\ncontact = \"Contato\"\r\nterms_of_service = \"Termos de serviço\"\r\nsource_code = \"Código-fonte\"\r\nlanguage = \"Idioma\"\r\n\r\n[member.javascript]\r\njs-confirm_delete = \"Tem certeza de que deseja excluir sua conta? Isso NÃO PODE ser desfeito! Clique em OK para digitar sua senha.\"\r\njs-enter_password = \"Digite sua senha para excluir PERMANENTEMENTE sua conta:\"\r\n\r\n[leaderboard.javascript]\r\nsupported_variants = \"Essa tabela de Classificação é usada para seguintes variantes:\"\r\nrank = \"Classificação\"\r\nplayer = \"Jogador\"\r\nrating = \"Rating\"\r\n\r\n[index]\r\ntitle = \"Xadrez Infinito | Início - O Site Oficial\" # The tab title\r\nsecondary_title = \"O site oficial para jogar ao vivo!\"\r\nwhat_is_it_title = \"O que é?\"\r\nwhat_is_it_pargaraphs = [\r\n\"Xadrez Infinito é uma variante do xadrez em que não há bordas, muito maior do que o seu familiar tabuleiro 8x8. A dama, as torres e os bispos <em>não têm limites</em> de quão longe podem se mover por turno. Escolha qualquer número natural até o infinito!\",\r\n\"Sem limite de quão longe você pode se mover, existem posições possíveis onde o número do relógio do xeque-mate, ou xeque-mate em <em>branco</em>, é representado pelo primeiro ordinal infinito, <strong>ômega ω</strong >. Na verdade, pesquisas descobriram que <strong>qualquer</strong> ordinal contável é alcançável para o relógio do xeque-mate!\",\r\n\"Como você pode imaginar, existem infinitas possibilidades de posições iniciais, muitas das quais você pode jogar competitivamente! Seu objetivo final ainda é o xeque-mate, o que requer novas táticas, visto que não há paredes contra as quais possa prender o rei inimigo. Os jogos tipicamente não duram muito mais do que os jogos normais de xadrez. Peões ainda promovem nas fileiras 1 & 8!\",\r\n]\r\nhow_to_title = \"Como eu posso jogar?\"\r\nhow_to_paragraph = [\"A versão atual é a 1.10 na página \",\"Jogar\",\"!\"]\r\nabout_title = \"Sobre o Projeto\"\r\nabout_paragraphs = [\r\n\"Eu sou Naviary. Desde que descobri o Xadrez Infinito (o conceito existia muito antes deste site), fiquei muito intrigado com ele e suas possibilidades! Até pouco tempo atrás, jogar era bastante difícil, exigindo que os membros do chess.com criassem imagens do tabuleiro atual e as enviassem de um lado para o outro a cada jogada realizada. Devido a isso, poucas pessoas conhecem ou conseguiram jogar.\",\r\n[\"Meu objetivo é criar uma maneira de tornar esse jogo fácil de jogar para todos e desenvolver uma comunidade em torno dele. Gastei inúmeras horas do meu tempo livre neste site, mantendo e desenvolvendo o jogo. Tenho muitas outras ideias que me manterão ocupado por algum tempo. Embora eu queira manter o jogo gratuito, a vida tem suas exigências. Para me ajudar financeiramente, considere a possibilidade de se juntar ao meu \", \"Patreon\", \".\"] # Patreon receives a hyperlink, here\r\n]\r\npatreon_title = \"Apoiadores do Patreon\"\r\ngithub_title = \"Contribuidores do Github\"\r\n\r\n[index.javascript]\r\ncontribution_count = [\"\", \" contribuições\"] # Um número é inserido entre essas duas strings.\r\n\r\n[credits]\r\ntitle = \"Créditos\"\r\ncopyright = \"Tudo o que estiver no site que não estiver listado abaixo é copyright de www.InfiniteChess.org\"\r\nvariants_heading = \"Variantes\"\r\nvariants_credits = [\r\n\"Core projetado por Andreas Tsevas.\",\r\n\"Space projetado por Andreas Tsevas.\",\r\n\"Space Classic projetado por Andreas Tsevas.\",\r\n\"Coaip (Chess on an Infinite Plane) projetado por V. Reinhart.\",\r\n\"Pawn Horde projetado por Inaccessible Cardinal.\",\r\n\"Abundance projetado por Clicktuck Suskriberz.\",\r\n\"Pawndard por SexyLexi.\",\r\n\"Classical+ por SexyLexi.\",\r\n\"Knightline por Inaccessible Cardinal.\",\r\n\"Knighted Chess por cycy98.\",\r\n\"projetado por Cory Evans e Joel Hamkins.\",\r\n\"projetado por Andreas Tsevas.\",\r\n\"projetado por Cory Evans e Joel Hamkins.\",\r\n\"projetado por Cory Evans, Joel Hamkins, e Norman Lewis Perlmutter.\",\r\n\"Chess on an Infinite Plane - Huygens Options por V. Reinhart.\",\r\n\"Confined Classical por Andreas Tsevas.\",\r\n\"4x4x4x4 Chess por Andreas Tsevas.\",\r\n\"5D Chess por Jace.\",\r\n]\r\ntextures_heading = \"Texturas\"\r\ntextures_licensed_under = \"texturas licenciadas sob a licença\"\r\nsounds_heading = \"Sons\"\r\nsounds_credits = [\r\n[\"Alguns sons são fornecidos pelo\", \"sob a\"],\r\n\"Outros sons criados por Naviary.\",\r\n]\r\ncode_heading = \"Código\"\r\ncode_credits = [\r\n\"por Brandon Jones e Colin MacKenzie IV.\",\r\n\"por Andreas Tsevas e Naviary.\",\r\n]\r\nlanguage_heading = \"Traduções de Idiomas\"\r\nlanguage_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded.\r\n\t\"Francês por \", \"Life Enjoyer\", \" e \", \"cycy98\", \".\",\r\n\t\"Chinês Simplificado por \", \"Heinrich Xiao\", \".\",\r\n\t\"Chinês Tradicional por \", \"Heinrich Xiao\", \".\",\r\n\t\"Polonês por \", \"Tymon Becella\", \".\", # Apsurt\r\n\t\"Português por \", \"Emerson P. Machado\", \".\", # The_Skeleton on discord\r\n\t\"Espanhol por \", \"xa31er\", \".\",\r\n\t\"Alemão por \", \"Estetique\", \".\"\r\n]\r\n\r\n[member]\r\ntitle = \"Membro\" # The tab name\r\nverify_message = \"Por favor verifique seu e-mail para confirmar sua conta. Contas não confirmadas serão deletadas após 3 dias.\"\r\nresend_message = [\"Não recebeu um? Verifique sua pasta de spam. Também, \", \"enviar novamente.\", \" Se você ainda não consegue encontrar, \", \"mande uma mensagem.\"]\r\nverify_confirm = \"Obrigado! Sua conta foi verificada\"\r\njoined = \"Ingressou:\"\r\nseen = [\"Visto:\", \" desde de\"]\r\npractice_progress = \"Progresso no modo Prática:\"\r\nranked_elo = \"Rating:\"\r\ninfinity_leaderboard_position = \"Rank Global:\"\r\ninfinity_leaderboard_rating_deviation = \"Desvio de Rating:\"\r\nreveal_info = \"Mostre informações da conta\"\r\naccount_info_heading = \"Informações da conta\"\r\nemail = \"Email:\"\r\ndelete_account = \"Deletar conta\"\r\n\r\n[member.badge-tooltips]\r\ncheckmate_bronze = \"Veterano do Xeque-mate: Complete 50% de todos os confrontos de prática.\"\r\ncheckmate_silver = \"Pro do Xeque-mate: Concluir 75% de todos os confrontos de prática.\"\r\ncheckmate_gold = \"Mestre do Xeque-mate: Complete 100% de todos os checkmates de prática.\"\r\n\r\n[create-account]\r\ntitle = \"Criar conta\"\r\nusername = \"Nome de usuário:\"\r\nemail = \"Email:\"\r\npassword = \"Senha:\"\r\ncreate_button = \"Criar conta\"\r\nagreement = [\"Eu concordo com os \", \"Termos de Serviço\", \".\"]  # a entrada do meio é um hiperlink, as outras não são\r\n\r\n[create-account.javascript]\r\njs-username_specs = \"O nome de usuário deve ter pelo menos 3 caracteres e conter apenas letras de A-Z e números de 0-9\"\r\njs-username_tooshort = \"O nome de usuário deve ter pelo menos 3 caracteres\"\r\njs-username_wrongenc = \"O nome de usuário deve conter apenas letras de A-Z e números de 0-9\"\r\njs-email_invalid = \"Este não é um email válido\"\r\njs-email_inuse = \"Este email já está em uso\"\r\n\r\n[reset-password.javascript]\r\njs-pwd_no_match = \"As senhas não são iguais.\"\r\nreset-password = \"Resetar Senha\"\r\nprocessing = \"Processando...\"\r\nnetwork-error = \"Ocorreu um erro de rede. Tente novamente.\"\r\n\r\n[password-validation]\r\njs-pwd_incorrect_format = \"A senha está em um formato incorreto\"\r\njs-pwd_too_short = \"A senha deve ter mais de 6 caracteres\"\r\njs-pwd_too_long = \"A senha não pode ter mais de 72 caracteres\"\r\njs-pwd_not_pwd = \"A senha não deve ser 'password'\"\r\n\r\n[leaderboard]\r\ntitle = \"Classificação\"\r\ninactive_players = \"Jogadores inativos com alta incerteza de rating são excluídos da tabela de classificação.\"\r\nyour_global_ranking = \"Seu Rank Global:\"\r\nshow_more = \"Mostrar mais...\"\r\n\r\n[play]\r\ntitle = \"Xadrez Infinito - Jogar\" # The tab title\r\nloading = \"CARREGANDO\"\r\nerror = \"ERRO\"\r\n\r\n[play.main-menu]\r\ncredits = \"Créditos\"\r\nplay = \"Jogar\"\r\npractice = \"Práticar\"\r\nguide = \"Guia\"\r\neditor = \"Editor de tabuleiro\"\r\n\r\n[play.guide]\r\ntitle = \"Guia\"\r\nrules = \"Regras\"\r\nrules_paragraphs = [\r\n\"As regras do Xadrez Infinito são quase idênticas às do xadrez clássico, exceto pelo fato de o tabuleiro ser infinito em todas as direções! Essas são as únicas observações e alterações das quais você precisa estar ciente:\",\r\n\"As peças com movimentos deslizantes, como as torres, os bispos e a dama, não têm limite para a distância que podem percorrer em um turno! Desde que o caminho delas esteja desobstruído, você pode mover milhões!\",\r\n[\"Na variante padrão \\\"Classical\\\", os peões brancos são promovidos na posição 8 e os peões pretos na posição 1. Nesta imagem, isso é indicado pelas linhas pretas finas. Elas são fracas, veja se você consegue identificá-las! Os peões só precisam alcançar a linha oposta para serem promovidos, \", \"não\", \" cruzá-la.\"],\r\n\"A notação dos quadrados não é mais descrita pela letra da coluna e pelo número da fileira (ou seja, a1); em vez disso, cada quadrado é definido por um par de coordenadas x e y. O quadrado a1 se tornou (1,1) e o quadrado h8 se tornou (8,8). Em dispositivos desktop, a coordenada sobre a qual o mouse está posicionado é exibida na parte superior da tela.\",\r\n\"Todas as outras regras são as mesmas do xadrez clássico, como xeque-mate, afogamento, empate por tripla repetição, regra dos 50 lances, roque, en passant, etc.!\"\r\n]\r\ncareful_heading = \"Cuidado!\"\r\ncareful_paragraphs = [\r\n\"A abertura do tabuleiro infinito significa que é muito fácil explorar garfos, cravadas e espetos. Sua retaguarda geralmente fica muito vulnerável. Cuidado com táticas como essa! Seja criativo ao criar proteção para seu rei e torres! A estratégia da abertura é muito diferente do xadrez clássico.\",\r\n\"Muitas outras variantes foram criadas com o objetivo de fortalecer suas costas.\"\r\n]\r\ncontrols_heading = \"Controles\"\r\ncontrols_paragraph = \"Muitos controles são intuitivos, como clicar e arrastar o tabuleiro para se movimentar e rolar para aumentar e diminuir o zoom, mas vamos dar uma olhada nos outros controles que estão à sua disposição!\"\r\nkeybinds = [\r\n\" para se movimentar.\",\r\n[\"Space\", \" e \", \"Shift\", \" para aumentar e diminuir o zoom\"],\r\n[\"Escape\", \" para pausar o jogo.\"],\r\n[\"Tab\", \" alterna o modo de funcionamento da seta nas bordas da tela que apontam para peças fora da tela. Por padrão, esse modo é definido como \\\"Defesa\\\", que mostra uma seta para peças que podem se mover para o seu local a partir de onde estão. Mas \", \"tab\", \" pode mudar para o modo \\\"Todas\\\" ou \\\"Off\\\", sendo que o modo \\\"Todas\\\" revela todas as peças nessas ortogonais e diagonais, quer elas possam se mover ortogonalmente ou diagonalmente. Essa configuração também pode ser alternada no menu de pausa.\"],\r\n[\"Control\", \"forçará o arrasto do tabuleiro em vez de arrastar uma peça, se o arrasto estiver ativado nas configurações.\"],\r\n\" ativará \\\"Modo Editor\\\" em jogos locais. Isso permite que você mova qualquer peça para qualquer outro lugar do tabuleiro! Muito útil para análise.\"\r\n]\r\ncontrols_paragraph2 = \"Esses são os principais controles que você precisa conhecer. Mas aqui estão alguns extras, caso você venha a precisar deles!\"\r\nkeybinds_extra = [\r\n\" redefinirá a renderização das peças. Isso é útil se elas ficarem invisíveis. Essa falha pode ocorrer se você se mover a distâncias extremas (como 1e21).\",\r\n\" alternará a renderização das barras de navegação e de informações do jogo, o que pode ser útil para a gravação. Transmitir e fazer vídeos sobre o jogo é bem-vindo!\",\r\n\" alternará seu medidor de FPS. Isso exibe o número de vezes que o jogo está sendo atualizado por segundo, nem sempre o número de quadros renderizados, pois o jogo pula a renderização quando nada visível foi alterado, para economizar computação.\",\r\n\" alternará a renderização de ícones. Essas são os mini-avatares clicáveis das peças quando você reduz o zoom o suficiente. Em jogos importados com mais de 50.000 peças, isso é desativado automaticamente, pois é um fator de redução de desempenho, mas pode ser ativado novamente com \",\r\n[\" (backtick ou a mesma tecla que \", \") alternará para modo Debug.\"],\r\n]\r\nfairy_heading = \"Peças Fadas\"\r\nfairy_paragraph = \"Você já sabe o que precisa saber para jogar a variante padrão \\\"Clássica\\\". As Peças das Fadas não são usadas no xadrez convencional, mas são incorporadas em outras variantes! Se você se encontrar em uma variante com algumas peças que nunca viu antes, vamos aprender como elas funcionam aqui!\"\r\nediting_heading = \"Editando o Tabuleiro\"\r\nediting_paragraphs = [\r\n[\"Há um \", \"editor de tabuleiro\", \" externo atualmente disponível em uma planilha pública do Google! Ela inclui instruções sobre como usá-la. Isso requer algum conhecimento básico do Google Sheets. Após a configuração, você poderá criar e importar posições personalizadas para o jogo por meio do botão \\\"Colar jogo\\\" no menu de opções!\"],\r\n\"Para jogar uma posição personalizada com um amigo, peça a ele que participe de um convite privado e, em seguida, vocês dois colarão o código do jogo antes de começar a jogar!\",\r\n\"Um editor de tabuleiro dentro do jogo ainda está planejado.\",\r\n]\r\nback = \"Voltar\"\r\n\r\n[play.guide.pieces]\r\nchancellor = {name=\"Chanceler \", description=\"Move-se como uma torre e um cavalo combinados.\"}\r\narchbishop = {name=\"Arcebispo\", description=\"Move-se como um bispo e um cavalo combinados.\"}\r\namazon = {name=\"Amazonas\", description=\"Move-se como uma rainha e um cavalo combinados. Essa é a peça mais forte do jogo!\"}\r\nguard = {name=\"Guarda\", description=\"Move-se como um rei, exceto que não é suscetível a xeque ou xeque-mate.\"}\r\nhawk = {name=\"Falcão\", description=\"Salta exatamente 2 ou 3 quadrados em qualquer direção.\"}\r\ncentaur = {name=\"Centauro\", description=\"Move-se como um cavalo e um guarda combinados.\"}\r\nknightrider = {name=\"Knightrider\", description=\"Pula como um cavalo em uma direção infinitamente, até ser obstruído.\"}\r\nhuygen = {name=\"Huygen\", description=\"Pula infinitamente em uma das quatro direções cardeais, visitando apenas os quadrados com uma distância de número primo do seu quadrado inicial, até ser obstruído.\"}\r\nobstacle = {name=\"Obstáculo\", description=\"Uma peça neutra (não controlado por nenhum dos jogadores) que bloqueia o movimento, mas pode ser capturado.\"}\r\nvoid = {name=\"Vazio\", description=\"Uma peça neutra (não controlada por nenhum dos jogadores) que representa a ausência do tabuleiro. As peças não podem se mover através dele ou sobre ele.\"}\r\n\r\n[play.practice-menu]\r\ntitle = \"Práticar - Xeque-mates\"\r\nplay = \"Jogar\"\r\nback = \"Voltar\"\r\ndifficulty = \"Dificuldade\"\r\n\r\n[play.play-menu]\r\ntitle = \"Jogar - Online\"\r\ncolors = \"Cores\"\r\nonline = \"Online\"\r\nlocal = \"Local\"\r\ncomputer = \"Computador\"\r\nvariant = \"Variante\"\r\nClassical = \"Classical\"\r\nConfined_Classical = \"Confined Classical\"\r\nClassical_Plus = \"Classical+\"\r\nCoaIP = \"Chess on an Infinite Plane\"\r\nPawndard = \"Pawndard\"\r\nKnighted_Chess = \"Knighted Chess\"\r\nKnightline = \"Knightline\"\r\nCore = \"Core\"\r\nStandarch = \"Standarch\"\r\nPawn_Horde = \"Pawn Horde\"\r\nSpace_Classic = \"Space Classic\"\r\nSpace = \"Space\"\r\nObstocean = \"Obstocean\"\r\nAbundance = \"Abundance\"\r\nAmazon_Chandelier = \"Amazon Chandelier\"\r\nContainment = \"Containment\"\r\nClassical_Limit_7 = \"Classical - Limit 7\"\r\nCoaIP_Limit_7 = \"Coaip - Limit 7\"\r\nChess = \"Xadrez\"\r\nClassical_KOTH = \"Experimental: Classical - KOTH\"\r\nCoaIP_KOTH = \"Experimental: Coaip - KOTH\"\r\nCoaIP_HO = \"Chess on an Infinite Plane - Huygens Option\"\r\nOmega = \"Showcase: Omega\"\r\nOmega_Squared = \"Showcase: Omega^2\"\r\nOmega_Cubed = \"Showcase: Omega^3\"\r\nOmega_Fourth = \"Showcase: Omega^4\"\r\n4x4x4x4_Chess = \"4×4×4×4 Chess\"\r\n5D_Chess = \"5D Chess\"\r\nno_clock = \"Sem relógio\"\r\nclock = \"Relógio\"\r\nminutes = \"m\"\r\nseconds = \"s\"\r\ninfinite_time = \"Tempo Infinito\"\r\ncolor = \"Cor\"\r\npiece_colors = [\"Aleatória\", \"Brancas\", \"Pretas\"]\r\nprivate = \"Privado\"\r\nno = \"Não\"\r\nyes = \"Sim\"\r\nrated = \"Com rating\"\r\ncasual = \"Sem rating\"\r\njoin_games = \"Participe dos jogos existentes - Jogos Ativos:\"\r\nprivate_invite = \"Convite Privado:\"\r\nyour_invite = \"Seu código de convite:\"\r\ncreate_invite = \"Criar convite\"\r\njoin = \"Entrar\"\r\ncopy = \"Copiar\"\r\nback = \"Voltar\"\r\ncode = \"Código\"\r\n\r\n[play.gamebuttontooltips]\r\nundo_transition = \"Voltar transição\"\r\nexpand_fit_all = \"Expandir para caber tudo\"\r\nrecenter = \"Recentralizar\"\r\nannotations = \"Mostrar anotações\"\r\nerase = \"Apagar anotações\"\r\ncollapse = \"Colapsar anotações\"\r\nrewind_move = \"Lance anterior\"\r\nforward_move = \"Próximo lance\"\r\nundo_edit = \"Desfazer\" # Board editor\r\nredo_edit = \"Refazer\" # Board editor\r\npause = \"Pausar\"\r\nundo = \"Voltar lance\" # Checkmate practice game\r\nrestart = \"Recomeçar partida\" # Checkmate practice game\r\n\r\n[play.pause]\r\ntitle = \"Pausado\"\r\nresume = \"Fechar\"\r\narrows = \"Setas: Defesa\"\r\nperspective = \"Perspectiva: Off\"\r\ncopy = \"Copiar Jogo\"\r\npaste = \"Colar Jogo\"\r\noffer_draw = \"Oferecer Empate\"\r\npractice_menu = \"Menu Práticar\"\r\nmain_menu = \"Menu Principal\"\r\n\r\n[play.drawoffer] # The draw offer UI that appears on the bottom bar\r\nquestion = \"Aceitar oferta de empate?\"\r\n\r\n[play.javascript] # Not text that's included in the html, but text that scripts use!\r\nguest_indicator = \"(Convidado)\"\r\nyou_indicator = \"(Você)\"\r\nengine_indicator = \"Computador\"\r\nplayer_name_white_generic = \"Brancas\"\r\nplayer_name_black_generic = \"Pretas\"\r\nwhite_to_move = \"Brancas jogam\"\r\nblack_to_move = \"Pretas jogam\"\r\nyour_move = \"Seu lance\"\r\ntheir_move = \"Lance dele(a)\"\r\nlost_network = \"Perca de conexão\"\r\nfailed_to_load = \"Houve falha no carregamento de um ou mais recursos. Por favor, atualize.\"\r\nplanned_feature = \"Esse recurso está planejado!\"\r\nmain_menu = \"Menu Principal\"\r\nresign_game = \"Desistir\"\r\nabort_game = \"Abortar Partida\"\r\noffer_draw = \"Oferecer Empate\" # Offer draw button text in the pause menu\r\naccept_draw = \"Aceitar Empate\" # Offer draw button text in the pause menu\r\narrows_off = \"Setas: Off\"\r\narrows_defense = \"Setas: Defesa\"\r\narrows_all = \"Setas: Todas\"\r\narrows_all_hippogonals = \"Setas: Todas (com hipogonais)\"\r\ntoggled = \"Alternado\"\r\nmenu_online = \"Jogar - Online\"\r\nmenu_local = \"Jogar - Local\"\r\ninvite_error_digits = \"Código de convite precisa ter 5 digitos.\"\r\ninvite_copied = \"Código de convite copiado para a área de transferência.\"\r\nmove_counter = \"Lance:\"\r\nconstructing_mesh = \"Constructing mesh\"\r\nrotating_mesh = \"Rotating mesh\"\r\nlost_connection = \"Perca de conexão.\"\r\nplease_wait = \"Aguarde um momento para executar essa tarefa.\"\r\nwebgl_unsupported = \"Por favor atualize seu navegador! Atualmente não suporta WebGL2...\"\r\nbigints_unsupported = \"Não há suporte para BigInts. Atualize seu navegador.\\nBigInts são necessários para tornar o tabuleiro infinito.\"\r\nshaders_failed = \"Não foi possível inicializar o programa de shaders:\"\r\nfailed_compiling_shaders = \"Ocorreu um erro na compilação dos shaders:\"\r\n# Checkmate Practice\r\nversus = \"vs\"\r\neasy = \"Easy\"\r\nmedium = \"Medium\"\r\nhard = \"Hard\"\r\ninsane = \"Insane\"\r\ncheckmate_logged_out = \"Você precisa entrar para ganhar distintivos.\"\r\ncheckmate_bronze = \"Veterano do Xeque-mate: Complete 50% de todos os confrontos de prática.\"\r\ncheckmate_silver = \"Pro do Xeque-mate: Concluir 75% de todos os confrontos de prática.\"\r\ncheckmate_gold = \"Mestre do Xeque-mate: Complete 100% de todos os checkmates de prática.\"\r\ncheckmate_bronze_unearned = \"Complete 50% de todos os xeque-mate de prática para ganhar esse distintivo.\"\r\ncheckmate_silver_unearned = \"Complete 75% de todos os xeque-mate de prática para ganhar esse distintivo.\"\r\ncheckmate_gold_unearned = \"Complete 100% de todos os xeque-mate de prática para ganhar esse distintivo.\"\r\n\r\n[play.javascript.copypaste]\r\ncopied_game = \"Jogo copiado para a área de transferência!\"\r\ncannot_paste_in_public = \"Não é possível colar o jogo em uma partida pública!\"\r\ncannot_paste_in_rated = \"Não é possível colar o jogo em uma partida com rating!\"\r\ncannot_paste_in_engine = \"Não é possível colar o jogo em uma partida com Engine!\"\r\ncannot_paste_after_moves = \"Não é possível colar o jogo depois que os movimentos são feitos!\"\r\nclipboard_denied = \"Permissão negada para a área de transferência. Pode ser seu navegador.\"\r\nclipboard_invalid = \"A área de transferência não está em uma notação ICN válida.\"\r\ngame_needs_to_specify = \"O jogo precisa especificar os metadados 'Variant' ou a propriedade 'position'.\"\r\ninvalid_wincon_white = \"As brancas têm uma condição de vitória inválida\"\r\ninvalid_wincon_black = \"As pretas têm uma condição de vitória inválida\"\r\npasting_game = \"Colando jogo...\"\r\npasting_in_private = \"Colar um jogo em uma partida privada causará uma dessincronização se o seu oponente não fizer o mesmo!\"\r\npiece_count = \"Contagem de peças\"\r\nexceeded = \"excedido\"\r\nchanged_wincon = \"Alterado as condições de vitória do xeque-mate para royalcapture e desativado a renderização de ícones. Pressione 'P' para reativar (não recomendado).\"\r\nloaded_from_clipboard = \"Jogo carregado da área de transferência!\"\r\nslidelimit_not_number = \"slideLimit gamerule deve ser um número. Recebido\"\r\n\r\n[play.javascript.rendering]\r\non = \"On\"\r\noff = \"Off\"\r\nicon_rendering_off = \"Renderização de ícones desativada.\"\r\nicon_rendering_on = \"Renderização de ícones ativada.\"\r\nperspective = \"Perspectiva\"\r\nperspective_mode_on_desktop = \"O modo de perspectiva está disponível no desktop!\"\r\nmovement_tutorial = \"WASD para mover. Espaço e shift para dar zoom.\"\r\nregenerated_pieces = \"Peças regeneradas.\"\r\n\r\n[play.javascript.invites]\r\nmove_mouse = \"Mova o mouse para reconectar.\"\r\ncannot_cancel = \"Não é possível cancelar um convite de ID indefinido.\"\r\nyou_are_white = \"Você: Brancas\"\r\nyou_are_black = \"Você: Pretas\"\r\nrandom = \"Aleatório\"\r\naccept = \"Aceitar\"\r\ncancel = \"Cancelar\"\r\ncreate_invite = \"Criar convite\"\r\ncancel_invite = \"Cancelar convite\"\r\nstart_game = \"Iniciar jogo\"\r\njoin_existing_active_games = \"Participe de jogos existentes - Jogos Ativos:\"\r\n\r\n[play.javascript.onlinegame]\r\nafk_warning = \"Você está ausente.\"\r\nopponent_afk = \"Seu oponente está ausente.\"\r\nopponent_disconnected = \"O oponente desconectou.\"\r\nopponent_lost_connection = \"O oponenteu perdeu conexão.\"\r\nauto_resigning_in = \"Desistindo automaticamente em\"\r\nauto_aborting_in = \"Abortando automaticamente em\"\r\nnot_logged_in = \"Você não está conectado. Faça login para se reconectar a este jogo.\"\r\ngame_no_longer_exists = \"Jogo não existe mais.\"\r\nanother_window_connected = \"Outra janela foi conectada.\"\r\nserver_restarting = \"Servidor reiniciando em breve...\"\r\nserver_restarting_in = \"Servidor reiniciando em\"\r\nminute = \"minuto\"\r\nminutes = \"minutos\"\r\n\r\n[play.javascript.websocket]\r\nno_connection = \"Sem conexão.\"\r\nreconnected = \"Reconectou.\"\r\nunable_to_identify_ip = \"Não é possível identificar o IP.\"\r\nonline_play_disabled = \"Jogo on-line desativado. Cookies não suportados. Tente um navegador diferente.\"\r\ntoo_many_requests = \"Muitas solicitações. Tente novamente em breve.\"\r\nmessage_too_big = \"Mensagem grande demais.\"\r\ntoo_many_sockets = \"Muitos sockets\"\r\norigin_error = \"Erro de Origem.\"\r\nconnection_closed = \"Conexão fechada inesperadamente. Mensagem do servidor:\"\r\nplease_report_bug = \"Isso nunca deveria acontecer. Relate esse bug!\"\r\n\r\n[play.javascript.termination] # O que causou o fim do jogo, é falado na lingua\r\ncheckmate = \"Xeque-mate\"\r\nstalemate = \"Afogamento\"\r\nrepetition = \"Tripla repetição\"\r\nmoverule = \"-Regra do\"  # O jogo insere um número na frente dessa frase\r\ninsuffmat = \"Material insuficiente\"\r\nroyalcapture = \"Captura da realeza\"\r\nallroyalscaptured = \"Todas peças da realeza capturadas\"\r\nallpiecescaptured = \"Todas peças capturadas\"\r\nkoth = \"King of the hill\"\r\nresignation = \"Desistência\"\r\nagreement = \"Acordo\" \r\ntime = \"Tempo esgotado\"\r\naborted = \"Cancelada\" # Partida foi cancelada (Sem troca de elo)\r\ndisconnect = \"Abandonado\" # Algum jogador saiu\r\n\r\n[play.javascript.results]\r\nyou_checkmate = \"Você venceu por Xeque-mate!\"\r\nyou_time = \"Você venceu por tempo!\"\r\nyou_resignation = \"Você venceu por desistência!\"\r\nyou_disconnect = \"Você venceu por desconexão!\"\r\nyou_royalcapture = \"Você venceu por captura da realeza!\"\r\nyou_allroyalscaptured = \"Você venceu por capturar todas as peças da realeza!\"\r\nyou_allpiecescaptured = \"Você venceu por capturar todas as peças!\"\r\nyou_koth = \"Você venceu Rei da Colina!\"\r\nyou_generic = \"Você venceu!\"\r\ndraw_stalemate = \"Empate por afogamento!\"\r\ndraw_repetition = \"Empate por repetição!\"\r\ndraw_moverule = [\"Empate pela regra dos \", \"-move-rule!\"]\r\ndraw_insuffmat = \"Empate por material insuficiente!\"\r\ndraw_agreement = \"Empate por acordo!\"\r\ndraw_generic = \"Empate!\"\r\naborted = \"Partida cancelada.\"\r\nopponent_checkmate = \"Você perdeu por Xeque-mate!\"\r\nopponent_time = \"Você venceu por tempo!\"\r\nopponen_resignation = \"Você perdeu por desistência!\"\r\nopponent_disconnect = \"Você perdeu por desconexão!\"\r\nopponent_royalcapture = \"Você perdeu por captura da realeza!\"\r\nopponent_allroyalscaptured = \"Você perdeu por pendurar todas as peças da realeza!\"\r\nopponent_allpiecescaptured = \"Você perdeu por pendurar todas as peças!\"\r\nopponent_koth = \"Você perdeu por Rei da Colina!\"\r\nopponent_generic = \"Você perdeu!\"\r\nwhite_checkmate = \"Brancas vencem por xeque-mate!\"\r\nblack_checkmate = \"Pretas vencem por xeque-mate!\"\r\nwhite_time = \"Brancas vencem por tempo!\"\r\nblack_time = \"Pretas vencem por tempo!\"\r\nwhite_resignation = \"Brancas vencem por desistência!\"\r\nblack_resignation = \"Pretas vencem por desistência!\"\r\nwhite_disconnect = \"Brancas vencem por desconexão!\"\r\nblack_disconnect = \"Pretas vencem por desconexão!\"\r\nwhite_royalcapture = \"Brancas vencem por captura da realeza!\"\r\nblack_royalcapture = \"Pretas vencem por captura da realeza!\"\r\nwhite_allroyalscaptured = \"Brancas vencem por capturar todas as peças da realeza!\"\r\nblack_allroyalscaptured = \"Pretas vencem por capturar todas as peças da realeza!\"\r\nwhite_allpiecescaptured = \"Brancas vencem por capturar todas as peças!\"\r\nblack_allpiecescaptured = \"Pretas vencem por capturar todas as peças!\"\r\nwhite_koth = \"Brancas ganham por Rei da Colina!\"\r\nblack_koth = \"Pretas ganham por Rei da Colina!\"\r\nbug_generic = \"Isso é um bug, por favor, informe!\"\r\n\r\n[terms] # Traduções estão desativadas por enquanto, a única lingua permitida é en-US\r\ntitle = \"Termos de Serviço\"\r\nwarning = [\"ESTE DOCUMENTO NÃO É JURIDICAMENTE VINCULATIVO. Somos responsáveis apenas pela versão em inglês deste documento. Esta tradução é fornecida apenas para fins informativos gerais. Você pode acessar a versão oficial em inglês \", \"aqui\", \".\"]\r\nconsent = \"Ao usar este site, você concorda em cumprir os seguintes termos. Se não concordar, você deve parar imediatamente de usar o site.\"\r\nguardian_consent = \"Se você for menor de 18 anos, deverá receber o consentimento de um dos pais ou responsável legal para usar este site e criar uma conta.\"\r\nparents_header = \"Pais\"\r\nparents_paragraphs = [\r\n\"Há um algoritmo em vigor para proibir que os usuários definam seus nomes com palavrões comuns. No momento, não há nenhum método de comunicação entre os membros do site.\",\r\n\"Atualmente, os membros não podem definir sua própria foto de perfil. Há um plano para permitir esse recurso. Nesse momento, faremos o possível para evitar fotos de perfil inadequadas\",\r\n]\r\nfair_play_header = \"Jogo limpo\"\r\nfair_play_paragraph1 = [\"Não é possível criar mais de uma conta.\"]\r\nfair_play_paragraph2 = \"Para manter o jogo divertido e justo para todos, você NÃO deve:\"\r\nfair_play_rules = [\r\n\"Modificar ou manipular o código de qualquer forma, incluindo, mas não se limitando a: Usar comandos de console, substituições locais, scripts personalizados, modificar solicitações http, etc. Isso pode ser feito para interromper intencionalmente o jogo ou para lhe dar uma vantagem.\",\r\n\"Em jogos classificados, receba ajuda/aconselhamento de outra pessoa ou programa sobre o que você deve jogar. (Criar um mecanismo é aceitável e incentivado, mas você deve limitar seu uso a jogos sem classificação)\",\r\n\"Trocar pontos de elo com outras pessoas, perdendo propositalmente com a intenção de aumentar o elo de seu oponente ou recebendo pontos de elo de um oponente que pretende perder para aumentar sua própria classificação. Isso abusa do sistema e cria classificações imprecisas de acordo com seu nível de habilidade.\"\r\n]\r\ncleanliness_header = \"Cavalheirismo\"\r\ncleanliness_rules = [\r\n\"Em toda a sua linguagem no site, você deve permanecer limpo, sem vulgaridade ou xingamentos. Você não pode intimidar, assediar ou ameaçar ninguém, nem fazer nada que seja ilegal. Não é permitido enviar spam a outros usuários ou fóruns.\",\r\n\"Você não pode carregar imagens em seu perfil que sejam inadequadas, sugestivas ou sangrentas. Fazer isso pode resultar em banimento ou encerramento de sua conta.\"\r\n]\r\nprivacy_header = \"Privacidade\"\r\nprivacy_rules = [\r\n\"Atualmente, as únicas informações pessoais que coletamos são o e-mail. A intenção é verificar as contas dos usuários e fornecer um meio de provar quem eles são quando solicitam uma redefinição de senha. Não enviamos nenhum e-mail promocional ou ofertas. Não compartilhamos o endereço de e-mail de nenhum usuário com ninguém.\",\r\n\"InfiniteChess.org pode coletar dados sobre seu uso no site, incluindo seu endereço IP. O objetivo é ajudar a prevenir ataques de bots e outras entidades indesejadas e manter estatísticas precisas no banco de dados. Este NÃO é o seu endereço residencial.\",\r\n\"Todos os jogos que você joga neste site se tornam informações públicas. Se desejar permanecer anônimo, não compartilhe seu nome de usuário com amigos ou familiares. Se esse for o seu desejo, é sua responsabilidade garantir que ninguém descubra que seu nome de usuário está associado à sua identidade humana.\",\r\n\"O status on-line de sua conta e a última vez que esteve ativo no site também são informações públicas.\",\r\n[\"Embora o InfiniteChess.org se esforce para manter a conta e as informações pessoais de todos seguras da melhor forma possível, no caso de uma invasão ou vazamento de dados, você não pode nos acusar. Se ocorrer um vazamento de dados, os usuários serão notificados na página \", \"Notícias\", \".\"],\r\n\"Não há conteúdo disponível no site para compra. Nenhuma outra informação pessoal é coletada.\",\r\n\"Para que suas informações privadas sejam excluídas de nossos servidores, você pode excluir sua conta por meio da página de perfil. A única coisa vinculada ao seu nome de usuário que NÃO será excluída é o seu histórico de jogos, pois todos os jogos são informações públicas.\",\r\n]\r\ncookie_header = \"Políticas de Cookies\"\r\ncookie_paragraphs = [\r\n\"Este site usa cookies, que são pequenos arquivos de texto armazenados em seu navegador e enviados ao servidor quando são feitas conexões. A finalidade desses cookies é: Validar sua sessão de login, validar que seu navegador pertence ao jogo de xadrez em que diz estar e armazenar as preferências de jogo do usuário para que ele possa manter suas preferências quando visitar o site novamente. O site não usa cookies de terceiros e os cookies não são compartilhados com terceiros.\",\r\n\"Os cookies são necessários para que este site e o jogo funcionem corretamente. Se não quiser que o site armazene cookies, você deverá parar de usar o site. Você pode navegar até as preferências do seu navegador para excluir os cookies existentes. Ao continuar a usar este site, você estará consentindo com o uso de cookies.\"\r\n]\r\nconclusion_header = \"Conclusão\"\r\nconclusion_paragraphs = [\r\n\"Qualquer violação desses termos poderá resultar em banimento ou encerramento de sua conta. O InfiniteChess.org quer dar a todos a oportunidade de jogar e se divertir! No entanto, reservamo-nos o direito de, a qualquer momento, banir ou encerrar as contas de qualquer usuário, por motivos que não precisam ser divulgados. Não podem ser feitas acusações contra nós.\",\r\n[\"Estes termos de serviço podem ser modificados a qualquer momento. É SUA responsabilidade garantir que você esteja atualizado sobre as últimas alterações! Quando estes termos de serviço receberem uma atualização, essa informação será publicada na página\", \"Notícias\", \". Se, no momento da atualização dos termos de serviço, você não concordar com os novos termos, deverá interromper imediatamente o uso do site. Você pode excluir sua conta na sua página de perfil. Se você excluir sua conta, todas as suas informações privadas e dados da conta serão excluídos, EXCETO que não excluiremos o histórico de jogos associado ao seu nome de usuário, que é uma informação pública.\"],\r\n[\"Este site é de código aberto. Você pode copiar ou distribuir qualquer conteúdo deste site, desde que siga as condições descritas em\", \"os termos da licença\", \"! Se esse link estiver quebrado, é sua responsabilidade encontrar os meios.\"],\r\n\"Não podemos garantir que o site estará funcionando 100% do tempo. Também não podemos garantir que os dados nunca serão corrompidos.\",\r\n\"Você não pode realizar nenhuma atividade ilegal no site.\",\r\n[\"Se tiver alguma dúvida sobre esses termos ou qualquer outra questão sobre o site,\", \"envie-nos um e-mail!\"]\r\n]\r\nupdate = \"(Última atualização: 7/13/24. Adicionado o aviso de que todos os jogos jogados podem se tornar informações públicas, incluindo o último tempo aproximado em que sua conta esteve ativa. Além disso, esses termos podem ser atualizados a qualquer momento, e é de sua responsabilidade manter-se atualizado).\"\r\nthanks = \"Muito obrigado!\"\r\n\r\n[login]\r\ntitle = \"Entrar\" # The tab name\r\nusername = \"Usuário:\"\r\npassword = \"Senha:\"\r\nlogin_button = \"Entrar\"\r\nsend_reset_link = \"Enviar Link de Redefinição\"\r\nforgot_question = \"Esqueceu a Senha?\"\r\nback_to_login = \"Voltar ao Login\"\r\nforgot_instruction = \"Por favor, insira o endereço de e-mail associado à sua conta.\"\r\n\r\n[login.javascript]\r\nnetwork-error = \"A network error occurred. Please try again.\"\r\n\r\n[reset_password]\r\ntitle = \"Redefinir sua Senha\"\r\ninstruction = \"Por favor digite sua Senha e confirme sua nova Senha.\"\r\nnew_password = \"Nova Senha\"\r\nconfirm_password = \"Confirmar Senha\"\r\nsubmit_button = \"Redefinir sua Senha\"\r\n\r\n[error-pages] # Mensagens de erro mostradas em algumas páginas, explicando o que houve de errado\r\n400_message = \"Parâmetros inválidos foram recebidos.\"\r\n409_message = [\"Pode ter havido um conflito de nome de usuário ou e-mail. Por favor \", \"recarregue\", \", ta página.\"]\r\n500_message = \"Isso não deveria acontecer. Há algum debug a ser feito!\"\r\n\r\n[news]\r\ntitle = \"Notícias\"\r\nmore_dev_logs = [\"Mais registros de desenvolvimento são postados no \", \"discord oficial\", \", e nos \", \"fóruns do chess.com!\"]\r\n\r\n[server.javascript]\r\nws-invalid_username = \"Nome de Usuário é inválido\"\r\nws-incorrect_password = \"Senha incorreta\"\r\nws-login_failure_retry_in = \"Falha no login, tente novamente em\"\r\nws-seconds = \"segundos\"\r\nws-second = \"segundo\"\r\nws-username_length = \"Nome de usuário deve ter de 3 a 20 caracteres\"\r\nws-username_letters = \"Nome de usuário deve conter apenas letras de A-Z e números de 0-9\"\r\nws-username_taken = \"Esse nome de usuário já está em uso\"\r\nws-username_bad_word = \"Esse nome de usuário contém uma palavra que não é permitida\"\r\nws-username_reserved = \"Esse nome de usuário é reservado\"\r\nws-email_too_long = \"Seu e-mail é muito loooooooongo.\"\r\nws-email_invalid = \"Este não é um e-mail válido\"\r\nws-email_in_use = \"Este e-mail já está em uso\"\r\nws-email_domain_invalid = \"Domínio Inválido.\"\r\nws-you_are_banned = \"Você foi banido.\"\r\nws-password_length = \"A senha deve ter entre 6-72 caracteres\"\r\nws-password_format = \"A senha está em um formato incorreto\"\r\nws-password_password = \"A senha não pode ser 'password'\"\r\nws-password-reset-link-sent = \"Se existir uma conta com esse e-mail, um link de redefinição de senha foi enviado.\"\r\nws-password-change-success = \"A senha foi redefinida com sucesso. Você será redirecionado para a página de login em breve.\"\r\nws-password-reset-token-invalid = \"O token de redefinição de senha é inválido ou expirou.\"\r\nws-forbidden_wrong_account = \"Proibido. Esta não é a sua conta.\"\r\nws-deleting_account_not_found = \"Falha ao excluir a conta. Conta não encontrada.\"\r\nws-deleting_account_in_game = \"Você não pode excluir sua conta enquanto estiver conectado a um jogo online.\"\r\nws-server_error = \"Desculpe, ocorreu um erro no servidor! Por favor, volte.\"\r\nws-not_found = \"404 Não Encontrado\"\r\nws-forbidden = \"Proibido.\"\r\nws-already_in_game = \"Você já está em um jogo.\"\r\nws-server_restarting = \"O servidor reiniciará em\"\r\nws-server_under_maintenance = \"O servidor está em manutenção. Verifique novamente mais tarde\"\r\nws-minutes = \"minutos\"\r\nws-minute = \"minuto\"\r\nws-game_aborted_cheating = \"Jogo abortado por provável trapaça.\"\r\nws-cannot_resign_finished_game = \"Não é possível desistir do jogo, ele já terminou.\"\r\nws-invalid_code = \"Código Inválido\"\r\nws-game_aborted = \"Jogo abortado.\"\r\nws-rated_invite_verification_needed = \"Para jogar ranqueado, você precisa estar logado em uma conta verificada.\"\r\n"
  },
  {
    "path": "translation/ru-RU.toml",
    "content": "name = \"Русский\" # Name of language\r\nenglish_name = \"Russian\"\r\ndirection = \"ltr\" # Change to \"rtl\" for right to left languages\r\nversion = \"76\"\r\nmaintainer = \"Function f(x)\"\r\n\r\n[header]\r\nhome = \"Бесконечные Шахматы\"\r\nplay = \"Играть\"\r\nnews = \"Новости\"\r\nlogin = \"Войти\"\r\nprofile = \"Профиль\"\r\ncreateaccount = \"Создать аккаунт\"\r\nlogout = \"Выйти\"\r\nleaderboard = \"Таблица лидеров\"\r\n\r\n[header.settings]\r\nlanguage = \"Язык\"\r\nappearance = \"Внешний вид\" # Board color/theme and visual effects\r\nappearance-theme = \"Тема\"\r\nappearance-starfield = \"Звёздное небо\" # The Starfield space animation underneath void \r\nappearance-advanced-effects = \"Расширенные эффекты\" # Post processing and board tile effects at extreme distances\r\nlegalmoves = \"Легальные ходы\" # Legal moves shape\r\nlegalmoves-squares = \"Квадраты\"\r\nlegalmoves-dots = \"Точки\" # Dots and 4 corner triangles\r\nselection = \"Выбор\"\r\nselection-drag = \"Перетаскивание фигур\"\r\nselection-premove = \"Премувы\"\r\nselection-animations = \"Анимации\"\r\nselection-lingering_annotations = \"Постоянные аннотации\"\r\nperspective = \"Перспектива\" # Perspective-mode\r\nperspective-mouse-sensitivity = \"Чувствительность мыши\"\r\nperspective-fov = \"Поле зрения\"\r\nsound = \"Звук\"\r\nsound-master-volume = \"Общая громкость\"\r\nsound-ambience = \"Атмосфера\"\r\nping = [\"Пинг\", \"мс\"] # A number is inserted between these 2 strings.\r\nreset-to-default = \"Сброс настроек\"\r\n\r\n[footer]\r\ncontact = \"Связаться с нами\"\r\nterms_of_service = \"Условия использования\"\r\nsource_code = \"Исходный код\"\r\nlanguage = \"Язык\"\r\n\r\n[member.javascript]\r\njs-confirm_delete = \"Вы уверены, что хотите удалить свою учётную запись? Это действие НЕВОЗМОЖНО отменить! Нажмите «Да», чтобы ввести пароль.\"\r\njs-enter_password = \"Введите пароль чтобы удалить свой аккаунт НАВСЕГДА:\"\r\n\r\n[leaderboard.javascript]\r\nsupported_variants = \"Эта таблица лидеров используется для следующих вариантов:\"\r\nrank = \"Место\"\r\nplayer = \"Игрок\"\r\nrating = \"Рейтинг\"\r\n\r\n[index]\r\ntitle = \"Бесконечные Шахматы | Домашняя страница - Официальный сайт\" # The tab title\r\nsecondary_title = \"Официальный сайт для игры онлайн!\"\r\nwhat_is_it_title = \"Что это?\"\r\nwhat_is_it_pargaraphs = [\r\n\"Бесконечные Шахматы это шахматный вариант где нет границ, гораздо больше чем твоя привычка доска 8x8. У ферзя, ладей и слонов <em>нет предела</em> как далеко они могут пойти за один ход. Выбери любое натуральное число до бесконечности!\",\r\n\"Без предела как далеко можно ходить, возможны позиции где часы Судного дня, или мат-в-<em>пробел</em>, числа представленного первым бесконечным ординалом, <strong>омегой ω</strong>. Более того, исследователи обнаружили что <strong>любой</strong> счётный ординал достижим для матовых часов!\",\r\n\"Как ты можешь представить, здесь бесконечность возможностей для стартовых конфигураций, большинство из которых ты можешь полностью испытать! Твоя главная цель это всё ещё мат, который теперь требует новой тактики так как теперь нет стен где можно заманить в ловушку вражеского короля. Партии обычно длятся ненамного дольше обычных шахматных. Пешки так же превращаются на 1й и 8й горизонталях!\",\r\n]\r\nhow_to_title = \"Как я могу играть?\"\r\nhow_to_paragraph = [\"Текущая версия релиза это 1.10 на странице \",\"Играть\",\"!\"]\r\nabout_title = \"Про проект\"\r\nabout_paragraphs = [\r\n\"Я Naviary. С того момента когда я придумал Бесконечные Шахматы (эта концепция существовала задолго до этого сайта), они и их возможности меня очень заинтриговали! До недавнего времени играть было довольно сложно, поскольку игрокам приходилось создавать изображения текущей доски и отправлять их друг другу для каждого сделанного хода. Поэтому не так много людей знали или имели возможность в это сыграть.\",\r\n[\"Моя цель — сделать игру доступной для всех и сформировать вокруг нее сообщество. Я провёл бесчисленное количество часов своего времени на этом сайте, поддерживая его и разрабатывая игру. У меня ещё много идей, которые займут меня надолго. Хоть я и хочу, чтобы игра была бесплатной, жизнь имеет свои особенности. Чтобы поддержать меня финансово, пожалуйста, рассмотрите возможность присоединиться к моему \", \"Patreon\", \".\"] # Patreon receives a hyperlink, here\r\n]\r\npatreon_title = \"Поддержавшие на Patreon\"\r\ngithub_title = \"Участники Github\"\r\n\r\n[index.javascript]\r\ncontribution_count_singular = [\"\", \" участник\"] # A number is inserted between these 2 strings.\r\ncontribution_count_plural = [\"\", \" участников\"]\r\n\r\n[credits]\r\ntitle = \"Благодарности\"\r\ncopyright = \"Все материалы на сайте, не указанные ниже, являются объектом авторского права www.InfiniteChess.org.\"\r\nvariants_heading = \"Варианты\"\r\nvariants_credits = [\r\n\"Ядро разработал Andreas Tsevas.\",\r\n\"Космос разработал Andreas Tsevas.\",\r\n\"Классический космос разработал Andreas Tsevas.\",\r\n\"Шахматы на бесконечной плоскости разработал V. Reinhart.\",\r\n\"Пешечную орду разработал Inaccessible Cardinal.\",\r\n\"Изобилие разработал Clicktuck Suskriberz.\",\r\n\"Пешкандард - SexyLexi.\",\r\n\"Классические+ - SexyLexi.\",\r\n\"Линия коней - Inaccessible Cardinal.\",\r\n\"Конные шахматы - cycy98.\",\r\n\" разработали Cory Evans и Joel Hamkins.\",\r\n\" разработал Andreas Tsevas.\",\r\n\" разработал Cory Evans и Joel Hamkins.\",\r\n\" разработали Cory Evans, Joel Hamkins, и Norman Lewis Perlmutter.\",\r\n\"Шахматы на бесконечной плоскости - Вариант с Гюйгенсом - V. Reinhart.\",\r\n\"Ограниченные классические - Andreas Tsevas.\",\r\n\"Шахматы 4x4x4x4 - Andreas Tsevas.\",\r\n\"5D Шахматы - Jace.\",\r\n]\r\ntextures_heading = \"Текстуры\"\r\ntextures_licensed_under = \"текстуры лицендированы под\"\r\nsounds_heading = \"Звуки\"\r\nsounds_credits = [\r\n[\"Некоторые звуки предоставлены\", \"проект в рамках\"],\r\n\"Другие звуки созданы Naviary.\",\r\n]\r\ncode_heading = \"Код\"\r\ncode_credits = [\r\n\"Brandon Jones и Colin MacKenzie IV.\",\r\n\"Andreas Tsevas и Naviary.\",\r\n]\r\nlanguage_heading = \"Переводы на языки\"\r\nlanguage_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded.\r\n\t\"Французский - \", \"Life Enjoyer\", \" и \", \"cycy98\", \".\",\r\n\t\"Упрощённый китайский - \", \"Heinrich Xiao\", \".\",\r\n\t\"Традиционный китайский - \", \"Heinrich Xiao\", \".\",\r\n\t\"Польский - \", \"Tymon Becella\", \".\", # Apsurt\r\n\t\"Португальский - \", \"Emerson P. Machado\", \".\", # The_Skeleton on discord\r\n\t\"Испанский - \", \"xa31er\", \".\",\r\n\t\"Немецкий - \", \"Estetique\", \".\"\r\n]\r\n\r\n[member]\r\ntitle = \"Пользователь\" # The tab name\r\nverify_message = \"Пожалуйста проверьте вашу электронную почту чтобы верифицировать ваш аккаунт. Не верифицированные аккаунты удаляются через 3 дня.\"\r\nresend_message = [\"Не получили письмо? Проверьте папку \\\"Спам\\\". Также можно \", \"отправить его заново.\", \" Если вы всё равно не можете найти, \", \"напишите нам.\"]\r\nverify_confirm = \"Спасибо! Ваш аккаунт был верифицирован.\"\r\njoined = \"Присоединился:\"\r\nseen = [\"Был в сети:\", \" назад\"]\r\npractice_progress = \"Прогресс в режиме практики:\"\r\nranked_elo = \"Рейтинг:\"\r\ninfinity_leaderboard_position = \"Место в мировом рейтинге:\"\r\ninfinity_leaderboard_rating_deviation = \"Отклонение рейтинга:\"\r\nreveal_info = \"Показать информацию об аккаунте\"\r\naccount_info_heading = \"Информация об аккаунте\"\r\nemail = \"Электронная почта:\"\r\ndelete_account = \"Удалить аккаунт\"\r\n\r\n[member.badge-tooltips]\r\ncheckmate_bronze = \"Ветеран мата: Пройти 50% от всех матов в практике.\"\r\ncheckmate_silver = \"Матовый про: Пройти 75% от всех матов в практике.\"\r\ncheckmate_gold = \"Шахматный мастер: Пройти 100% от всех матов в практике.\"\r\n\r\n[create-account]\r\ntitle = \"Регистрация\" # The tab name\r\nusername = \"Имя пользователя:\"\r\nemail = \"Электронная почта:\"\r\npassword = \"Пароль:\"\r\ncreate_button = \"Зарегистрироваться\"\r\nagreement = [\"Я соглашаюсь с \", \"Условиями использования\", \".\"]  # the middle entry is a hyperlink, the others are not\r\n\r\n[create-account.javascript]\r\njs-username_specs = \"Имя пользователя должно быть как минимум 3 символа, и содержать только буквы A-Z и цифры 0-9\"\r\njs-username_tooshort = \"Имя пользователя должно быть как минимум 3 символа\"\r\njs-username_wrongenc = \"Имя пользователя должно содержать только буквы A-Z и цифры 0-9\"\r\njs-email_invalid = \"Это некорректная электронная почта\"\r\njs-email_inuse = \"Эта электронная почта уже используется\"\r\n\r\n[reset-password.javascript]\r\njs-pwd_no_match = \"Пароли не совпадают.\"\r\nreset-password = \"Сбросить пароль\"\r\nprocessing = \"Обработка...\"\r\nnetwork-error = \"Произошла сетевая ошибка. Попробуйте еще раз.\"\r\n\r\n[password-validation]\r\njs-pwd_incorrect_format = \"Пароль имеет неверный формат\"\r\njs-pwd_too_short = \"Пароль должен содержать не менее 6 символов\"\r\njs-pwd_too_long = \"Пароль не может быть длиннее 72 символов\"\r\njs-pwd_not_pwd = \"Пароль не должен быть словом 'password'\"\r\n\r\n[leaderboard]\r\ntitle = \"Таблица лидеров\"\r\ninactive_players = \"Неактивные игроки с высокой погрешностью рейтинга не включаются в таблицу лидеров.\"\r\nyour_global_ranking = \"Ваше место в мировом рейтинге:\"\r\nshow_more = \"Показать больше...\"\r\n\r\n[play]\r\ntitle = \"Бесконечные Шахматы - Играть\" # The tab title\r\nloading = \"ЗАГРУЗКА\"\r\nerror = \"ОШИБКА\"\r\n\r\n[play.main-menu]\r\ncredits = \"Благодарности\"\r\nplay = \"Играть\"\r\npractice = \"Практика\"\r\nguide = \"Гайд\"\r\neditor = \"Редактор доски\"\r\n\r\n[play.guide]\r\ntitle = \"Гайд\"\r\nrules = \"Правила\"\r\nrules_paragraphs = [\r\n\"Правила бесконечных шахмат почти идентичны классическим, за исключением того, что доска бесконечна во всех направлениях! Вот все особенности и изменения, о которых вам нужно знать:\",\r\n\"Фигуры с дальнобойными ходами, такие как ладьи, слоны и ферзь, не имеют ограничений по расстоянию за один ход! Пока их путь не перекрыт, вы можете переместиться на миллионы клеток!\",\r\n[\"В стандартном варианте \\\"Classical\\\" белые пешки превращаются на 8-й горизонтали, а чёрные — на 1-й. На изображении это обозначено тонкими чёрными линиями — они едва заметны, попробуйте их найти! Пешкам нужно лишь достичь противоположной линии для превращения, \", \"не\", \" пересечь её.\"],\r\n\"Клетки больше не обозначаются буквами и цифрами (например, a1); вместо этого каждая клетка определяется парой координат x и y. Клетка a1 стала (1,1), а h8 — (8,8). На десктопных устройствах координаты клетки под курсором отображаются в верхней части экрана.\",\r\n\"Все остальные правила такие же, как в классических шахматах: мат, пат, троекратное повторение позиции, правило 50 ходов, рокировка, мечта каждой пешки - EN PASSANT, и так далее!\"\r\n]\r\ncareful_heading = \"Осторожно!\"\r\ncareful_paragraphs = [\r\n\"Открытость бесконечной доски делает вилки, связки и линейные удары особенно опасными. Ваши тыловые позиции часто уязвимы. Будьте начеку против таких тактических приёмов! Проявляйте изобретательность в защите короля и ладей! Стратегия дебюта здесь совершенно иная, чем в классических шахматах.\",\r\n\"Многие другие варианты были созданы специально для усиления защиты тыловых позиций.\"\r\n]\r\ncontrols_heading = \"Управление\"\r\ncontrols_paragraph = \"Кликните и перетаскивайте доску, чтобы перемещаться. Используйте колесо мыши для увеличения и уменьшения масштаба. Кликните на любую фигуру, включая фигуры противника, чтобы в любой момент увидеть её возможные ходы! Дополнительные элементы управления:\"\r\nkeybinds = [\r\n\"Стрелки для перемещения по доске.\",\r\n[\"Пробел\", \" и \", \"Shift\", \" для увеличения и уменьшения масштаба.\"],\r\n[\"Escape\", \" для паузы в игре.\"],\r\n[\"Tab\", \" переключает индикаторы-стрелки на краях экрана, указывающие на фигуры за пределами видимой области. По умолчанию этот режим установлен в «Защита», что отображает стрелки, указывающие на все фигуры, которые могут переместиться на ваш экран в их направлении движения. Но \", \"Tab\", \" может переключить этот режим в «Все» или «Выкл»; «Все» отображает стрелки для всех фигур, независимо от того, могут ли они переместиться на ваш экран. Эту настройку также можно изменить в меню паузы. Клик по стрелке мгновенно переместит вас к фигуре, на которую она указывает.\"],\r\n[\"Control\", \" будет принудительно перемещать доску вместо перетаскивания фигуры, если перетаскивание включено в настройках.\"],\r\n\" переключает «Режим редактирования» в локальных играх. Это позволяет перемещать любую фигуру в любое место на доске! Очень полезно для анализа.\"\r\n]\r\ncontrols_paragraph2 = \"Это основные элементы управления, которые вам нужно знать. Но вот некоторые дополнительные возможности, если они вам когда-нибудь понадобятся!\"\r\nkeybinds_extra = [\r\n\" сбросит отображение фигур. Это полезно, если они становятся невидимыми. Такой глюк может возникнуть при перемещении на экстремальные расстояния (например, 1e21).\",\r\n\" переключает отображение панелей навигации и игровой информации, что может быть полезно для записи. Стриминг и создание видео с игрой приветствуется!\",\r\n\" переключает отображение FPS. Это показывает, сколько раз в секунду обновляется игра, а не всегда количество отрендеренных кадров, так как игра пропускает рендеринг, когда ничего видимого не изменилось, чтобы повысить производительность.\",\r\n\" переключает отображение иконок фигур. Это миниатюрные кликабельные изображения фигур, появляющиеся при достаточном отдалении. В импортированных играх с более чем 50 000 фигур эта функция автоматически отключается, так как значительно снижает производительность, но их можно снова включить с помощью \",\r\n[\"Ё\", \" переключает режим отладки.\"]\r\n]\r\nfairy_heading = \"Нестандартные фигуры\"\r\nfairy_paragraph = \"Вы уже знаете всё необходимое для игры в стандартный вариант «Классические». Нестандартные шахматные фигуры не используются в обычных шахматах, но применяются в других вариантах! Если вы окажетесь в варианте с фигурами, которые вы раньше не видели, узнайте, как они работают, здесь!\"\r\nediting_heading = \"Редактирование доски\"\r\nediting_paragraphs = [\r\n[\"Внешний \", \"редактор доски\", \" в настоящее время доступен в общедоступной Google Таблице! Там есть инструкции по использованию. Это требует базовых знаний Google Таблиц. После настройки вы сможете создавать и импортировать пользовательские позиции в игру через кнопку «Вставить игру» в меню настроек!\"],\r\n\"Чтобы сыграть в пользовательскую позицию с другом, попросите его присоединиться к приватной игре по приглашению, а затем оба можете вставить код игры, чтобы начать играть!\",\r\n\"Встроенный редактор доски всё ещё находится в планах.\"\r\n]\r\nback = \"Назад\"\r\n\r\n[play.guide.pieces]\r\nchancellor = {name=\"Канцлер\", description=\"Ходит как ладья и конь вместе.\"}\r\narchbishop = {name=\"Архиепископ\", description=\"Ходит как слон и конь вместе.\"}\r\namazon = {name=\"Амазон\", description=\"Ходит как ферзь и конь вместе. Это самая сильная фигура в игре!\"}\r\nguard = {name=\"Страж\", description=\"Ходит как король, но не может находиться под шахом или матом.\"}\r\nhawk = {name=\"Ястреб\", description=\"Совершает прыжки ровно на 2 или 3 клетки в любом направлении.\"}\r\ncentaur = {name=\"Кентавр\", description=\"Ходит как конь и страж вместе.\"}\r\nknightrider = {name=\"Гиперконь\", description=\"Совершает бесконечные прыжки как конь в одном направлении до препятствия.\"}\r\nhuygen = {name=\"Гюйгенс\", description=\"Прыгает бесконечно в одном из четырех основных направлений, посещая только клетки на расстоянии простого числа от начальной позиции, до препятствия.\"}\r\nrose = {name=\"Роза\", description=\"Круговой гиперконь. Перемещается по часовой и против часовой стрелки по круговым траекториям, совершая прыжки как конь и поворачивая на 45 градусов после каждого прыжка. Может быть заблокирован другими фигурами, поэтому красная клетка на изображении недоступна для розы.\"}\r\nobstacle = {name=\"Препятствие\", description=\"Нейтральная фигура (не контролируется ни одним игроком), которая блокирует движение, но может быть взята.\"}\r\nvoid = {name=\"Пустота\", description=\"Нейтральная фигура (не контролируется ни одним игроком), представляющая отсутствие доски. Фигуры не могут двигаться через нее или на нее.\"}\r\n\r\n[play.practice-menu]\r\ntitle = \"Практика - Мат одинокому королю\"\r\nplay = \"Играть\"\r\nback = \"Назад\"\r\ndifficulty = \"Сложность\"\r\n\r\n[play.play-menu]\r\ntitle = \"Играть - Онлайн\"\r\ncolors = \"Цвета\"\r\nonline = \"Онлайн\"\r\nlocal = \"Локально\"\r\ncomputer = \"Компьютер\"\r\nvariant = \"Вариант\"\r\nClassical = \"Классические\"\r\nConfined_Classical = \"Ограниченные Классические\"\r\nClassical_Plus = \"Классические+\"\r\nCoaIP = \"Шахматы на бесконечной плоскости\"\r\nPawndard = \"Пешкандард\"\r\nKnighted_Chess = \"Конные шахматы\"\r\nPalace = \"Дворец\"\r\nKnightline = \"Линия коней\"\r\nCore = \"Ядро\"\r\nStandarch = \"Стандархиепископ\"\r\nPawn_Horde = \"Пешечная орда\"\r\nSpace_Classic = \"Классический космос\"\r\nSpace = \"Космос\"\r\nObstocean = \"Препятствокеан\"\r\nAbundance = \"Изобилие\"\r\nAmazon_Chandelier = \"Люстра амазонов\"\r\nContainment = \"Сдерживание\"\r\nClassical_Limit_7 = \"Классические - Лимит 7\"\r\nCoaIP_Limit_7 = \"Шахматы на бесконечной плоскости - Лимит 7\"\r\nChess = \"Шахматы\"\r\nClassical_KOTH = \"Экспериментальный: Классические - Царь горы\"\r\nCoaIP_KOTH = \"Экспериментальный: Шахматы на бесконечной плоскости - Царь горы\"\r\nCoaIP_HO = \"Шахматы на бесконечной плоскости - Вариант с Гюйгенсом\"\r\nCoaIP_RO = \"Шахматы на бесконечной плоскости - Вариант с Розой\"\r\nCoaIP_NO = \"Шахматы на бесконечной плоскости - Вариант с Гиперконём\"\r\nOmega = \"Демонстрация: Омега\"\r\nOmega_Squared = \"Демонстрация: Омега^2\"\r\nOmega_Cubed = \"Демонстрация: Омега^3\"\r\nOmega_Fourth = \"Демонстрация: Омега^4\"\r\n4x4x4x4_Chess = \"Шахматы 4×4×4×4\"\r\n5D_Chess = \"5D Шахматы\"\r\nno_clock = \"Без часов\"\r\nclock = \"Часы\"\r\nminutes = \"мин\"\r\nseconds = \"сек\"\r\ninfinite_time = \"Бесконечно времени!\"\r\ncolor = \"Цвет\"\r\npiece_colors = [\"Случайный\", \"Белые\", \"Чёрные\"]\r\nprivate = \"Приватная\"\r\nno = \"Нет\"\r\nyes = \"Да\"\r\nrated = \"Рейтинговая\"\r\ncasual = \"Казуальная\"\r\njoin_games = \"Зайти в существующую - Активные игры:\"\r\nprivate_invite = \"Приватное приглашение:\"\r\nyour_invite = \"Ваш код приглашения:\"\r\ncreate_invite = \"Создать приглашение\"\r\njoin = \"Зайти\"\r\ncopy = \"Скопировать\"\r\nback = \"Назад\"\r\ncode = \"Код\"\r\n\r\n[play.gamebuttontooltips]\r\nundo_transition = \"Отменить переход\"\r\nexpand_fit_all = \"Расширить чтобы поместилось всё\"\r\nrecenter = \"Повторно центрировать\"\r\nannotations = \"Рисовать аннотации\"\r\nerase = \"Стереть аннотации\"\r\ncollapse = \"Свернуть аннотации\"\r\nrewind_move = \"Предыдущий ход\"\r\nforward_move = \"Следующий ход\"\r\nundo_edit = \"Отменить редактирование (Ctrl+Z)\" # Board editor\r\nredo_edit = \"Вернуть редактирование (Ctrl+Y)\" # Board editor\r\npause = \"Пауза\"\r\nundo = \"Отменить ход\" # Checkmate practice game\r\nrestart = \"Перезапустить партию\" # Checkmate practice game\r\n\r\n[play.pause]\r\ntitle = \"На паузе\"\r\nresume = \"Возобновить\"\r\narrows = \"Стрелки: Защита\"\r\nperspective = \"Перспектива: Выкл\"\r\ncopy = \"Скопировать игру\"\r\npaste = \"Вставить игру\"\r\noffer_draw = \"Предложить ничью\"\r\npractice_menu = \"Меню практики\"\r\nmain_menu = \"Главное меню\"\r\n\r\n[play.drawoffer] # The draw offer UI that appears on the bottom bar\r\nquestion = \"Принять ничью?\"\r\n\r\n[play.javascript] # Not text that's included in the html, but text that scripts use!\r\nguest_indicator = \"(Гость)\"\r\nyou_indicator = \"(Вы)\"\r\nengine_indicator = \"Движок\"\r\nplayer_name_white_generic = \"Белые\"\r\nplayer_name_black_generic = \"Чёрные\"\r\nwhite_to_move = \"Ход белых\"\r\nblack_to_move = \"Ход чёрных\"\r\nyour_move = \"Ваш ход\"\r\ntheir_move = \"Ход противника\"\r\nlost_network = \"Потеряна сеть.\"\r\nfailed_to_load = \"Не удалось загрузить один или несколько ресурсов. Обновите страницу.\"\r\nplanned_feature = \"Эта функция запланирована!\"\r\nmain_menu = \"Главное меню\"\r\nresign_game = \"Сдаться \"\r\nabort_game = \"Отменить игру\"\r\noffer_draw = \"Предоожить ничью\" # Offer draw button text in the pause menu\r\naccept_draw = \"Принять ничью\" # Offer draw button text in the pause menu\r\narrows_off = \"Стрелки: Выкл\"\r\narrows_defense = \"Стрелки: Защита\"\r\narrows_all = \"Стрелки: Все\"\r\narrows_all_hippogonals = \"Стрелки: Все (с гиппогонами)\"\r\ntoggled = \"Переключено\"\r\nmenu_online = \"Играть - Онлайн\"\r\nmenu_local = \"Играть - Локально\"\r\ninvite_error_digits = \"Код приглашения должен состоять из 5 цифр.\"\r\ninvite_copied = \"Код приглашения скопирован в буфер обмена.\"\r\nmove_counter = \"Ход:\"\r\nconstructing_mesh = \"Построение сетки\"\r\nrotating_mesh = \"Поворот сетки\"\r\nlost_connection = \"Потерянно соединение.\"\r\nplease_wait = \"Пожалуйста, подождите немного, чтобы выполнить эту задачу.\"\r\nwebgl_unsupported = \"Пожалуйста, обновите ваш браузер! Он не поддерживает WebGL2.\"\r\nbigints_unsupported = \"BigInt не поддерживается. Обновите браузер.\\nBigInt нужны, чтобы сделать доску бесконечной.\"\r\n# Checkmate Practice\r\nversus = \"против\"\r\neasy = \"Лёгкий\"\r\nmedium = \"Средний\"\r\nhard = \"Сложный\"\r\ninsane = \"Безумный\"\r\ncheckmate_logged_out = \"Чтобы получить значки, вам необходимо войти в систему.\"\r\ncheckmate_bronze = \"Ветеран мата: Поставить 50% от всех матов в практике.\"\r\ncheckmate_silver = \"Матовый про: Поставить 75% от всех матов в практике.\"\r\ncheckmate_gold = \"Шахматный Мастер: Поставить все маты в практике.\"\r\ncheckmate_bronze_unearned = \"Поставьте 50% всех матов в практике, чтобы получить этот значок.\"\r\ncheckmate_silver_unearned = \"Поставьте 75% всех матов в практике, чтобы получить этот значок.\"\r\ncheckmate_gold_unearned = \"Поставьте все маты в практике, чтобы получить этот значок.\"\r\ncoords-invalid = \"Неверный формат координат. Введите целые числа или e-нотацию (например 1.23e4).\"\r\ncoords-exceeded = \"Ты не можешь телепортироваться так далеко! Это было бы слишком просто ;)\"\r\n\r\n[play.javascript.piecenames] # The string representations of each raw piece type, as found in typeutil.strtypes\r\nvoid = \"Пустота\"\r\nobstacle = \"Препятствие\"\r\nking = \"Король\"\r\ngiraffe = \"Жираф\"\r\ncamel = \"Верблюд\"\r\nzebra = \"Зебра\"\r\nknightrider = \"Гиперконь\"\r\namazon = \"Амазон\"\r\nqueen = \"Ферзь\"\r\nroyalQueen = \"Королевский ферзь\"\r\nhawk = \"Ястреб\"\r\nchancellor = \"Канцлер\"\r\narchbishop = \"Архиепископ\"\r\ncentaur = \"Центавр\"\r\nroyalCentaur = \"Королевский центавр\"\r\nrose = \"Роза\"\r\nknight = \"Конь\"\r\nguard = \"Страж\"\r\nhuygen = \"Гюйгенс\"\r\nrook = \"Ладья\"\r\nbishop = \"Слон\"\r\npawn = \"Пешка\"\r\n\r\n[play.javascript.copypaste]\r\ncopied_game = \"Партия скопирована в буфер обмена!\"\r\ncannot_paste_in_public = \"Невозможно вставить партию в публичный матч!\"\r\ncannot_paste_in_rated = \"Невозможно вставить партию в рейтинговый матч!\"\r\ncannot_paste_in_engine = \"Невозможно вставить партию в матч с движком!\"\r\ncannot_paste_after_moves = \"Невозможно вставить партию после того, как сделаны ходы!\"\r\nclipboard_denied = \"Отказано в доступе к буферу обмена. Возможно, проблема в вашем браузере.\"\r\nclipboard_invalid = \"Буфер обмена не имеет допустимой нотации ICN.\"\r\ngame_needs_to_specify = \"В партии необходимо указать либо метаданные 'Variant', либо свойство 'positon'.\"\r\ninvalid_wincon = \"У игрока неверное условие выигрыша\"\r\npasting_game = \"Вставка партии...\"\r\npasting_in_private = \"Вставка партии в приватный матч приведет к рассинхронизации, если ваш противник не сделает того же самого!\"\r\npiece_count = \"Количество фигур\"\r\nexceeded = \"превышен\"\r\nchanged_wincon = \"Изменены условия победы: вместо мата используется захват королевской фигуры, а также отключено отображение иконок. Нажмите 'P', чтобы включить обратно (не рекомендуется).\"\r\nloaded_from_clipboard = \"Загружена партия из буфера обмена!\"\r\ncopied_position = \"Позиция скопирована в буфер обмена!\"\r\nloaded_position_from_clipboard = \"Загружена позиция из буфера обмена\"\r\nreset_position = \"Позиция была сброшена!\"\r\nclear_position = \"Позиция была очищена!\"\r\n\r\n[play.javascript.rendering]\r\non = \"Вкл\"\r\noff = \"Выкл\"\r\nicon_rendering_off = \"Отключено отображение значков.\"\r\nicon_rendering_on = \"Включено отображение значков.\"\r\nperspective = \"Перспектива\"\r\nperspective_mode_on_desktop = \"Режим перспективы доступен на десктопе!\"\r\nmovement_tutorial = \"WASD чтобы двигаться. Пробел и shift для масштабирования\"\r\nregenerated_pieces = \"Фигуры регенерированы\"\r\n\r\n[play.javascript.invites]\r\nmove_mouse = \"Для повторного подключения переместите мышь.\"\r\ncannot_cancel = \"Невозможно отменить приглашение с неопределенным ИД.\"\r\nyou_are_white = \"Вы: Белые\"\r\nyou_are_black = \"Вы: Чёрные\"\r\nrandom = \"Случайно\"\r\naccept = \"Принять\"\r\ncancel = \"Закрыть\"\r\ncreate_invite = \"Создать приглашение\"\r\ncancel_invite = \"Закрыть приглашение\"\r\nstart_game = \"Начать игру\"\r\njoin_existing_active_games = \"Зайти в существующую - Активные игры:\"\r\n\r\n[play.javascript.onlinegame]\r\nafk_warning = \"Вы в AFK.\"\r\nopponent_afk = \"Противник в AFK.\"\r\nopponent_disconnected = \"Противник отключился.\"\r\nopponent_lost_connection = \"Противник потерял соединение.\"\r\nauto_resigning_in = \"Авто-сдача через\"\r\nauto_aborting_in = \"Авто-отмена через\"\r\nnot_logged_in = \"Вы не вошли в систему. Пожалуйста, войдите в систему, чтобы повторно подключиться к этой партии.\"\r\ngame_no_longer_exists = \"Партия больше не существует\"\r\nanother_window_connected = \"Подключилось еще одно окно.\"\r\nserver_restarting = \"Сервер скоро перезагрузится...\"\r\nserver_restarting_in = \"Сервер перезагрузится через\"\r\nminute = \"минуту\"\r\nminutes = \"минут\"\r\n\r\n[play.javascript.websocket]\r\nno_connection = \"Нет соединения.\"\r\nreconnected = \"Переподключено.\"\r\nunable_to_identify_ip = \"Не удалось определить IP.\"\r\nonline_play_disabled = \"Онлайн-игра отключена. Файлы cookie не поддерживаются. Попробуйте другой браузер.\"\r\ntoo_many_requests = \"Слишком много запросов. Попробуйте ещё раз.\"\r\nmessage_too_big = \"Сообщение слишком большое.\"\r\ntoo_many_sockets = \"Слишком много соединений\"\r\norigin_error = \"Ошибка происхождения.\"\r\nconnection_closed = \"Соединение неожиданно разорвано. Сообщение сервера:\"\r\nplease_report_bug = \"Этого никогда не должно происходить, пожалуйста, сообщите об этой ошибке!\"\r\n\r\n[play.javascript.termination] # What caused the termination of the game, in spoken language\r\ncheckmate = \"Мат\"\r\nstalemate = \"Пат\"\r\nrepetition = \"Троекратное повторение\"\r\nmoverule = [\"Правило \", \" ходов\"]  # The game inserts a number inbetween these two strings\r\ninsuffmat = \"Недостаток материала для мата\"\r\nroyalcapture = \"Взята королевская фигура\"\r\nallroyalscaptured = \"Взяты все королевские фигуры\"\r\nallpiecescaptured = \"Взяты все фигуры\"\r\nkoth = \"Царь на горе\"\r\nresignation = \"Сдача\"\r\nagreement = \"Соглашение\" \r\ntime = \"Время вышло\"\r\naborted = \"Отменена\" # Game was cancelled (no elo exchanged)\r\ndisconnect = \"Противник вышел\" # A player left\r\n\r\n[play.javascript.results]\r\nyou_checkmate = \"Вы выиграли - объявлен мат!\"\r\nyou_time = \"Вы выиграли по времени!\"\r\nyou_resignation = \"Вы выиграли - противник сдался!\"\r\nyou_disconnect = \"Вы выиграли - противник вышел!\"\r\nyou_royalcapture = \"Вы выиграли - взята королевская фигура!\"\r\nyou_allroyalscaptured = \"Вы выиграли - взяты все королевские фигуры!\"\r\nyou_allpiecescaptured = \"Вы выиграли - взяты все фигуры!\"\r\nyou_koth = \"Вы выиграли - Царь на горе!\"\r\nyou_generic = \"Вы выиграли!\"\r\ndraw_stalemate = \"Ничья - пат!\"\r\ndraw_repetition = \"Ничья - повторение позиции!\"\r\ndraw_moverule = [\"Ничья - правило \", \" ходов!\"] # The game inserts a number inbetween these two strings\r\ndraw_insuffmat = \"Ничья - недостаток материала для мата!\"\r\ndraw_agreement = \"Ничья по соглашению!\"\r\ndraw_generic = \"Ничья!\"\r\naborted = \"Партия отменена.\"\r\nopponent_checkmate = \"Вы проиграли - объявлен мат!\"\r\nopponent_time = \"Вы проиграли по времени!\"\r\nopponent_resignation = \"Вы проиграли - вы сдались!\"\r\nopponent_disconnect = \"Вы проиграли - вы вышли!\"\r\nopponent_royalcapture = \"Вы проиграли - взята королевская фигура!\"\r\nopponent_allroyalscaptured = \"Вы проиграли - взяты все королевские фигуры!\"\r\nopponent_allpiecescaptured = \"Вы проиграли - взяты все фигуры!\"\r\nopponent_koth = \"Вы проиграли - Царь на горе!\"\r\nopponent_generic = \"Вы проиграли!\"\r\nwhite_checkmate = \"Белые выиграли - объявлен мат!\"\r\nblack_checkmate = \"Чёрные выиграли - объявлен мат!\"\r\nwhite_time = \"Белые выиграли по времени!\"\r\nblack_time = \"Чёрные выиграли по времени!\"\r\nwhite_resignation = \"Белые выиграли - противник сдался!\"\r\nblack_resignation = \"Чёрные выиграли - противник сдался!\"\r\nwhite_disconnect = \"Белые выиграли - противник вышел!\"\r\nblack_disconnect = \"Чёрные выиграли - противник вышел!\"\r\nwhite_royalcapture = \"Белые выиграли - взята королевская фигура!\"\r\nblack_royalcapture = \"Чёрные выиграли - взята королевская фигура!\"\r\nwhite_allroyalscaptured = \"Белые выиграли - взяты все королевские фигуры!\"\r\nblack_allroyalscaptured = \"Чёрные выиграли - взяты все королевские фигуры!\"\r\nwhite_allpiecescaptured = \"Белые выиграли - взяты все фигуры!\"\r\nblack_allpiecescaptured = \"Чёрные выиграли - взяты все фигуры!\"\r\nwhite_koth = \"Белые выиграли - Царь на горе!\"\r\nblack_koth = \"Чёрные выиграли - Царь на горе!\"\r\nbug_generic = \"Это ошибка, пожалуйста, сообщите!\"\r\n\r\n[terms]\r\ntitle = \"Условия использования\"\r\nwarning = [\"ЭТОТ ДОКУМЕНТ НЕ ИМЕЕТ ЮРИДИЧЕСКОЙ СИЛЫ. Мы несём ответственность только за английскую версию этого документа. Данный перевод предоставляется исключительно в информационных целях. Официальную английскую версию можно найти \", \"здесь\", \".\"]\r\nconsent = \"Используя этот сайт, вы соглашаетесь соблюдать следующие условия. Если вы не согласны, вы должны немедленно прекратить использование сайта.\"\r\nguardian_consent = \"Если вам меньше 18 лет, вы должны получить согласие родителя или законного представителя на использование сайта и создание учётной записи.\"\r\nparents_header = \"Родителям\"\r\nparents_paragraphs = [\r\n\"В системе есть алгоритм, запрещающий пользователям устанавливать в качестве имени распространённые нецензурные слова. В настоящее время на сайте отсутствует возможность общения между участниками.\",\r\n\"В настоящее время участники не могут устанавливать собственные изображения профиля. Планируется добавить эту функцию. Когда это произойдёт, мы сделаем всё возможное, чтобы предотвратить размещение неподходящих изображений профиля.\"\r\n]\r\nfair_play_header = \"Честная игра\"\r\nfair_play_paragraph1 = [\"Вы не можете иметь более одной учётной записи.\"]\r\nfair_play_paragraph2 = \"Чтобы сохранить игру справедливой и приятной для всех, вам ЗАПРЕЩАЕТСЯ:\"\r\nfair_play_rules = [\r\n\"Изменять или модифицировать код каким-либо образом, включая, помимо прочего: использование консольных команд, локальных переопределений, пользовательских скриптов, изменение HTTP-запросов, сообщений веб-сокетов и т.д. Это может быть сделано для намеренного нарушения работы игры, совершения иначе незаконных ходов или получения преимущества.\",\r\n\"Злоупотреблять ошибками или сбоями в программном обеспечении для прерывания игры, получения преимущества или для того, чтобы сделать игру невозможной для продолжения. Это может включать перемещение фигуры на экстремальные расстояния, например, на 10^15 от центральной точки.\",\r\n\"В рейтинговых играх получать помощь/советы от другого человека или программы относительно того, какой ход делать. (Создание шахматного движка допускается и поощряется, но вы должны ограничить его использование только нерейтинговыми, обычными играми)\",\r\n\"Обмениваться рейтинговыми очками с другими людьми, намеренно проигрывая с целью повышения рейтинга вашего оппонента или получая рейтинговые очки от оппонента, который намеренно проигрывает для повышения вашего рейтинга. Это нарушает систему и создаёт неточные рейтинги, не соответствующие вашему реальному уровню мастерства.\"\r\n]\r\ncleanliness_header = \"Корректное поведение\"\r\ncleanliness_rules = [\r\n\"Во всех ваших сообщениях на сайте вы должны сохранять чистоту речи: без вульгарности и нецензурной лексики. Вам запрещается издеваться, преследовать или угрожать кому-либо или совершать что-либо незаконное. Запрещается рассылать спам другим пользователям или на форумах.\",\r\n\"Вы не можете загружать изображения в свой профиль, которые являются неподходящими, откровенными или изображающими насилие. Нарушение этого правила может привести к блокировке или удалению вашей учётной записи.\"\r\n]\r\nprivacy_header = \"Конфиденциальность\"\r\nprivacy_rules = [\r\n\"В настоящее время единственная личная информация, которую мы собираем, — это ваш адрес электронной почты. Это делается с целью верификации учётных записей пользователей и предоставления средства подтверждения вашей личности при запросе сброса пароля. Мы не отправляем рекламные письма или предложения. Мы не передаём адреса электронной почты пользователей третьим лицам.\",\r\n\"InfiniteChess.org может собирать данные о вашем использовании сайта, включая ваш IP-адрес. Это предназначено для предотвращения атак ботов и других нежелательных субъектов, а также для ведения точной статистики в базе данных. Это НЕ ваш домашний адрес.\",\r\n\"Все игры, в которые вы играете на этом веб-сайте, становятся публичной информацией. Если вы хотите оставаться анонимным, не сообщайте своё имя пользователя друзьям или родственникам. Если это ваше желание, вы обязаны убедиться, что никто не узнает, что ваше имя пользователя связано с вашей реальной личностью.\",\r\n\"Статус вашей учётной записи (онлайн/оффлайн) и приблизительное время вашего последнего посещения сайта также являются публичной информацией.\",\r\n[\"Хотя InfiniteChess.org будет стремиться сохранить безопасность учётных записей и личной информации всех пользователей наилучшим образом, в случае взлома или утечки данных вы не можете возбуждать против нас судебное преследование. Если когда-либо произойдёт утечка данных, пользователи будут уведомлены на странице \", \"Новости\", \".\"],\r\n\"На сайте отсутствует контент для покупки. Любая другая личная информация не собирается.\",\r\n\"Чтобы удалить вашу личную информацию с наших серверов, вы можете удалить свою учётную запись через страницу профиля. Единственное, что связано с вашим именем пользователя и что мы НЕ будем удалять, — это история ваших игр, потому что все игры являются публичной информацией.\"\r\n]\r\ncookie_header = \"Политика использования файлов cookie\"\r\ncookie_paragraphs = [\r\n\"Этот сайт использует файлы cookie — небольшие текстовые файлы, которые хранятся в вашем браузере и отправляются на сервер при установлении соединений. Назначение этих файлов cookie: подтверждение вашей сессии входа, проверка того, что ваш браузер принадлежит к той шахматной игре, в которой он утверждает, что находится, и сохранение пользовательских настроек игры, чтобы вы могли сохранить свои предпочтения при повторном посещении сайта. Сайт не использует сторонние файлы cookie, файлы cookie не передаются внешним сторонам.\",\r\n\"Файлы cookie необходимы для корректной работы этого сайта и игры. Если вы не хотите, чтобы сайт хранил файлы cookie, вы должны прекратить использование сайта. Вы можете перейти в настройки вашего браузера, чтобы удалить существующие файлы cookie. Продолжая использовать этот сайт, вы даёте согласие на использование файлов cookie.\"\r\n]\r\nconclusion_header = \"Заключение\"\r\nconclusion_paragraphs = [\r\n\"Любые нарушения этих условий могут привести к блокировке или удалению вашей учётной записи. InfiniteChess.org стремится предоставить всем возможность играть и получать удовольствие! Однако мы оставляем за собой право в любое время блокировать или удалять учётные записи любых пользователей по причинам, которые могут не раскрываться. Против нас не может быть возбуждено судебное преследование.\",\r\n[\"Эти условия использования могут быть изменены в любой момент. ВАША ответственность — следить за последними изменениями! Когда эти условия использования обновляются, эта информация будет размещена на странице \", \"Новости\", \". Если в момент обновления условий использования вы не согласны с новыми условиями, вы должны немедленно прекратить использование веб-сайта. Вы можете удалить свою учётную запись через страницу профиля. Если вы удалите свою учётную запись, вся ваша личная информация и данные учётной записи будут удалены, ЗА ИСКЛЮЧЕНИЕМ истории ваших игр, связанной с вашим именем пользователя — это публичная информация.\"],\r\n[\"Этот сайт имеет открытый исходный код. Вы можете копировать или распространять любой материал с этого веб-сайта при условии соблюдения условий, изложенных в \", \"условиях лицензии\", \"! Если эта ссылка не работает, вы обязаны найти эти условия самостоятельно.\"],\r\n\"Мы не можем гарантировать, что сайт будет работать 100% времени. Мы также не можем гарантировать, что данные никогда не будут повреждены.\",\r\n\"Вам запрещается совершать любую незаконную деятельность на сайте.\",\r\n[\"Если у вас есть какие-либо вопросы относительно этих условий или любые другие вопросы о сайте,\", \"напишите нам по электронной почте!\"]\r\n]\r\nthanks = \"Спасибо!\"\r\n\r\n[login]\r\ntitle = \"Вход\" # The tab name\r\nusername = \"Имя пользователя:\"\r\npassword = \"Пароль:\"\r\nlogin_button = \"Войти\"\r\nsend_reset_link = \"Отправить ссылку для сброса\"\r\nforgot_question = \"Забыли пароль?\"\r\nback_to_login = \"Вернуться к входу\"\r\nforgot_instruction = \"Введите адрес электронной почты, связанный с вашей учетной записью.\"\r\n\r\n[login.javascript]\r\nnetwork-error = \"Произошла ошибка сети. Попробуйте ещё раз.\"\r\n\r\n[reset_password]\r\ntitle = \"Сброс пароля\"\r\ninstruction = \"Введите и подтвердите новый пароль.\"\r\nnew_password = \"Новый пароль\"\r\nconfirm_password = \"Подтвердить пароль\"\r\nsubmit_button = \"Сбросить пароль\"\r\n\r\n[error-pages] # Messages shown on some error pages explaining what went wrong\r\n400_message = \"Получены неверные параметры.\"\r\n409_message = [\"Возможно, имело место конфликтующее имя пользователя или адрес электронной почты. Пожалуйста \", \"перезагрузите\", \", страницу.\"]\r\n500_message = \"Этого не должно было случиться. Нужно провести некоторую отладку и всё будет в порядке!\"\r\n\r\n[news]\r\ntitle = \"Новости\" # The tab name\r\nmore_dev_logs = [\"Больше записей о разработке публикуются в \", \"официальном Discord\", \", и на \", \"форумах chess.com!\"]\r\n\r\n[server.javascript]\r\nws-invalid_username = \"Не верное имя пользователя\"\r\nws-incorrect_password = \"Неправильный пароль\"\r\nws-login_failure_retry_in = \"Не удалось войти, попробуйте еще раз через\"\r\nws-seconds = \"секунд\" # unit of time\r\nws-second = \"секунда\" # unit of time\r\nws-username_length = \"Имя пользователя должно содержать от 3 до 20 символов.\"\r\nws-username_letters = \"Имя пользователя должно содержать только буквы A-Z и цифры 0-9.\"\r\nws-username_taken = \"Это имя пользователя занято.\"\r\nws-username_bad_word = \"Это имя пользователя содержит недопустимое слово.\"\r\nws-username_reserved = \"Это имя пользователя зарезервировано.\"\r\nws-email_too_long = \"Ваш адрес электронной почты слишком длиииииииииинный\"\r\nws-email_invalid = \"Это некорректная электронная почта\"\r\nws-email_in_use = \"Эта электронная почта уже используется\"\r\nws-email_domain_invalid = \"Неверный домен.\"\r\nws-you_are_banned = \"Вы забанены.\"\r\nws-password_length = \"Пароль должен быть длиной от 6 до 72 символов.\"\r\nws-password_format = \"Пароль имеет неверный формат.\"\r\nws-password_password = \"Пароль не должен быть словом 'password'\"\r\nws-password-reset-link-sent = \"Если учетная запись с таким адресом электронной почты существует, вам была отправлена ссылка для сброса пароля.\"\r\nws-password-change-success = \"Пароль успешно сброшен. Скоро вы будете перенаправлены на страницу входа.\"\r\nws-password-reset-token-invalid = \"Токен сброса пароля недействителен или истек срок его действия.\"\r\nws-forbidden_wrong_account = \"Запрещено. Это не ваш аккаунт.\"\r\nws-deleting_account_not_found = \"Не удалось удалить аккаунт. Аккаунт не найден.\"\r\nws-deleting_account_in_game = \"Вы не сможете удалить свой аккаунт, пока вы подключены к онлайн-игре.\"\r\nws-server_error = \"Извините, произошла ошибка сервера! Пожалуйста, вернитесь.\"\r\nws-not_found = \"404 Не найдено\"\r\nws-forbidden = \"Запрещено.\"\r\nws-already_in_game = \"Вы уже в партии.\"\r\nws-server_restarting = \"Сервер перезапускается через\" # The server inserts a number immediately after this, followed by the correct plurality of minutes.\r\nws-server_under_maintenance = \"Сервер находится на обслуживании. Зайдите позже!\" # Can be changed at will to change the display message.\r\nws-minutes = \"минут\" # unit of time\r\nws-minute = \"минута\" # unit of time\r\nws-game_aborted_cheating = \"Игра прервана из-за вероятного читерства.\"\r\nws-cannot_resign_finished_game = \"Нельзя сдатся, партия уже окончена.\"\r\nws-invalid_code = \"Неверный код!\" # Invite code doesn't match any existing invites\r\nws-game_aborted = \"Партия отменена.\" # Invite was cancelled as you clicked on it\r\nws-rated_invite_verification_needed = \"Для участия в рейтинговых партиях вам необходимо войти в систему с подтвержденным аккаунтом.\""
  },
  {
    "path": "translation/zh-CN.toml",
    "content": "name = \"简体中文\" # Name of language\nenglish_name = \"Simplified Chinese\"\ndirection = \"ltr\" # Change to \"rtl\" for right to left languages\nversion = \"22\"\nmaintainer = \"Heinrich Xiao\"\n\n[header]\nhome = \"无限棋\"\nplay = \"开始\"\nnews = \"消息\"\nlogin = \"登录\"\nprofile = \"个人资料\"\ncreateaccount = \"注册\"\nlogout = \"登出\"\n\n[header.settings]\nlanguage = \"语言\"\nboard = \"棋盘\" # Board color/theme\nlegalmoves = \"合法移动\" # Legal moves shape\nlegalmoves-squares = \"方块\"\nlegalmoves-dots = \"点\" # Dots and 4 corner triangles\nperspective = \"视角\" # Perspective-mode\nperspective-mouse-sensitivity = \"鼠标灵敏度\"\nperspective-fov = \"视野范围\"\nping = [\"延迟\", \"毫秒\"] # A number is inserted between these 2 strings.\nreset-to-default = \"恢复默认\"\n\n[footer]\ncontact = \"联系\"\nterms_of_service = \"服务条款\"\nsource_code = \"程序\"\nlanguage = \"语言\"\n\n[member.javascript]\njs-confirm_delete = \"您确定要删除账号吗？这无法撤销！要是你确定删除账号，点OK。\"\njs-enter_password = \"输入密码以永久删除您的账户:\"\n\n[index]\ntitle = \"无限棋 | 首页 - 官方网站\"  # The tab title\nsecondary_title = \"现场游戏的官方网站！\"\nwhat_is_it_title = \"这是什么？\"\nwhat_is_it_pargaraphs = [\n\"无限国际象棋是一种棋类变体，没有边界，比你熟悉的8x8棋盘大得多。皇后、车和主教在每一回合中可以移动的距离<em>没有限制</em>。选择任何自然数，直至无限！\",\n\"由于移动距离没有限制，因此有可能出现末日时钟或将军<em>空白</em>位置的数字由第一个无限序数<strong>omega ω</strong>表示。事实上，研究人员已经发现<strong>任何</strong>可数序数都可以用于将军时钟！\",\n\"可以想象，起始配置有无数种可能，其中许多可以进行竞技比赛！你的最终目标仍然是将军，这需要新的策略，因为没有墙可以用来困住敌方的国王。游戏通常不会比正常的国际象棋比赛持续更久。兵仍然在第1和第8排晋升！\",\n]\nhow_to_title = \"我要怎么玩?\"\nhow_to_paragraph = [\"当前版本是1.10，你可以在\",\"游戏页面\",\"上进行游戏！\"]\nabout_title = \"关于项目\"\nabout_paragraphs = [\n\"我是Naviary。自从我第一次发现无限国际象棋（这个概念在这个网站出现之前就已经存在），我就对它及其可能性非常感兴趣！直到最近，玩这款游戏一直很困难，需要chess.com会员每次走棋时创建当前棋盘的图像并来回发送。因此，知道并能玩这款游戏的人并不多。\",\n[\"我的目标是建立一种方式，让每个人都可以轻松地玩这个游戏，并建立一个围绕它的社区。我已经花费了无数个小时在这个网站上，维护和开发游戏。我还有很多想法，这些想法会让我忙上一段时间。虽然我希望保持免费游戏，但生活有其需求，如果你能在经济上支持我，请考虑加入我的 \", \"Patreon\", \".\"], # Patreon receives a hyperlink, here\n]\npatreon_title = \"Patreon支持者\"\n\n[credits]\ntitle = \"鸣谢\"\ncopyright = \"网站上未列出的任何内容均为 www.InfiniteChess.org 的版权\"\nvariants_heading = \"变体\"\nvariants_credits = [\n\"核心设计者：Andreas Tsevas。\",\n\"空间设计者：Andreas Tsevas。\",\n\"经典空间设计者：Andreas Tsevas。\",\n\"无限平面上的国际象棋（Coaip）设计者：V. Reinhart。\",\n\"兵群设计者：Inaccessible Cardinal。\",\n\"丰富设计者：Clicktuck Suskriberz。\",\n\"Pawndard设计者：SexiLexi。\",\n\"Classical+设计者：SexiLexi。\",\n\"Knightline设计者：Inaccessible Cardinal。\",\n\"Knighted Chess设计者：cycy98。\",\n\"设计者：Cory Evans 和 Joel Hamkins。\",\n\"设计者：Andreas Tsevas。\",\n\"设计者：Cory Evans 和 Joel Hamkins。\",\n\"设计者：Cory Evans，Joel Hamkins 和 Norman Lewis Perlmutter。\",\n]\ntextures_heading = \"纹理\"\ntextures_licensed_under = \"纹理使用了\"\ntextures_credits = [\n\"金币设计者：Quolte。\",\n]\nsounds_heading = \"声音\"\nsounds_credits = [\n[\"部分声音由\", \"项目提供，使用许可为\"],\n\"其他声音由Naviary创作。\",\n]\ncode_heading = \"程序\"\ncode_credits = [\n\"由Brandon Jones 和 Colin MacKenzie IV 编写。\",\n\"由Andreas\"\n]\nlanguage_heading = \"语言翻译\"\nlanguage_credits = [\n    \"法语由 \", \"Life Enjoyer\", \" 和 \", \"cycy98\", \" 贡献。\",\n    \"简体中文由 \", \"Heinrich Xiao\", \" 贡献。\",\n    \"繁体中文由 \", \"Heinrich Xiao\", \" 贡献。\",\n    \"波兰语由 \", \"Tymon Becella\", \" 贡献。\",\n    \"葡萄牙语由 \", \"Emerson P. Machado\", \" 贡献。\", # The_Skeleton on discord\n\t\t\"西班牙语由 \", \"xa31er\", \" 贡献。\"\n]\n\n[member]\ntitle = \"会员\" # The tab name\nverify_message = \"请检查您的电子邮件以验证您的账户。未验证的账户将在 3 天后删除。\"\nresend_message = [\"没有收到？请检查您的垃圾邮件文件夹。另外，\", \"重新发送邮件。\", \"如果仍找不到，请\", \"联系我们。\"]\nverify_confirm = \"感谢您！您的账户已验证。\"\nrating = \"Elo 评级:\"\njoined = \"加入时间:\"\nseen = [\"上次在线:\", \" 前\"]\nreveal_info = \"显示账号资料\"\naccount_info_heading = \"账号资料\"\nemail = \"电子邮箱:\"\ndelete_account = \"删除账号\"\npassword_reset_message = [\"要更改您的用户名、电子邮件或密码，请\", \"联系我们。\"]\n\n[create-account]\ntitle = \"注册\"\nusername = \"账号:\"\nemail = \"电子邮箱:\"\npassword = \"密码:\"\ncreate_button = \"注册\"\nagreement = [\"我同意\", \"服务条款\", \"。\"]\n\n[create-account.javascript]\njs-username_specs = \"用户名必须至少包含 3 个字符，并且只能包含字母 A-Z 和数字 0-9\"\njs-username_tooshort = \"用户名必须多于三个字母\"\njs-username_wrongenc = \"用户名只能包含字母 A-Z 和数字 0-9。\"\njs-email_invalid = \"这不是一个有效的邮箱\"\njs-email_inuse = \"这个电子邮箱已经被用了\"\njs-pwd_incorrect_format = \"密码格式不正确\"\njs-pwd_too_short = \"密码必须多于六个字母\"\njs-pwd_too_long = \"密码禁止多于七十二个字母\"\njs-pwd_not_pwd = \"密码禁止是'password'\"\n\n[play]\ntitle = \"无限棋 - 对局\"  # The tab title\nloading = \"加载中\"\nerror = \"错误\"\n\n[play.main-menu]\ncredits = \"鸣谢\"\nplay = \"开始\"\nguide = \"指南\"\neditor = \"棋盘编辑器\"\n\n[play.guide]\ntitle = \"指南\"\nrules = \"规则\"\nrules_paragraphs = [\n\"无限国际象棋的规则与经典国际象棋几乎相同，唯一的区别是棋盘在所有方向上都是无限的！以下是您需要注意的更改和说明：\",\n\"滑动移动的棋子，如车、主教和皇后，每回合移动的距离没有限制！只要路径畅通无阻，您可以移动数百万格！\",\n[\"在“经典”默认变体中，白兵在第8排晋升，黑兵在第1排晋升。在这张图片中，细黑线表示这一点，它们很微弱，看看您是否能找到它们！兵只需要到达相对的线即可晋升，\", \"不需要\", \"越过它。\"],\n\"棋盘方格不再用字母和数字（例如a1）表示，而是用x和y坐标对来定义。a1方格变成了(1,1)，h8方格变成了(8,8)。在桌面设备上，鼠标悬停的坐标会显示在屏幕顶部。\",\n\"其他规则与经典国际象棋相同，例如将军、逼和、三次重复、50步规则、王车易位、“吃过路兵”等！\"\n]\ncareful_heading = \"小心!\"\ncareful_paragraphs = [\n\"无限棋盘的开放性意味着很容易利用叉子、钉子和斜线攻击。您的后方通常非常脆弱。小心这样的战术！在保护国王和车的过程中要有创造力！开局策略与经典国际象棋非常不同。\",\n\"为了增强您的后方，已经创建了许多其他变体。\"\n]\ncontrols_heading = \"控制\"\ncontrols_paragraph = \"点击并拖动棋盘来移动。滚动鼠标滚轮进行缩放。点击任何棋子，包括对手的棋子，在任何时候查看它们的合法移动！其他控制如下：\"\nkeybinds = [\n\" 来移动棋盘。\",\n[\"空格键\", \" 和 \", \"Shift键\", \" 来缩放。\"],\n[\"Esc键\", \" 来暂停游戏。\"],\n[\"Tab键\", \" 切换屏幕边缘的箭头指示器，用于指向屏幕外的棋子。默认情况下，此模式设置为“防御”，显示从当前位置可以移动到您所在位置的棋子的箭头。但按\", \"Tab键\", \"可以将此模式切换为“全部”或“关闭”。“全部”模式显示所有在那些直线和斜线上的棋子，无论它们是否可以直线或斜线移动。此设置也可以在暂停菜单中切换。点击这些箭头会将您传送到它们指向的棋子位置。\"],\n\" 在本地游戏中切换“编辑模式”。这允许您将任何棋子移动到棋盘上的其他位置！非常适合分析。\"\n]\ncontrols_paragraph2 = \"这些是您需要了解的主要控制。但如果您需要，这里还有一些额外的操作！\"\nkeybinds_extra = [\n\" 将重置棋子的渲染。如果它们变得不可见，这将非常有用。如果您移动极远的距离（例如1e21），可能会发生此错误。\",\n\" 将切换导航和游戏信息栏的渲染，这对录制很有用。欢迎在游戏中进行流媒体或制作视频！\",\n\" 将切换FPS计数器。这显示游戏每秒更新的次数，而不总是显示渲染的帧数，因为游戏在没有可见变化时跳过渲染以节省计算资源。\",\n\" 将切换图标渲染。这些是在您足够远地缩小时棋子的可点击缩略图。在导入超过50,000个棋子的游戏中，这将自动关闭，因为它是性能瓶颈，但您可以使用 \",\n[\" （反引号，或与 \", \"相同的键）将切换调试模式。\"],\n]\nfairy_heading = \"仙子棋子\"\nfairy_paragraph = \"您已经掌握了玩默认“经典”变体所需的知识。仙子棋子不用于常规国际象棋，但被整合到其他变体中！如果您发现自己在某个变体中遇到了一些以前没见过的棋子，让我们在这里学习它们的工作原理！\"\nediting_heading = \"棋盘编辑\"\nediting_paragraphs = [\n[\"目前有一个外部 \", \"棋盘编辑器\", \"，可在公共Google表单上使用！它包含使用说明。此工具需要一些基本的Google表单知识。设置后，您将能够通过选项菜单中的“粘贴游戏”按钮创建和导入自定义棋局位置！\"],\n\"要与朋友玩自定义棋局，请让他们加入私人邀请，然后在开始游戏之前，双方都粘贴游戏代码！\",\n\"游戏内棋盘编辑器仍在计划中。\",\n]\nback = \"返回\"\n\n[play.guide.pieces]\nchancellor = {name=\"大臣\", description=\"像车和骑士的组合一样移动。\"}\narchbishop = {name=\"主教骑士\", description=\"像主教和骑士的组合一样移动。\"}\namazon = {name=\"女皇\", description=\"像皇后和骑士的组合一样移动。这是游戏中最强大的棋子！\"}\nguard = {name=\"护卫\", description=\"像国王一样移动，但不易受将军或将死。\"}\nhawk = {name=\"鹰\", description=\"在任何方向上跳跃2或3格。\"}\ncentaur = {name=\"人马\", description=\"像骑士和护卫的组合一样移动。\"}\nknightrider = {name=\"骑士骑士\", description=\"像骑士一样在一个方向上无限跳跃，直到被阻挡。\"}\nobstacle = {name=\"障碍物\", description=\"一个中立棋子（不由任何玩家控制），阻挡移动，但可以被捕获。\"}\nvoid = {name=\"虚空\", description=\"一个中立棋子（不由任何玩家控制），表示棋盘的缺失。棋子不能穿过或移动到它上面。\"}\n\n[play.play-menu]\ntitle = \"玩 - 网上\"\ncolors = \"颜色\"\nonline = \"网上\"\nlocal = \"本地\"\ncomputer = \"计算机\"\nvariant = \"变体\"\nClassical = \"经典\"\nClassical_Plus = \"经典+\"\nCoaIP = \"无限棋盘上的国际象棋\"\nPawndard = \"兵棋\"\nKnighted_Chess = \"骑士国际象棋\"\nKnightline = \"骑士线\"\nCore = \"核心\"\nStandarch = \"标准弧\"\nPawn_Horde = \"兵群\"\nSpace_Classic = \"太空经典\"\nSpace = \"太空\"\nObstocean = \"障碍海洋\"\nAbundance = \"丰饶\"\nAmazon_Chandelier = \"亚马逊吊灯\"\nContainment = \"遏制\"\nClassical_Limit_7 = \"经典 - 限制 7\"\nCoaIP_Limit_7 = \"无限棋盘 - 限制 7\"\nChess = \"国际象棋\"\nClassical_KOTH = \"实验: 经典 - 王者争夺\"\nCoaIP_KOTH = \"实验: 无限棋盘 - 王者争夺\"\nOmega = \"展示: 欧米伽\"\nOmega_Squared = \"展示: 欧米伽²\"\nOmega_Cubed = \"展示: 欧米伽³\"\nOmega_Fourth = \"展示: 欧米伽⁴\"\nno_clock = \"没有表\"\nclock = \"表\"\nminutes = \"分钟\"\nseconds = \"秒\"\ninfinite_time = \"无限时间\"\ncolor = \"颜色\"\npiece_colors = [\"随机\", \"白\", \"黑\"]\nprivate = \"未发布\"\nno = \"不\"\nyes = \"是\"\nrated = \"评级\"\ncasual = \"休闲\"\njoin_games = \"加入现有 - 活跃游戏:\"\nprivate_invite = \"私人邀请:\"\nyour_invite = \"您的邀请码:\"\ncreate_invite = \"创建邀请\"\njoin = \"加入\"\ncopy = \"复制\"\nback = \"返回\"\ncode = \"邀请码\"\n\n[play.gamebuttontooltips]\nundo_transition = \"撤销过渡\"\nexpand_fit_all = \"展开以适应所有\"\nrecenter = \"重新居中\"\nrewind_move = \"倒回操作\"\nforward_move = \"前进操作\"\npause = \"暂停\"\n\n[play.footer]\nwhite_to_move = \"白方走起\"\nplayer_white = \"白方\"\nplayer_black = \"黑方\"\n\n[play.pause]\ntitle = \"暂停\"\nresume = \"继续\"\narrows = \"箭头: 防御\"\nperspective = \"视角: 关闭\"\ncopy = \"复制棋局\"\npaste = \"粘贴棋局\"\noffer_draw = \"提和\"\nmain_menu = \"主页\"\n\n[play.drawoffer] # The draw offer UI that appears on the bottom bar\nquestion = \"接受和棋提议\"\n\n[play.javascript]\nguest_indicator = \"(游客)\"\nyou_indicator = \"(您)\"\nwhite_to_move = \"白方走棋\"\nblack_to_move = \"黑方走棋\"\nyour_move = \"轮到您走棋\"\ntheir_move = \"轮到他走棋\"\nlost_network = \"网络丢失。\"\nfailed_to_load = \"一个或多个资源加载失败。请刷新页面。\"\nplanned_feature = \"此功能已计划！\"\nmain_menu = \"主页\"\nresign_game = \"认输\"\nabort_game = \"放弃游戏\"\noffer_draw = \"提和\" # Offer draw button text in the pause menu\naccept_draw = \"接受和棋\" # Offer draw button text in the pause menu\narrows_off = \"箭头: 关闭\"\narrows_defense = \"箭头: 防御\"\narrows_all = \"箭头: 全部\"\ntoggled = \"切换\"\nmenu_online = \"玩 - 网上\"\nmenu_local = \"玩 - 本地\"\ninvite_error_digits = \"邀请码需要5位数字。\"\ninvite_copied = \"邀请码已复制到剪贴板。\"\nmove_counter = \"步数:\"\nconstructing_mesh = \"构建网格\"\nrotating_mesh = \"旋转网格\"\nlost_connection = \"连接丢失。\"\nplease_wait = \"请稍等，正在执行此任务。\"\nwebgl_unsupported = \"您的浏览器不支持WebGL。此游戏需要WebGL才能运行。请更新您的浏览器。\"\nbigints_unsupported = \"BigInts 不受支持。请升级您的浏览器。\\nBigInts 用于使棋盘无限。\"\nshaders_failed = \"无法初始化着色器程序：\"\nfailed_compiling_shaders = \"编译着色器时发生错误：\"\n\n[play.javascript.copypaste]\ncopied_game = \"游戏已复制到剪贴板！\"\ncannot_paste_in_public = \"不能在公共比赛中粘贴游戏！\"\ncannot_paste_after_moves = \"移动后不能粘贴游戏！\"\nclipboard_denied = \"剪贴板权限被拒绝。这可能是您的浏览器问题。\"\nclipboard_invalid = \"剪贴板内容不符合有效的ICN格式。\"\ngame_needs_to_specify = \"游戏需要指定 'Variant' 元数据或 'position' 属性。\"\ninvalid_wincon_white = \"白方有无效的胜利条件\"\ninvalid_wincon_black = \"黑方有无效的胜利条件\"\npasting_game = \"正在粘贴游戏...\"\npasting_in_private = \"在私人比赛中粘贴游戏会导致不同步，如果对手没有做同样的操作！\"\npiece_count = \"棋子数量\"\nexceeded = \"超过了\"\nchanged_wincon = \"将将死胜利条件更改为royalcapture，并关闭了图标渲染。按 'P' 重新启用（不推荐）。\"\nloaded_from_clipboard = \"从剪贴板加载了游戏！\"\nloaded = \"游戏已加载！\"\nslidelimit_not_number = \"slideLimit 游戏规则必须是数字。收到\"\n\n[play.javascript.rendering]\non = \"开启\"\noff = \"关闭\"\nicon_rendering_off = \"图标渲染已关闭。\"\nicon_rendering_on = \"图标渲染已开启。\"\ntoggled_edit = \"编辑模式已切换：\"\nperspective = \"视角\"\nperspective_mode_on_desktop = \"桌面版支持视角模式！\"\nmovement_tutorial = \"WASD 移动。空格 & Shift 缩放。\"\nregenerated_pieces = \"重新生成了棋子。\"\n\n[play.javascript.invites]\nmove_mouse = \"移动鼠标以重新连接。\"\nunknown_action_received_1 = \"未知操作\"\nunknown_action_received_2 = \"从邀请订阅中接收到的服务器消息！\"\ncannot_cancel = \"无法取消未定义 ID 的邀请。\"\nyou_indicator = \"(你)\"\nyou_are_white = \"你是白方\"\nyou_are_black = \"你是黑方\"\nrandom = \"随机\"\naccept = \"接受\"\ncancel = \"取消\"\ncreate_invite = \"创建邀请函\"\ncancel_invite = \"取消邀请函\"\nstart_game = \"开始\"\njoin_existing_active_games = \"加入现有 - 活跃游戏：\"\n\n[play.javascript.onlinegame]\nafk_warning = \"你是AFK.\"\nopponent_afk = \"对手是AFK.\"\nopponent_disconnected = \"对手断开连接了\"\nopponent_lost_connection = \"对手断开连接了\"\nauto_resigning_in = \"将很快自动认输\"\nauto_aborting_in = \"将很快自动中止\"\nnot_logged_in = \"您未登录。请登录以重新连接到此游戏。\"\ngame_no_longer_exists = \"游戏不再存在。\"\nanother_window_connected = \"另一个窗口已连接。\"\nserver_restarting = \"服务器即将重新启动...\"\nserver_restarting_in = \"服务器即将重新启动\"\nminute = \"分钟\"\nminutes = \"分钟\"\n\n[play.javascript.websocket]\nno_connection = \"没有连接\"\nreconnected = \"重新连接了\"\nunable_to_identify_ip = \"无法识别IP地址\"\nonline_play_disabled = \"在线游戏已禁用。不支持Cookie。请尝试其他浏览器。\"\ntoo_many_requests = \"请求次数过多，请稍后再试。\"\nmessage_too_big = \"消息太大。\"\ntoo_many_sockets = \"套接字太多。\"\norigin_error = \"来源错误。\"\nconnection_closed = \"连接意外关闭。服务器消息。\"\nplease_report_bug = \"这不应该发生，请报告此错误！\"\n\n[play.javascript.termination] # What caused the termination of the game, in spoken language\ncheckmate = \"将死\"\nstalemate = \"僵局\"\nrepetition = \"三次重复\"\nmoverule = [\"\", \"-回合规则\"]  # The game inserts a number inbetween these two strings\ninsuffmat = \"棋子不足\"\nroyalcapture = \"王被吃\"\nallroyalscaptured = \"所有王被吃\"\nallpiecescaptured = \"所有棋子被吃\"\nthreecheck = \"三次将军\"\nkoth = \"山丘之王\"\nresignation = \"认输\"\nagreement = \"同意\" \ntime = \"超时\"\naborted = \"已中止\"  # Game was cancelled (no elo exchanged)\ndisconnect = \"弃赛\"  # A player left\n\n[play.javascript.results]\nyou_checkmate = \"你赢了，将死！\"\nyou_time = \"你超时赢了！\"\nyou_resignation = \"你赢了，对手认输！\"\nyou_disconnect = \"你赢了，对手弃权！\"\nyou_royalcapture = \"你赢了，皇族棋子被捕！\"\nyou_allroyalscaptured = \"你赢了，所有皇族棋子被捕！\"\nyou_allpiecescaptured = \"你赢了，全军覆没！\"\nyou_threecheck = \"你赢了，三步将军！\"\nyou_koth = \"你赢了，山顶王！\"\nyou_generic = \"你赢了！\"\ndraw_stalemate = \"和棋，僵局！\"\ndraw_repetition = \"和棋，局面重复！\"\ndraw_moverule = [\"和棋，\", \"步规则！\"]\ndraw_insuffmat = \"和棋，因棋子不足！\"\ndraw_agreement = \"协议和棋\"\ndraw_generic = \"和棋!\"\naborted = \"游戏中止\"\nopponent_checkmate = \"你输了，将死！\"\nopponent_time = \"你超时输了！\"\nopponent_resignation = \"你输了，对手认输！\"\nopponent_disconnect = \"你输了，对手弃权！\"\nopponent_royalcapture = \"你输了，皇族棋子被捕！\"\nopponent_allroyalscaptured = \"你输了，所有皇族棋子被捕！\"\nopponent_allpiecescaptured = \"你输了，全军覆没！\"\nopponent_threecheck = \"你输了，被三步将军！\"\nopponent_koth = \"你输了山顶王！\"\nopponent_generic = \"你输了!\"\nwhite_checkmate = \"白方将死获胜!\"\nblack_checkmate = \"黑方将死获胜!\"\nbug_checkmate = \"这是一个错误，请报告。游戏以将死结束。\"\nwhite_time = \"白方超时胜\"\nblack_time = \"黑方超时胜!\"\nbug_time = \"这是一个错误，请报告！游戏因超时结束。\"\nwhite_royalcapture = \"白方通过捕获皇族棋子获胜！\"\nblack_royalcapture = \"黑方通过捕获皇族棋子获胜！\"\nbug_royalcapture = \"这是一个错误，请报告！游戏因皇族棋子被捕获而结束。\"\nwhite_allroyalscaptured = \"白方通过吃掉所有皇族棋子获胜！\"\nblack_allroyalscaptured = \"黑方通过吃掉所有皇族棋子获胜！\"\nbug_allroyalscaptured = \"这是一个错误，请报告！游戏因所有皇族棋子被捕获而结束。\"\nwhite_allpiecescaptured = \"白方通过吃掉所有棋子获胜！\"\nblack_allpiecescaptured = \"黑方通过吃掉所有棋子获胜！\"\nbug_allpiecescaptured = \"这是一个错误，请报告！游戏因所有棋子被捕获而结束。\"\nwhite_threecheck = \"白方通过三步将军获胜！\"\nblack_threecheck = \"黑方通过三步将军获胜！\"\nbug_threecheck = \"这是一个错误，请报告！游戏被三步将军结束。\"\nwhite_koth = \"白方通过山顶之王获胜！\"\nblack_koth = \"黑方通过山顶之王获胜！\"\nbug_koth = \"这是一个错误，请报告！游戏被山顶之王结束。\"\nbug_generic = \"这是一个错误，请报告！\"\n\n[terms]\ntitle = \"服务条款\"\nwarning = [\"此文件不具有法律约束力。我们只对英文版本的文件负责。本翻译仅供一般参考。您可以在此处访问官方英文版本\", \"这里\", \"。\"]\nconsent = \"使用本网站即表示您同意遵守以下条款。如果您不同意，您必须立即停止使用本网站。\"\nguardian_consent = \"如果您未满18岁，您必须获得父母或法定监护人的同意，才能使用本网站并创建账户。\"\nparents_header = \"父母\"\nparents_paragraphs = [\n\"本网站有一个算法，用于禁止用户将其名字设置为常见的脏话。目前，网站上用户之间没有交流方式。\",\n\"目前，会员无法设置自己的个人资料图片。我们计划在未来允许此功能，届时我们将尽最大努力防止不适当的个人资料图片。\",\n]\nfair_play_header = \"公平游戏\"\nfair_play_paragraph1 = [\"您不能创建超过一个账户。如果您希望更改与账户关联的电子邮件地址，请\", \"联系我们。\"]\nfair_play_paragraph2 = \"为了让游戏保持有趣和公平，您不得：\"\nfair_play_rules = [\n\"以任何方式修改或操纵代码，包括但不限于：使用控制台命令、本地覆盖、自定义脚本、修改HTTP请求等。这样做可能是为了故意破坏游戏，或给自己带来优势。\",\n\"在评级游戏中，接受他人或程序的帮助/建议，以决定应该下什么棋。（创建引擎是可以的，并且是鼓励的，但您必须将其使用限制在非评级游戏中。）\",\n\"通过故意输棋以提升对手的Elo积分，或接受对手故意输棋以提升自己的Elo积分。这会滥用系统，导致根据您的技能水平产生不准确的评级。\"\n]\ncleanliness_header = \"清洁\"\ncleanliness_rules = [\n\"在网站上使用的所有语言中，您必须保持文明，不得使用粗俗语言或脏话。您不得欺凌、骚扰或威胁他人，或从事任何非法行为。您不得向其他用户或论坛发送垃圾信息。\",\n\"您不得上传不适当、暗示性或血腥的图像作为您的个人资料图片。这样做可能会导致您被禁止或终止账户。\"\n]\nprivacy_header = \"隐私\"\nprivacy_rules = [\n\"目前，我们收集的唯一个人信息是电子邮件。这是为了验证用户的账户，并提供在他们请求密码重置时证明身份的手段。我们不会发送任何促销电子邮件或优惠。我们不会与任何人共享用户的电子邮件地址。\",\n\"InfiniteChess.org可能会收集您在网站上使用的数据，包括您的IP地址。这是为了帮助防止来自机器人的攻击和其他不受欢迎的实体，并保持数据库中的准确统计信息。这不是您的家庭地址。\",\n\"您在本网站上玩的所有游戏都会成为公共信息。如果您希望保持匿名，请不要与朋友或家人分享您的用户名。如果这是您的愿望，您有责任确保没有人发现您的用户名与您的真实身份相关联。\",\n\"您的账户在线状态以及您上次在网站上活跃的近似时间也是公共信息。\",\n[\"尽管InfiniteChess.org将尽力在其能力范围内保护每个人的账户和个人信息，但在发生黑客攻击或数据泄露时，您不得向我们提出指控。如果发生数据泄露，用户将在\", \"新闻\", \"页面上收到通知。\"],\n\"网站上没有可购买的内容。我们不收集其他个人信息。\",\n\"要从我们的服务器中删除您的私人信息，您可以通过个人资料页面删除您的账户。唯一与您的用户名有关且我们不会删除的内容是您的游戏历史记录，因为所有游戏都是公开信息。\",\n]\ncookie_header = \"Cookie政策\"\ncookie_paragraphs = [\n\"本网站使用Cookie，Cookie是存储在您浏览器中的小型文本文件，在连接时发送到服务器。使用这些Cookie的目的是：验证您的登录会话，验证您的浏览器属于它所称的棋局，并存储用户的游戏偏好，以便他们在重新访问网站时可以保留其偏好。该网站不使用第三方Cookie，Cookie不会与外部方共享。\",\n\"Cookie是本网站和游戏正常运行所必需的。如果您不希望网站存储Cookie，您必须停止使用本网站。您可以进入浏览器偏好设置删除现有的Cookie。继续使用本网站即表示您同意使用Cookie。\"\n]\nconclusion_header = \"结论\"\nconclusion_paragraphs = [\n\"任何违反这些条款的行为可能导致您被禁止或终止账户。InfiniteChess.org希望能够为每个人提供玩乐的机会！但是，我们保留随时禁止或终止任何用户账户的权利，原因无需披露。您不得向我们提出指控。\",\n[\"这些服务条款可能随时修改。您有责任确保您保持最新！当这些服务条款更新时，该信息将发布在\", \"新闻\", \"页面上。如果在服务条款更新时，您不同意新条款，您必须立即停止使用网站。您可以从您的个人资料页面删除账户。如果您删除账户，所有您的私人信息和账户数据将被删除，除了与您的用户名相关的游戏历史记录，因为这是公开信息。\"],\n[\"此网站是开源的。只要您遵循许可条款中规定的条件，您可以复制或分发本网站上的任何内容！\", \"许可条款\", \"。如果此链接失效，您有责任找到条款。\"],\n\"我们不能保证网站将100%时间运行。我们也不能保证数据永远不会被损坏。\",\n\"您不得在网站上从事任何非法活动。\",\n[\"如果您对这些条款有任何疑问，或对网站有任何其他问题，请\", \"通过电子邮件联系我们！\"]\n]\nupdate = \"(最后更新日期：2024年7月13日。添加了警告，所有玩过的游戏可能成为公共信息，包括您账户上次活跃的大致时间。此外，这些条款可能会随时更新，您有责任确保您保持更新。)\"\nthanks = \"谢谢！\"\n\n\n[login]\ntitle = \"登录\"\nusername = \"用户名:\"\npassword = \"密码:\"\nforgot_password = [\"忘记了? \", \"给我们发邮件.\"]\nlogin_button = \"登录\"\n\n[error-pages] # Messages shown on some error pages explaining what went wrong\n400_message = \"收到无效参数。\"\n409_message = [\"可能存在冲突的用户名或电子邮件。请\", \"重新加载\", \"页面\"]\n500_message = \"这不应该发生。需要进行一些调试！\"\n\n########### NEWS ###########\n\n[news]\ntitle = \"新闻\"\nmore_dev_logs = [\"更多开发者日志在我们的\", \"Discord\", \"和在\", \"chess.com的论坛！\"]\n\n[server.javascript]\nws-invalid_username = \"用户名无效\"\nws-incorrect_password = \"密码部队\"\nws-username_and_password_required = \"需要用户名和密码\"\nws-username_and_password_string = \"用户名和密码必须是字符串\"\nws-login_failure_retry_in = \"登录失败\"\nws-seconds = \"妙\" # unit of time\nws-second = \"妙\" # unit of time\nws-username_length = \"用户名必须多于3个字母和少于20字母\"\nws-username_letters = \"用户名只能包含字母 A-Z 和数字 0-9。\"\nws-username_taken = \"那个用户名已经被用了。\"\nws-username_bad_word = \"该用户名包含不允许的词语。\"\nws-email_too_long = \"您的电子有限太长。\"\nws-email_invalid = \"这个邮箱无效。\"\nws-email_in_use = \"这个邮箱已经被用了。\"\nws-you_are_banned = \"您被封禁了\"\nws-password_length = \"密码必须多于6个字母，并且少于72个字母。\"\nws-password_format = \"密码格式不正确\"\nws-password_password = \"密码禁止是password\"\nws-refresh_token_not_found_logged_out = \"没有成员拥有该刷新令牌 (已经等处)\"\nws-refresh_token_not_found = \"没有成员拥有该刷新令牌\"\nws-refresh_token_expired = \"未找到刷新令牌（会话过期）\"\nws-refresh_token_invalid = \"刷新令牌过期或被篡改\"\nws-member_not_found = \"账户未找到\"\nws-forbidden_wrong_account = \"禁止。这不是您的账户。\"\nws-deleting_account_not_found = \"刪除帳戶失敗。找不到帳戶。\"\nws-server_error = \"抱歉，發生伺服器錯誤！請返回。\"\nws-unable_to_identify_client_ip = \"無法識別客戶端 IP 地址\"\nws-you_are_banned_by_server = \"您被禁用了\"\nws-too_many_requests_to_server = \"請求過多。請稍後再試。\"\nws-bad_request = \"400\"\nws-not_found = \"404\"\nws-forbidden = \"403\"\nws-unauthorized_patron_page = \"未经授权。此页面仅限会员访问。\"\nws-username_reserved = \"用户名已被保留\"\nws-already_in_game = \"已在游戏中\"\nws-server_restarting = \"服务器正在重启\"\nws-minutes = \"分钟\"\nws-server_under_maintenance = \"服务器正在维护中。请稍后再试！\"  # Can be changed at will to change the display message.\nws-minute = \"分钟\"\nws-no_abort_game_over = \"游戏结束无法中止\"\nws-no_abort_after_moves = \"无法在棋步后中止\"\nws-game_aborted_cheating = \"因作弊游戏中止\"\nws-cannot_resign_finished_game = \"无法放弃已完成的游戏\"\nws-invalid_code = \"无效代码\"\nws-game_aborted = \"游戏中止\"\n"
  },
  {
    "path": "translation/zh-TW.toml",
    "content": "name = \"繁體中文\" # Name of language\nenglish_name = \"Traditional Chinese\"\ndirection = \"ltr\" # Change to \"rtl\" for right to left languages\nversion = \"20\"\nmaintainer = \"Heinrich Xiao\"\n\n[header]\nhome = \"主頁\"\nplay = \"開始\"\nnews = \"消息\"\nlogin = \"登錄\"\ncreateaccount = \"注冊\"\n\n[footer]\ncontact = \"聯系\"\nterms_of_service = \"服務條款\"\nsource_code = \"程序\"\nlanguage = \"語言\"\n\n[header.javascript]\njs-profile = \"個人賬戶\"\njs-logout = \"登出\"\njs-login = \"登錄\"\njs-createaccount = \"注冊\"\n\n[member.javascript]\njs-confirm_delete = \"您確定要刪除賬號嗎？這無法撤銷！要是你確定刪除賬號，點OK。\"\njs-enter_password = \"輸入密碼以永久刪除您的賬戶:\"\n\n[index]\ntitle = \"無限棋 | 首頁 - 官方網站\"  # The tab title\nsecondary_title = \"現場游戲的官方網站！\"\nwhat_is_it_title = \"這是什麼？\"\nwhat_is_it_pargaraphs = [\n\"無限國際象棋是一種棋類變體，沒有邊界，比你熟悉的8x8棋盤大得多。皇后、車和主教在每一回合中可以移動的距離<em>沒有限制</em>。選擇任何自然數，直至無限！\",\n\"由於移動距離沒有限制，因此有可能出現末日時鐘或將軍<em>空白</em>位置的數字由第一個無限序數<strong>omega ω</strong>表示。事實上，研究人員已經發現<strong>任何</strong>可數序數都可以用於將軍時鐘！\",\n\"可以想象，起始配置有無數種可能，其中許多可以進行競技比賽！你的最終目標仍然是將軍，這需要新的策略，因為沒有牆可以用來困住敵方的國王。游戲通常不會比正常的國際象棋比賽持續更久。兵仍然在第1和第8排晉升！\",\n]\nhow_to_title = \"我要怎麼玩?\"\nhow_to_paragraph = [\"當前版本是1.10，你可以在\",\"游戲頁面\",\"上進行游戲！\"]\nabout_title = \"關於項目\"\nabout_paragraphs = [\n\"我是Naviary。自從我第一次發現無限國際象棋（這個概念在這個網站出現之前就已經存在），我就對它及其可能性非常感興趣！直到最近，玩這款游戲一直很困難，需要chess.com會員每次走棋時創建當前棋盤的圖像並來回發送。因此，知道並能玩這款游戲的人並不多。\",\n[\"我的目標是建立一種方式，讓每個人都可以輕鬆地玩這個游戲，並建立一個圍繞它的社區。我已經花費了無數個小時在這個網站上，維護和開發游戲。我還有很多想法，這些想法會讓我忙上一段時間。雖然我希望保持免費游戲，但生活有其需求，如果你能在經濟上支持我，請考慮加入我的 \", \"Patreon\", \".\"], # Patreon receives a hyperlink, here\n]\npatreon_title = \"Patreon支持者\"\n\n[credits]\ntitle = \"鳴謝\"\ncopyright = \"網站上未列出的任何內容均為 www.InfiniteChess.org 的版權\"\nvariants_heading = \"變體\"\nvariants_credits = [\n\"核心設計者：Andreas Tsevas。\",\n\"空間設計者：Andreas Tsevas。\",\n\"經典空間設計者：Andreas Tsevas。\",\n\"無限平面上的國際象棋（Coaip）設計者：V. Reinhart。\",\n\"兵群設計者：Inaccessible Cardinal。\",\n\"豐富設計者：Clicktuck Suskriberz。\",\n\"Pawndard設計者：SexiLexi。\",\n\"Classical+設計者：SexiLexi。\",\n\"Knightline設計者：Inaccessible Cardinal。\",\n\"Knighted Chess設計者：cycy98。\",\n\"設計者：Cory Evans 和 Joel Hamkins。\",\n\"設計者：Andreas Tsevas。\",\n\"設計者：Cory Evans 和 Joel Hamkins。\",\n\"設計者：Cory Evans，Joel Hamkins 和 Norman Lewis Perlmutter。\",\n]\ntextures_heading = \"紋理\"\ntextures_licensed_under = \"紋理使用了\"\ntextures_credits = [\n\"金幣設計者：Quolte。\",\n]\nsounds_heading = \"聲音\"\nsounds_credits = [\n[\"部分聲音由\", \"項目提供，使用許可為\"],\n\"其他聲音由Naviary創作。\",\n]\ncode_heading = \"程序\"\ncode_credits = [\n\"由Brandon Jones 和 Colin MacKenzie IV 編寫。\",\n\"由Andreas\"\n]\nlanguage_heading = \"語言翻譯\"\nlanguage_credits = [\n    \"法語由 \", \"Life Enjoyer\", \" 和 \", \"cycy98\", \" 貢獻。\",\n    \"繁體中文由 \", \"Heinrich Xiao\", \" 貢獻。\",\n    \"簡體中文由 \", \"Heinrich Xiao\", \" 貢獻。\",\n    \"波蘭語由 \", \"Tymon Becella\", \" 貢獻。\",\n    \"葡萄牙語由 \", \"Emerson P. Machado\", \" 貢獻。\", # The_Skeleton on discord\n\t\t\"西班牙語由 \", \"xa31er\", \" 貢獻。\"\n]\n\n[member]\ntitle = \"會員\" # The tab name\nverify_message = \"請檢查您的電子郵件以驗證您的賬戶。未驗證的賬戶將在 3 天後刪除。\"\nresend_message = [\"沒有收到？請檢查您的垃圾郵件文件夾。另外，\", \"重新發送郵件。\", \"如果仍找不到，請\", \"聯繫我們。\"]\nverify_confirm = \"感謝您！您的賬戶已驗証。\"\nrating = \"Elo 評級:\"\njoined = \"加入時間:\"\nseen = [\"上次在線:\", \" 前\"]\nreveal_info = \"顯示賬號資料\"\naccount_info_heading = \"賬號資料\"\nemail = \"電子郵箱:\"\ndelete_account = \"刪除賬號\"\npassword_reset_message = [\"要更改您的用戶名、電子郵件或密碼，請\", \"聯系我們。\"]\n\n[create-account]\ntitle = \"注冊\"\nusername = \"賬號:\"\nemail = \"電子郵箱:\"\npassword = \"密碼:\"\ncreate_button = \"注冊\"\nagreement = [\"我同意\", \"服務條款\", \"。\"]\n\n[create-account.javascript]\njs-username_specs = \"用戶名必須至少包含 3 個字符，並且隻能包含字母 A-Z 和數字 0-9\"\njs-username_tooshort = \"用戶名必須多於三個字母\"\njs-username_wrongenc = \"用戶名隻能包含字母 A-Z 和數字 0-9。\"\njs-email_invalid = \"這不是一個有效的郵箱\"\njs-email_inuse = \"這個電子郵箱已經被用了\"\njs-pwd_incorrect_format = \"密碼格式不正確\"\njs-pwd_too_short = \"密碼必須多於六個字母\"\njs-pwd_too_long = \"密碼禁止多於七十二個字母\"\njs-pwd_not_pwd = \"密碼禁止是'password'\"\n\n[play]\ntitle = \"無限棋 - 對局\"  # The tab title\nloading = \"加載中\"\nerror = \"錯誤\"\n\n[play.main-menu]\ncredits = \"鳴謝\"\nplay = \"開始\"\nguide = \"指南\"\neditor = \"棋盤編輯器\"\n\n[play.guide]\ntitle = \"指南\"\nrules = \"規則\"\nrules_paragraphs = [\n\"無限國際象棋的規則與經典國際象棋幾乎相同，唯一的區別是棋盤在所有方向上都是無限的！以下是您需要注意的更改和說明：\",\n\"滑動移動的棋子，如車、主教和皇后，每回合移動的距離沒有限制！隻要路徑暢通無阻，您可以移動數百萬格！\",\n[\"在“經典”默認變體中，白兵在第8排晉升，黑兵在第1排晉升。在這張圖片中，細黑線表示這一點，它們很微弱，看看您是否能找到它們！兵隻需要到達相對的線即可晉升，\", \"不需要\", \"越過它。\"],\n\"棋盤方格不再用字母和數字（例如a1）表示，而是用x和y坐標對來定義。a1方格變成了(1,1)，h8方格變成了(8,8)。在桌面設備上，鼠標懸停的坐標會顯示在屏幕頂部。\",\n\"其他規則與經典國際象棋相同，例如將軍、逼和、三次重復、50步規則、王車易位、“吃過路兵”等！\"\n]\ncareful_heading = \"小心!\"\ncareful_paragraphs = [\n\"無限棋盤的開放性意味著很容易利用叉子、釘子和斜線攻擊。您的后方通常非常脆弱。小心這樣的戰術！在保護國王和車的過程中要有創造力！開局策略與經典國際象棋非常不同。\",\n\"為了增強您的后方，已經創建了許多其他變體。\"\n]\ncontrols_heading = \"控制\"\ncontrols_paragraph = \"點擊並拖動棋盤來移動。滾動鼠標滾輪進行縮放。點擊任何棋子，包括對手的棋子，在任何時候查看它們的合法移動！其他控制如下：\"\nkeybinds = [\n\" 來移動棋盤。\",\n[\"空格鍵\", \" 和 \", \"Shift鍵\", \" 來縮放。\"],\n[\"Esc鍵\", \" 來暫停游戲。\"],\n[\"Tab鍵\", \" 切換屏幕邊緣的箭頭指示器，用於指向屏幕外的棋子。默認情況下，此模式設置為“防御”，顯示從當前位置可以移動到您所在位置的棋子的箭頭。但按\", \"Tab鍵\", \"可以將此模式切換為“全部”或“關閉”。“全部”模式顯示所有在那些直線和斜線上的棋子，無論它們是否可以直線或斜線移動。此設置也可以在暫停菜單中切換。點擊這些箭頭會將您傳送到它們指向的棋子位置。\"],\n\" 在本地游戲中切換“編輯模式”。這允許您將任何棋子移動到棋盤上的其他位置！非常適合分析。\"\n]\ncontrols_paragraph2 = \"這些是您需要了解的主要控制。但如果您需要，這裡還有一些額外的操作！\"\nkeybinds_extra = [\n\" 將重置棋子的渲染。如果它們變得不可見，這將非常有用。如果您移動極遠的距離（例如1e21），可能會發生此錯誤。\",\n\" 將切換導航和游戲信息欄的渲染，這對錄制很有用。歡迎在游戲中進行流媒體或制作視頻！\",\n\" 將切換FPS計數器。這顯示游戲每秒更新的次數，而不總是顯示渲染的幀數，因為游戲在沒有可見變化時跳過渲染以節省計算資源。\",\n\" 將切換圖標渲染。這些是在您足夠遠地縮小時棋子的可點擊縮略圖。在導入超過50,000個棋子的游戲中，這將自動關閉，因為它是性能瓶頸，但您可以使用 \",\n[\" （反引號，或與 \", \"相同的鍵）將切換調試模式。\"],\n]\nfairy_heading = \"仙子棋子\"\nfairy_paragraph = \"您已經掌握了玩默認“經典”變體所需的知識。仙子棋子不用於常規國際象棋，但被整合到其他變體中！如果您發現自己在某個變體中遇到了一些以前沒見過的棋子，讓我們在這裡學習它們的工作原理！\"\nediting_heading = \"棋盤編輯\"\nediting_paragraphs = [\n[\"目前有一個外部 \", \"棋盤編輯器\", \"，可在公共Google表單上使用！它包含使用說明。此工具需要一些基本的Google表單知識。設置后，您將能夠通過選項菜單中的“粘貼游戲”按鈕創建和導入自定義棋局位置！\"],\n\"要與朋友玩自定義棋局，請讓他們加入私人邀請，然后在開始游戲之前，雙方都粘貼游戲代碼！\",\n\"游戲內棋盤編輯器仍在計劃中。\",\n]\nback = \"返回\"\n\n[play.guide.pieces]\nchancellor = {name=\"大臣\", description=\"像車和騎士的組合一樣移動。\"}\narchbishop = {name=\"主教騎士\", description=\"像主教和騎士的組合一樣移動。\"}\namazon = {name=\"女皇\", description=\"像皇后和騎士的組合一樣移動。這是游戲中最強大的棋子！\"}\nguard = {name=\"護衛\", description=\"像國王一樣移動，但不易受將軍或將死。\"}\nhawk = {name=\"鷹\", description=\"在任何方向上跳躍2或3格。\"}\ncentaur = {name=\"人馬\", description=\"像騎士和護衛的組合一樣移動。\"}\nknightrider = {name=\"騎士騎士\", description=\"像騎士一樣在一個方向上無限跳躍，直到被阻擋。\"}\nobstacle = {name=\"障礙物\", description=\"一個中立棋子（不由任何玩家控制），阻擋移動，但可以被捕獲。\"}\nvoid = {name=\"虛空\", description=\"一個中立棋子（不由任何玩家控制），表示棋盤的缺失。棋子不能穿過或移動到它上面。\"}\n\n[play.play-menu]\ntitle = \"玩 - 網上\"\ncolors = \"顏色\"\nonline = \"網上\"\nlocal = \"本地\"\ncomputer = \"計算機\"\nvariant = \"變體\"\nClassical = \"經典\"\nClassical_Plus = \"經典+\"\nCoaIP = \"無限棋盤上的國際象棋\"\nPawndard = \"兵棋\"\nKnighted_Chess = \"騎士國際象棋\"\nKnightline = \"騎士線\"\nCore = \"核心\"\nStandarch = \"標准弧\"\nPawn_Horde = \"兵群\"\nSpace_Classic = \"太空經典\"\nSpace = \"太空\"\nObstocean = \"障礙海洋\"\nAbundance = \"豐饒\"\nAmazon_Chandelier = \"亞馬遜吊燈\"\nContainment = \"遏制\"\nClassical_Limit_7 = \"經典 - 限制 7\"\nCoaIP_Limit_7 = \"無限棋盤 - 限制 7\"\nChess = \"國際象棋\"\nClassical_KOTH = \"實驗: 經典 - 王者爭奪\"\nCoaIP_KOTH = \"實驗: 無限棋盤 - 王者爭奪\"\nOmega = \"展示: 歐米伽\"\nOmega_Squared = \"展示: 歐米伽²\"\nOmega_Cubed = \"展示: 歐米伽³\"\nOmega_Fourth = \"展示: 歐米伽⁴\"\nno_clock = \"沒有表\"\nclock = \"表\"\nminutes = \"分鐘\"\nseconds = \"秒\"\ninfinite_time = \"無限時間\"\ncolor = \"顏色\"\npiece_colors = [\"隨機\", \"白\", \"黑\"]\nprivate = \"未發布\"\nno = \"不\"\nyes = \"是\"\nrated = \"評級\"\ncasual = \"休閑\"\njoin_games = \"加入現有 - 活躍游戲:\"\nprivate_invite = \"私人邀請:\"\nyour_invite = \"您的邀請碼:\"\ncreate_invite = \"創建邀請\"\njoin = \"加入\"\ncopy = \"復制\"\nback = \"返回\"\ncode = \"邀請碼\"\n\n[play.gamebuttontooltips]\nundo_transition = \"撤銷過渡\"\nexpand_fit_all = \"展開以適應所有\"\nrecenter = \"重新居中\"\nrewind_move = \"倒回操作\"\nforward_move = \"前進操作\"\npause = \"暫停\"\n\n[play.footer]\nwhite_to_move = \"白方走起\"\nplayer_white = \"白方\"\nplayer_black = \"黑方\"\n\n[play.pause]\ntitle = \"暫停\"\nresume = \"繼續\"\narrows = \"箭頭: 防御\"\nperspective = \"視角: 關閉\"\ncopy = \"復制棋局\"\npaste = \"粘貼棋局\"\noffer_draw = \"提和\"\nmain_menu = \"主頁\"\n\n[play.drawoffer] # The draw offer UI that appears on the bottom bar\nquestion = \"接受和棋提議\"\n\n[play.javascript]\nguest_indicator = \"(游客)\"\nyou_indicator = \"(您)\"\nwhite_to_move = \"白方走棋\"\nblack_to_move = \"黑方走棋\"\nyour_move = \"輪到您走棋\"\ntheir_move = \"輪到他走棋\"\nlost_network = \"網絡丟失。\"\nfailed_to_load = \"一個或多個資源加載失敗。請刷新頁面。\"\nplanned_feature = \"此功能已計劃！\"\nmain_menu = \"主頁\"\nresign_game = \"認輸\"\nabort_game = \"放棄游戲\"\noffer_draw = \"提和\" # Offer draw button text in the pause menu\naccept_draw = \"接受和棋\" # Offer draw button text in the pause menu\narrows_off = \"箭頭: 關閉\"\narrows_defense = \"箭頭: 防御\"\narrows_all = \"箭頭: 全部\"\ntoggled = \"切換\"\nmenu_online = \"玩 - 網上\"\nmenu_local = \"玩 - 本地\"\ninvite_error_digits = \"邀請碼需要5位數字。\"\ninvite_copied = \"邀請碼已復制到剪貼板。\"\nmove_counter = \"步數:\"\nconstructing_mesh = \"構建網格\"\nrotating_mesh = \"旋轉網格\"\nlost_connection = \"連接丟失。\"\nplease_wait = \"請稍等，正在執行此任務。\"\nwebgl_unsupported = \"您的瀏覽器不支持WebGL。此游戲需要WebGL才能運行。請更新您的瀏覽器。\"\nbigints_unsupported = \"BigInts 不受支持。請升級您的瀏覽器。\\nBigInts 用於使棋盤無限。\"\nshaders_failed = \"無法初始化著色器程序：\"\nfailed_compiling_shaders = \"編譯著色器時發生錯誤：\"\n\n[play.javascript.copypaste]\ncopied_game = \"游戲已復制到剪貼板！\"\ncannot_paste_in_public = \"不能在公共比賽中粘貼游戲！\"\ncannot_paste_after_moves = \"移動后不能粘貼游戲！\"\nclipboard_denied = \"剪貼板權限被拒絕。這可能是您的瀏覽器問題。\"\nclipboard_invalid = \"剪貼板內容不符合有效的ICN格式。\"\ngame_needs_to_specify = \"游戲需要指定 'Variant' 元數據或 'position' 屬性。\"\ninvalid_wincon_white = \"白方有無效的勝利條件\"\ninvalid_wincon_black = \"黑方有無效的勝利條件\"\npasting_game = \"正在粘貼游戲...\"\npasting_in_private = \"在私人比賽中粘貼游戲會導致不同步，如果對手沒有做同樣的操作！\"\npiece_count = \"棋子數量\"\nexceeded = \"超過了\"\nchanged_wincon = \"將將死勝利條件更改為royalcapture，並關閉了圖標渲染。按 'P' 重新啟用（不推薦）。\"\nloaded_from_clipboard = \"從剪貼板加載了游戲！\"\nloaded = \"游戲已加載！\"\nslidelimit_not_number = \"slideLimit 游戲規則必須是數字。收到\"\n\n[play.javascript.rendering]\non = \"開啟\"\noff = \"關閉\"\nicon_rendering_off = \"圖標渲染已關閉。\"\nicon_rendering_on = \"圖標渲染已開啟。\"\ntoggled_edit = \"編輯模式已切換：\"\nperspective = \"視角\"\nperspective_mode_on_desktop = \"桌面版支持視角模式！\"\nmovement_tutorial = \"WASD 移動。空格 & Shift 縮放。\"\nregenerated_pieces = \"重新生成了棋子。\"\n\n[play.javascript.invites]\nmove_mouse = \"移動鼠標以重新連接。\"\nunknown_action_received_1 = \"未知操作\"\nunknown_action_received_2 = \"從邀請訂閱中接收到的服務器消息！\"\ncannot_cancel = \"無法取消未定義 ID 的邀請。\"\nyou_indicator = \"(你)\"\nyou_are_white = \"你是白方\"\nyou_are_black = \"你是黑方\"\nrandom = \"隨機\"\naccept = \"接受\"\ncancel = \"取消\"\ncreate_invite = \"創建邀請函\"\ncancel_invite = \"取消邀請函\"\nstart_game = \"開始\"\njoin_existing_active_games = \"加入現有 - 活躍游戲：\"\n\n[play.javascript.onlinegame]\nafk_warning = \"你是AFK.\"\nopponent_afk = \"對手是AFK.\"\nopponent_disconnected = \"對手斷開連接了\"\nopponent_lost_connection = \"對手斷開連接了\"\nauto_resigning_in = \"將很快自動認輸\"\nauto_aborting_in = \"將很快自動中止\"\nnot_logged_in = \"您未登錄。請登錄以重新連接到此游戲。\"\ngame_no_longer_exists = \"游戲不再存在。\"\nanother_window_connected = \"另一個窗口已連接。\"\nserver_restarting = \"服務器即將重新啟動...\"\nserver_restarting_in = \"服務器即將重新啟動\"\nminute = \"分鐘\"\nminutes = \"分鐘\"\n\n[play.javascript.websocket]\nno_connection = \"沒有連接\"\nreconnected = \"重新連接了\"\nunable_to_identify_ip = \"無法識別IP地址\"\nonline_play_disabled = \"在線游戲已禁用。不支持Cookie。請嘗試其他瀏覽器。\"\ntoo_many_requests = \"請求次數過多，請稍后再試。\"\nmessage_too_big = \"消息太大。\"\ntoo_many_sockets = \"套接字太多。\"\norigin_error = \"來源錯誤。\"\nconnection_closed = \"連接意外關閉。服務器消息。\"\nplease_report_bug = \"這不應該發生，請報告此錯誤！\"\n\n[play.javascript.termination] # What caused the termination of the game, in spoken language\ncheckmate = \"將死\"\nstalemate = \"僵局\"\nrepetition = \"三次重復\"\nmoverule = [\"\", \"-回合規則\"]  # The game inserts a number inbetween these two strings\ninsuffmat = \"棋子不足\"\nroyalcapture = \"王被吃\"\nallroyalscaptured = \"所有王被吃\"\nallpiecescaptured = \"所有棋子被吃\"\nthreecheck = \"三次將軍\"\nkoth = \"山丘之王\"\nresignation = \"認輸\"\nagreement = \"同意\" \ntime = \"超時\"\naborted = \"已中止\"  # Game was cancelled (no elo exchanged)\ndisconnect = \"棄賽\"  # A player left\n\n[play.javascript.results]\nyou_checkmate = \"你贏了，將死！\"\nyou_time = \"你超時贏了！\"\nyou_resignation = \"你贏了，對手認輸！\"\nyou_disconnect = \"你贏了，對手棄權！\"\nyou_royalcapture = \"你贏了，皇族棋子被捕！\"\nyou_allroyalscaptured = \"你贏了，所有皇族棋子被捕！\"\nyou_allpiecescaptured = \"你贏了，全軍覆沒！\"\nyou_threecheck = \"你贏了，三步將軍！\"\nyou_koth = \"你贏了，山頂王！\"\nyou_generic = \"你贏了！\"\ndraw_stalemate = \"和棋，僵局！\"\ndraw_repetition = \"和棋，局面重復！\"\ndraw_moverule = [\"和棋，\", \"步規則！\"]\ndraw_insuffmat = \"和棋，因棋子不足！\"\ndraw_agreement = \"協議和棋\"\ndraw_generic = \"和棋!\"\naborted = \"游戲中止\"\nopponent_checkmate = \"你輸了，將死！\"\nopponent_time = \"你超時輸了！\"\nopponent_resignation = \"你輸了，對手認輸！\"\nopponent_disconnect = \"你輸了，對手棄權！\"\nopponent_royalcapture = \"你輸了，皇族棋子被捕！\"\nopponent_allroyalscaptured = \"你輸了，所有皇族棋子被捕！\"\nopponent_allpiecescaptured = \"你輸了，全軍覆沒！\"\nopponent_threecheck = \"你輸了，被三步將軍！\"\nopponent_koth = \"你輸了山頂王！\"\nopponent_generic = \"你輸了!\"\nwhite_checkmate = \"白方將死獲勝!\"\nblack_checkmate = \"黑方將死獲勝!\"\nbug_checkmate = \"這是一個錯誤，請報告。游戲以將死結束。\"\nwhite_time = \"白方超時勝\"\nblack_time = \"黑方超時勝!\"\nbug_time = \"這是一個錯誤，請報告！游戲因超時結束。\"\nwhite_royalcapture = \"白方通過捕獲皇族棋子獲勝！\"\nblack_royalcapture = \"黑方通過捕獲皇族棋子獲勝！\"\nbug_royalcapture = \"這是一個錯誤，請報告！游戲因皇族棋子被捕獲而結束。\"\nwhite_allroyalscaptured = \"白方通過吃掉所有皇族棋子獲勝！\"\nblack_allroyalscaptured = \"黑方通過吃掉所有皇族棋子獲勝！\"\nbug_allroyalscaptured = \"這是一個錯誤，請報告！游戲因所有皇族棋子被捕獲而結束。\"\nwhite_allpiecescaptured = \"白方通過吃掉所有棋子獲勝！\"\nblack_allpiecescaptured = \"黑方通過吃掉所有棋子獲勝！\"\nbug_allpiecescaptured = \"這是一個錯誤，請報告！游戲因所有棋子被捕獲而結束。\"\nwhite_threecheck = \"白方通過三步將軍獲勝！\"\nblack_threecheck = \"黑方通過三步將軍獲勝！\"\nbug_threecheck = \"這是一個錯誤，請報告！游戲被三步將軍結束。\"\nwhite_koth = \"白方通過山頂之王獲勝！\"\nblack_koth = \"黑方通過山頂之王獲勝！\"\nbug_koth = \"這是一個錯誤，請報告！游戲被山頂之王結束。\"\nbug_generic = \"這是一個錯誤，請報告！\"\n\n[terms]\ntitle = \"服務條款\"\nwarning = [\"此文件不具有法律約束力。我們隻對英文版本的文件負責。本翻譯僅供一般參考。您可以在此處訪問官方英文版本\", \"這裡\", \"。\"]\nconsent = \"使用本網站即表示您同意遵守以下條款。如果您不同意，您必須立即停止使用本網站。\"\nguardian_consent = \"如果您未滿18歲，您必須獲得父母或法定監護人的同意，才能使用本網站並創建賬戶。\"\nparents_header = \"父母\"\nparents_paragraphs = [\n\"本網站有一個算法，用於禁止用戶將其名字設置為常見的臟話。目前，網站上用戶之間沒有交流方式。\",\n\"目前，會員無法設置自己的個人資料圖片。我們計劃在未來允許此功能，屆時我們將盡最大努力防止不適當的個人資料圖片。\",\n]\nfair_play_header = \"公平游戲\"\nfair_play_paragraph1 = [\"您不能創建超過一個賬戶。如果您希望更改與賬戶關聯的電子郵件地址，請\", \"聯系我們。\"]\nfair_play_paragraph2 = \"為了讓游戲保持有趣和公平，您不得：\"\nfair_play_rules = [\n\"以任何方式修改或操縱代碼，包括但不限於：使用控制台命令、本地覆蓋、自定義腳本、修改HTTP請求等。這樣做可能是為了故意破壞游戲，或給自己帶來優勢。\",\n\"在評級游戲中，接受他人或程序的幫助/建議，以決定應該下什麼棋。（創建引擎是可以的，並且是鼓勵的，但您必須將其使用限制在非評級游戲中。）\",\n\"通過故意輸棋以提升對手的Elo積分，或接受對手故意輸棋以提升自己的Elo積分。這會濫用系統，導致根據您的技能水平產生不准確的評級。\"\n]\ncleanliness_header = \"清潔\"\ncleanliness_rules = [\n\"在網站上使用的所有語言中，您必須保持文明，不得使用粗俗語言或臟話。您不得欺凌、騷擾或威脅他人，或從事任何非法行為。您不得向其他用戶或論壇發送垃圾信息。\",\n\"您不得上傳不適當、暗示性或血腥的圖像作為您的個人資料圖片。這樣做可能會導致您被禁止或終止賬戶。\"\n]\nprivacy_header = \"隱私\"\nprivacy_rules = [\n\"目前，我們收集的唯一個人信息是電子郵件。這是為了驗証用戶的賬戶，並提供在他們請求密碼重置時証明身份的手段。我們不會發送任何促銷電子郵件或優惠。我們不會與任何人共享用戶的電子郵件地址。\",\n\"InfiniteChess.org可能會收集您在網站上使用的數據，包括您的IP地址。這是為了幫助防止來自機器人的攻擊和其他不受歡迎的實體，並保持數據庫中的准確統計信息。這不是您的家庭地址。\",\n\"您在本網站上玩的所有游戲都會成為公共信息。如果您希望保持匿名，請不要與朋友或家人分享您的用戶名。如果這是您的願望，您有責任確保沒有人發現您的用戶名與您的真實身份相關聯。\",\n\"您的賬戶在線狀態以及您上次在網站上活躍的近似時間也是公共信息。\",\n[\"盡管InfiniteChess.org將盡力在其能力范圍內保護每個人的賬戶和個人信息，但在發生黑客攻擊或數據泄露時，您不得向我們提出指控。如果發生數據泄露，用戶將在\", \"新聞\", \"頁面上收到通知。\"],\n\"網站上沒有可購買的內容。我們不收集其他個人信息。\",\n\"要從我們的服務器中刪除您的私人信息，您可以通過個人資料頁面刪除您的賬戶。唯一與您的用戶名有關且我們不會刪除的內容是您的游戲歷史記錄，因為所有游戲都是公開信息。\",\n]\ncookie_header = \"Cookie政策\"\ncookie_paragraphs = [\n\"本網站使用Cookie，Cookie是存儲在您瀏覽器中的小型文本文件，在連接時發送到服務器。使用這些Cookie的目的是：驗証您的登錄會話，驗証您的瀏覽器屬於它所稱的棋局，並存儲用戶的游戲偏好，以便他們在重新訪問網站時可以保留其偏好。該網站不使用第三方Cookie，Cookie不會與外部方共享。\",\n\"Cookie是本網站和游戲正常運行所必需的。如果您不希望網站存儲Cookie，您必須停止使用本網站。您可以進入瀏覽器偏好設置刪除現有的Cookie。繼續使用本網站即表示您同意使用Cookie。\"\n]\nconclusion_header = \"結論\"\nconclusion_paragraphs = [\n\"任何違反這些條款的行為可能導致您被禁止或終止賬戶。InfiniteChess.org希望能夠為每個人提供玩樂的機會！但是，我們保留隨時禁止或終止任何用戶賬戶的權利，原因無需披露。您不得向我們提出指控。\",\n[\"這些服務條款可能隨時修改。您有責任確保您保持最新！當這些服務條款更新時，該信息將發布在\", \"新聞\", \"頁面上。如果在服務條款更新時，您不同意新條款，您必須立即停止使用網站。您可以從您的個人資料頁面刪除賬戶。如果您刪除賬戶，所有您的私人信息和賬戶數據將被刪除，除了與您的用戶名相關的游戲歷史記錄，因為這是公開信息。\"],\n[\"此網站是開源的。隻要您遵循許可條款中規定的條件，您可以復制或分發本網站上的任何內容！\", \"許可條款\", \"。如果此鏈接失效，您有責任找到條款。\"],\n\"我們不能保証網站將100%時間運行。我們也不能保証數據永遠不會被損壞。\",\n\"您不得在網站上從事任何非法活動。\",\n[\"如果您對這些條款有任何疑問，或對網站有任何其他問題，請\", \"通過電子郵件聯系我們！\"]\n]\nupdate = \"(最后更新日期：2024年7月13日。添加了警告，所有玩過的游戲可能成為公共信息，包括您賬戶上次活躍的大致時間。此外，這些條款可能會隨時更新，您有責任確保您保持更新。)\"\nthanks = \"謝謝！\"\n\n[login]\ntitle = \"登錄\"\nusername = \"用戶名:\"\npassword = \"密碼:\"\nforgot_password = [\"忘記了? \", \"給我們發郵件.\"]\nlogin_button = \"登錄\"\n\n[error-pages] # Messages shown on some error pages explaining what went wrong\n400_message = \"收到無效參數。\"\n409_message = [\"可能存在沖突的用戶名或電子郵件。請\", \"重新加載\", \"頁面\"]\n500_message = \"這不應該發生。需要進行一些調試！\"\n\n[news]\ntitle = \"新聞\"\nmore_dev_logs = [\"更多開發者日志在我們的\", \"Discord\", \"和在\", \"chess.com的論壇！\"]\n\n[server.javascript]\nws-invalid_username = \"用戶名無效\"\nws-incorrect_password = \"密碼部隊\"\nws-username_and_password_required = \"需要用戶名和密碼\"\nws-username_and_password_string = \"用戶名和密碼必須是字符串\"\nws-login_failure_retry_in = \"登錄失敗\"\nws-seconds = \"妙\" # unit of time\nws-second = \"妙\" # unit of time\nws-username_length = \"用戶名必須多於3個字母和少於20字母\"\nws-username_letters = \"用戶名隻能包含字母 A-Z 和數字 0-9。\"\nws-username_taken = \"那個用戶名已經被用了。\"\nws-username_bad_word = \"該用戶名包含不允許的詞語。\"\nws-email_too_long = \"您的電子有限太長。\"\nws-email_invalid = \"這個郵箱無效。\"\nws-email_in_use = \"這個郵箱已經被用了。\"\nws-you_are_banned = \"您被封禁了\"\nws-password_length = \"密碼必須多於6個字母，並且少於72個字母。\"\nws-password_format = \"密碼格式不正確\"\nws-password_password = \"密碼禁止是password\"\nws-refresh_token_not_found_logged_out = \"沒有成員擁有該刷新令牌 (已經等處)\"\nws-refresh_token_not_found = \"沒有成員擁有該刷新令牌\"\nws-refresh_token_expired = \"未找到刷新令牌（會話過期）\"\nws-refresh_token_invalid = \"刷新令牌過期或被篡改\"\nws-member_not_found = \"賬戶未找到\"\nws-forbidden_wrong_account = \"禁止。這不是您的賬戶。\"\nws-deleting_account_not_found = \"刪除帳戶失敗。找不到帳戶。\"\nws-server_error = \"抱歉，發生伺服器錯誤！請返回。\"\nws-unable_to_identify_client_ip = \"無法識別客戶端 IP 地址\"\nws-you_are_banned_by_server = \"您被禁用了\"\nws-too_many_requests_to_server = \"請求過多。請稍後再試。\"\nws-bad_request = \"400\"\nws-not_found = \"404\"\nws-forbidden = \"403\"\nws-unauthorized_patron_page = \"未經授權。此頁面僅限會員訪問。\"\nws-username_reserved = \"用戶名已被保留\"\nws-already_in_game = \"已在游戲中\"\nws-server_restarting = \"服務器正在重啟\"\nws-minutes = \"分鐘\"\nws-server_under_maintenance = \"服務器正在維護中。請稍后再試！\"  # Can be changed at will to change the display message.\nws-minute = \"分鐘\"\nws-no_abort_game_over = \"游戲結束無法中止\"\nws-no_abort_after_moves = \"無法在棋步后中止\"\nws-game_aborted_cheating = \"因作弊游戲中止\"\nws-cannot_resign_finished_game = \"無法放棄已完成的游戲\"\nws-invalid_code = \"無效代碼\"\nws-game_aborted = \"游戲中止\"\n"
  },
  {
    "path": "tsconfig.json",
    "content": "// tsconfig.json\n\n{\n  \"compilerOptions\": {\n    \"outDir\": \"./dist\",\n    \"target\": \"ESNext\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"alwaysStrict\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noImplicitAny\": true,\n    \"noImplicitOverride\": true,\n    \"noImplicitReturns\": true,\n    \"noImplicitThis\": true,\n    \"noPropertyAccessFromIndexSignature\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"strict\": true,\n    \"noEmitOnError\": true,\n    \"tsBuildInfoFile\": \"./dist/tsconfig.tsbuildinfo\",\n    \"allowJs\": true // Allows TypeScript to import JavaScript modules.\n    // \"checkJs\": true  // Reports errors in .js files based on JSDoc and inference.\n  },\n  // Tells TypeScript and every tool that uses this file (IntelliSense, ESLint): \"My source code is in the src folder.\"\n  \"exclude\": [\n    \"node_modules\", // Don't look in node_modules\n    \"dist\", // Don't look in the output directory\n    \"dev-utils\"\n  ]\n}\n"
  },
  {
    "path": "vitest.config.ts",
    "content": "// vitest.config.ts\n\nimport { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n\ttest: {\n\t\tglobals: true,\n\t\tenvironment: 'node',\n\t\tsetupFiles: ['src/tests/tests-setup.ts'],\n\t\tinclude: ['**/*.test.ts', '**/*.test.js'],\n\t\texclude: ['node_modules', 'dist'],\n\t},\n});\n"
  }
]